content:
     1→# 05 - Business Logic Specification
     2→
     3→> E-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12
     4→
     5→---
     6→
     7→## Conventions
     8→
     9→| Convention | Detail |
    10→|---|---|
    11→| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |
    12→| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |
    13→| Rounding | Round each line independently, then sum (prevents rounding drift) |
    14→| Database | SQLite - single-writer, WAL mode, no advisory locks needed |
    15→| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |
    16→| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |
    17→
    18→---
    19→
    20→## 1. Multi-Tenancy and Store Isolation
    21→
    22→### 1.1 Tenant Resolution Middleware
    23→
    24→**Class:** `App\Http\Middleware\ResolveStore`
    25→
    26→**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.
    27→
    28→#### Storefront Route Resolution (from request hostname)
    29→
    30→1. Extract hostname from the incoming HTTP request.
    31→2. Look up `store_domains` table where `hostname` matches.
    32→3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.
    33→4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.
    34→5. If hostname is not found in `store_domains`: return HTTP 404.
    35→6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.
    36→
    37→#### Admin Route Resolution (from session)
    38→
    39→1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).
    40→2. Verify the authenticated user has a `store_users` record for that store.
    41→3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.
    42→
    43→```mermaid
    44→flowchart TD
    45→    A[Incoming Request] --> B{Route type?}
    46→    B -->|Storefront| C[Extract hostname]
    47→    C --> D{Hostname in cache?}
    48→    D -->|Yes| E[Load store from cache]
    49→    D -->|No| F[Query store_domains table]
    50→    F --> G{Found?}
    51→    G -->|No| H[Return 404]
    52→    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]
    53→    I --> E
    54→    E --> J{Store suspended?}
    55→    J -->|Yes| K[Return 503 maintenance]
    56→    J -->|No| L[Bind store as singleton, continue]
    57→
    58→    B -->|Admin| M[Read store_id from session]
    59→    M --> N{User has store_users record?}
    60→    N -->|No| O[Return 403]
    61→    N -->|Yes| P{Store suspended?}
    62→    P -->|Yes| Q[Return 403 for mutations]
    63→    P -->|No| L
    64→```
    65→
    66→### 1.2 Store Scoping
    67→
    68→**Trait:** `App\Models\Concerns\BelongsToStore`
    69→**Global Scope:** `App\Models\Scopes\StoreScope`
    70→
    71→The trait applies a global query scope to every model that uses it. The scope adds a `WHERE store_id = <current_store_id>` clause to all queries automatically.
    72→
    73→**Behavior:**
    74→
    75→- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).
    76→- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.
    77→- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.
    78→
    79→**Models that must use this trait:**
    80→products, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.
    81→
    82→### 1.3 Role-Based Access Control
    83→
    84→**Enum:** `App\Enums\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`
    85→
    86→**Permission Matrix:**
    87→
    88→| Permission | Owner | Admin | Staff | Support |
    89→|---|---|---|---|---|
    90→| Manage store settings | Yes | Yes | No | No |
    91→| Manage staff | Yes | Yes | No | No |
    92→| Manage products | Yes | Yes | Yes | No |
    93→| Manage orders | Yes | Yes | Yes | Yes (read-only) |
    94→| Manage discounts | Yes | Yes | Yes | No |
    95→| Manage themes | Yes | Yes | No | No |
    96→| Manage apps | Yes | Yes | No | No |
    97→| View analytics | Yes | Yes | Yes | No |
    98→| Manage shipping/tax | Yes | Yes | No | No |
    99→| Process refunds | Yes | Yes | No | No |
   100→| Create fulfillments | Yes | Yes | Yes | No |
   101→
   102→**Implementation requirements:**
   103→
   104→- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).
   105→- Register gate definitions in a service provider.
   106→- Each policy method receives the User, looks up the user's role for the current store via the `store_users` pivot table, and checks the matrix above.
   107→- Add a helper method on the User model - `roleForStore(store)` - that returns the user's role for a given store or null if the user has no access.
   108→
   109→---
   110→
   111→## 2. Product Lifecycle
   112→
   113→### 2.1 Status Transitions
   114→
   115→**Enum:** `App\Enums\ProductStatus` - values: `draft`, `active`, `archived`
   116→
   117→```mermaid
   118→stateDiagram-v2
   119→    [*] --> Draft : Product created
   120→    Draft --> Active : Publish (requires variant with price > 0, title not empty)
   121→    Draft --> Archived : Archive
   122→    Active --> Archived : Archive (hide from storefront)
   123→    Archived --> Active : Re-publish (same preconditions as Draft->Active)
   124→    Active --> Draft : Revert (BLOCKED if order_lines exist)
   125→    Archived --> Draft : Revert (BLOCKED if order_lines exist)
   126→```
   127→
   128→**Transition rules:**
   129→
   130→| From | To | Preconditions |
   131→|---|---|---|
   132→| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |
   133→| Draft | Archived | None |
   134→| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |
   135→| Archived | Active | Same preconditions as Draft to Active |
   136→| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |
   137→| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |
   138→
   139→**Deletion rules:**
   140→- Only `draft` products with zero `order_lines` references may be hard-deleted.
   141→- Products with existing order references must be archived, never deleted, to preserve order history integrity.
   142→
   143→**Service:** `App\Services\ProductService`
   144→- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.
   145→- On activation: set `published_at` to current time if not already set.
   146→- Dispatch `ProductStatusChanged` event after successful transition.
   147→
   148→### 2.2 Variant and Option Matrix
   149→
   150→**Rules:**
   151→
   152→- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.
   153→- Each option has 1 or more values, stored in `product_option_values`.
   154→- Variants represent the cartesian product of option values (or a manually curated subset).
   155→- When a product has no options, a single "Default" variant is auto-created with `is_default = 1`.
   156→
   157→**Matrix rebuild logic (when options change):**
   158→
   159→```
   160→PROCEDURE rebuildMatrix(product):
   161→    current_option_values = load all option values grouped by option
   162→    desired_combos = cartesianProduct(current_option_values)
   163→    existing_variants = load all variants with their option value sets
   164→
   165→    FOR EACH combo IN desired_combos:
   166→        IF combo matches an existing variant's option value set:
   167→            SKIP (preserve existing variant - prices, SKUs, inventory intact)
   168→        ELSE:
   169→            CREATE new variant with default pricing from first existing variant
   170→
   171→    FOR EACH existing_variant NOT matching any desired combo:
   172→        IF variant has order_line references:
   173→            ARCHIVE variant (set status = 'archived')
   174→        ELSE:
   175→            DELETE variant
   176→```
   177→
   178→**Service:** `App\Services\VariantMatrixService`
   179→
   180→### 2.3 SKU Uniqueness
   181→
   182→- Combination of store_id (derived through the product's store) and SKU must be unique.
   183→- Enforced at application level before save.
   184→- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).
   185→- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.
   186→
   187→### 2.4 Handle/Slug Generation
   188→
   189→**Helper class:** `App\Support\HandleGenerator`
   190→
   191→- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).
   192→- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.
   193→- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.
   194→- Merchants can manually edit the handle; on save, re-validate uniqueness.
   195→- Same logic applies to collections and pages.
   196→
   197→---
   198→
   199→## 3. Inventory Management
   200→
   201→### 3.1 Tracking Model
   202→
   203→Each `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).
   204→
   205→| Field | Description |
   206→|---|---|
   207→| `quantity_on_hand` | Total physical stock count |
   208→| `quantity_reserved` | Units held for active checkouts or pending orders |
   209→| **Available** (computed) | `quantity_on_hand - quantity_reserved` |
   210→
   211→### 3.2 Inventory Policy
   212→
   213→Per-variant policy stored in `inventory_items.policy`:
   214→
   215→| Policy | Behavior |
   216→|---|---|
   217→| `deny` | Block add-to-cart when available <= 0. Storefront displays "Out of stock". |
   218→| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays "Available on backorder". |
   219→
   220→### 3.3 Inventory Operations
   221→
   222→**Service:** `App\Services\InventoryService`
   223→
   224→All operations must execute within a database transaction.
   225→
   226→```mermaid
   227→flowchart LR
   228→    subgraph "Stock Lifecycle"
   229→        A[On Hand] -->|Reserve| B[Reserved]
   230→        B -->|Commit on payment| C[Deducted]
   231→        B -->|Release on expiry| A
   232→        C -->|Restock on refund| A
   233→    end
   234→```
   235→
   236→**Operation definitions:**
   237→
   238→| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |
   239→|---|---|---|---|---|
   240→| **Reserve** | Checkout enters `payment_selected` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |
   241→| **Release** | Checkout expires or is abandoned | No change | -quantity | None |
   242→| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |
   243→| **Restock** | Refund processed with restock flag | +quantity | No change | None |
   244→
   245→### 3.4 Concurrency
   246→
   247→SQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.
   248→
   249→---
   250→
   251→## 4. Cart Service
   252→
   253→### 4.1 Cart Lifecycle
   254→
   255→**Service:** `App\Services\CartService`
   256→
   257→```mermaid
   258→stateDiagram-v2
   259→    [*] --> active : Cart created
   260→    active --> converted : Checkout completed successfully
   261→    active --> abandoned : Cleanup job (14 days inactive)
   262→```
   263→
   264→**Cart identification:**
   265→- Guest users: cart ID stored in session cookie.
   266→- Authenticated customers: cart linked via `customer_id` foreign key.
   267→
   268→The guest cart is bound to the session using the key `cart_id`. When a guest visits the storefront, a cart is created on first add-to-cart and the cart's ID is stored in `session('cart_id')`. On customer login, if a session cart exists, it is merged with any existing customer cart (the session cart's items are added to the customer cart, preferring the higher quantity for duplicate variants). After merge, the session key is cleared.
   269→
   270→**Cart merge on login:**
   271→
   272→```
   273→PROCEDURE mergeGuestCart(guestCart, customerCart):
   274→    FOR EACH line IN guestCart.lines:
   275→        existingLine = customerCart.findLineByVariant(line.variant_id)
   276→        IF existingLine EXISTS:
   277→            existingLine.quantity = MAX(existingLine.quantity, line.quantity)
   278→            RECALCULATE existingLine amounts
   279→        ELSE:
   280→            MOVE line to customerCart
   281→    MARK guestCart as abandoned
   282→    RECALCULATE all line amounts on customerCart
   283→```
   284→
   285→### 4.2 Cart Operations
   286→
   287→**Create cart:**
   288→- Accepts: store, optional customer
   289→- Sets: store_id, currency from store's default_currency, initial cart_version = 1
   290→
   291→**Add line - validations (in order):**
   292→
   293→1. Variant exists and belongs to the current store (through its product).
   294→2. Product has status = `active`.
   295→3. Variant has status = `active`.
   296→4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.
   297→5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.
   298→6. Set `unit_price_amount` from the variant's `price_amount`.
   299→7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.
   300→8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).
   301→9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.
   302→10. Increment `cart_version`.
   303→
   304→**Update quantity:**
   305→- Validate new quantity > 0. If 0, remove the line instead.
   306→- Re-check inventory policy for the new quantity.
   307→- Recalculate line amounts.
   308→- Increment `cart_version`.
   309→
   310→**Remove line:**
   311→- Delete the `cart_lines` record.
   312→- Increment `cart_version`.
   313→
   314→### 4.3 Cart Version (Optimistic Concurrency)
   315→
   316→Every cart mutation increments `cart_version` by 1.
   317→
   318→**For API clients:**
   319→- Client sends `expected_version` with each mutation request.
   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.
   321→- This prevents race conditions when multiple browser tabs or devices modify the same cart.
   322→
   323→**For Livewire/session-based flows:**
   324→- Still implemented for consistency, but less critical since requests are serialized per session.
   325→
   326→### 4.4 Line Amount Calculation
   327→
   328→```
   329→line_subtotal_amount = unit_price_amount * quantity
   330→line_discount_amount = 0  (updated by discount engine during checkout)
   331→line_total_amount    = line_subtotal_amount - line_discount_amount
   332→```
   333→
   334→All values are integers in minor currency units.
   335→
   336→### 4.5 Cart Expiration
   337→
   338→**Job:** `App\Jobs\CleanupAbandonedCarts`
   339→
   340→- Schedule: daily
   341→- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.
   342→- Marks them as `abandoned`.
   343→- Releases any reserved inventory associated with active checkouts for these carts.
   344→
   345→---
   346→
   347→## 5. Pricing Engine
   348→
   349→### 5.1 Calculation Order (deterministic pipeline)
   350→
   351→**Service:** `App\Services\PricingEngine`
   352→
   353→The pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.
   354→
   355→```mermaid
   356→flowchart TD
   357→    A["Step 1: Line Subtotals
   358→    FOR EACH line: subtotal = unit_price * quantity"] --> B["Step 2: Cart Subtotal
   359→    SUM of all line subtotals"]
   360→    B --> C["Step 3: Discount
   361→    Apply discount rules (Section 7)
   362→    Allocate proportionally across qualifying lines"]
   363→    C --> D["Step 4: Discounted Subtotal
   364→    subtotal - total_discount"]
   365→    D --> E["Step 5: Shipping
   366→    Based on selected method and rate rules (Section 9)"]
   367→    E --> F["Step 6: Tax
   368→    Calculated on discounted line amounts
   369→    + shipping if taxable (Section 8)"]
   370→    F --> G["Step 7: Total
   371→    discounted_subtotal + shipping + tax_total"]
   372→```
   373→
   374→### 5.2 Output Structure
   375→
   376→**Value Object:** `App\ValueObjects\PricingResult`
   377→
   378→| Field | Type | Description |
   379→|---|---|---|
   380→| `subtotal` | integer (cents) | Sum of all line subtotals before discount |
   381→| `discount` | integer (cents) | Total discount amount |
   382→| `shipping` | integer (cents) | Shipping cost |
   383→| `taxLines` | array of TaxLine | Individual tax line items |
   384→| `taxTotal` | integer (cents) | Sum of all tax line amounts |
   385→| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |
   386→| `currency` | string | ISO 4217 currency code |
   387→
   388→**Value Object:** `App\ValueObjects\TaxLine`
   389→
   390→| Field | Type | Description |
   391→|---|---|---|
   392→| `name` | string | Tax label (e.g., "State Tax") |
   393→| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |
   394→| `amount` | integer (cents) | Calculated tax amount |
   395→
   396→### 5.3 Rounding Strategy
   397→
   398→- Each line's discount allocation, tax, and total are rounded independently.
   399→- Totals are computed by summing already-rounded line amounts.
   400→- This prevents accumulation of fractional cents across lines.
   401→- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).
   402→
   403→### 5.4 Determinism and Snapshotting
   404→
   405→- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.
   406→- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.
   407→- The snapshot structure mirrors the PricingResult value object.
   408→
   409→---
   410→
   411→## 6. Checkout State Machine
   412→
   413→### 6.1 States
   414→
   415→**Enum:** `App\Enums\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_selected`, `completed`, `expired`
   416→
   417→```mermaid
   418→stateDiagram-v2
   419→    [*] --> started : Checkout created from cart
   420→    started --> addressed : Address provided
   421→    addressed --> shipping_selected : Shipping method chosen
   422→    shipping_selected --> payment_selected : Payment method selected
   423→    payment_selected --> completed : Payment confirmed
   424→    payment_selected --> expired : Timeout (24h)
   425→
   426→    started --> expired : Timeout (24h)
   427→    addressed --> expired : Timeout (24h)
   428→    shipping_selected --> expired : Timeout (24h)
   429→
   430→    note right of addressed : Triggers pricing recalculation
   431→    note right of shipping_selected : Triggers pricing recalculation
   432→    note right of payment_selected : Inventory reserved
   433→    note right of completed : Order created, inventory committed
   434→    note right of expired : Reserved inventory released
   435→```
   436→
   437→### 6.2 Transitions and Validation
   438→
   439→**Service:** `App\Services\CheckoutService`
   440→
   441→#### Transition: started -> addressed
   442→
   443→**Method:** `setAddress(checkout, addressData)`
   444→
   445→**Required data:**
   446→- `email` - valid email address
   447→- `shipping_address` with required fields:
   448→
   449→| Field | Required | Type | Notes |
   450→|---|---|---|---|
   451→| `first_name` | Yes | string | |
   452→| `last_name` | Yes | string | |
   453→| `address1` | Yes | string | |
   454→| `address2` | No | string | |
   455→| `company` | No | string | |
   456→| `city` | Yes | string | |
   457→| `province` | No | string | |
   458→| `province_code` | No | string | Used for zone matching |
   459→| `country` | Yes | string | 2-letter ISO 3166-1 code |
   460→| `postal_code` | Yes | string | |
   461→| `phone` | No | string | |
   462→
   463→**Actions:**
   464→1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).
   465→2. Save email and shipping_address_json on the checkout.
   466→3. Copy shipping address to billing_address_json by default (can be overridden later).
   467→4. Recalculate pricing (shipping zones and tax regions may change based on address).
   468→5. Transition status to `addressed`.
   469→6. Dispatch `CheckoutAddressed` event.
   470→
   471→#### Transition: addressed -> shipping_selected
   472→
   473→**Method:** `setShippingMethod(checkout, shippingRateId)`
   474→
   475→**Requires:**
   476→- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.
   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.
   478→
   479→**Actions:**
   480→1. Resolve available shipping zones for the address (see Section 9).
   481→2. Verify the selected rate belongs to an applicable zone.
   482→3. Save shipping_method_id on the checkout.
   483→4. Recalculate pricing with the selected shipping rate.
   484→5. Transition status to `shipping_selected`.
   485→6. Dispatch `CheckoutShippingSelected` event.
   486→
   487→#### Transition: shipping_selected -> payment_selected
   488→
   489→**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method
   490→
   491→**Requires:**
   492→- All previous data is valid (address, shipping method).
   493→- Totals have been calculated and stored.
   494→- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.
   495→
   496→**Actions:**
   497→1. Store `payment_method` on the checkout record.
   498→2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).
   499→3. Set `expires_at` on the checkout to current time + 24 hours.
   500→4. Transition status to `payment_selected`.
   501→
   502→#### Transition: payment_selected -> completed
   503→
   504→**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order
   505→
   506→**Triggered by:** Customer submitting the payment form (onsite, no external redirect).
   507→
   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.
   509→
   510→**Actions (all within a single database transaction):**
   511→
   512→```
   513→PROCEDURE completeCheckout(checkout, paymentMethodData):
   514→    BEGIN TRANSACTION
   515→
   516→    1. IF order already exists for this checkout:
   517→           RETURN existing order (idempotent)
   518→
   519→    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)
   520→       IF result.success = false:
   521→           Release reserved inventory
   522→           THROW PaymentFailedException(result.errorCode)
   523→
   524→    3. Generate next sequential order number for this store
   525→
   526→    4. DETERMINE order statuses based on payment method:
   527→       IF checkout.payment_method IN ('credit_card', 'paypal'):
   528→           orderStatus = 'paid'
   529→           financialStatus = 'paid'
   530→           paymentStatus = 'captured'
   531→           inventoryAction = 'commit'
   532→       ELSE IF checkout.payment_method = 'bank_transfer':
   533→           orderStatus = 'pending'
   534→           financialStatus = 'pending'
   535→           paymentStatus = 'pending'
   536→           inventoryAction = 'keep_reserved'
   537→
   538→    5. CREATE order record with:
   539→       - All checkout data (addresses, email, totals snapshot)
   540→       - payment_method = checkout.payment_method
   541→       - status = orderStatus, financial_status = financialStatus
   542→       - fulfillment_status = 'unfulfilled'
   543→       - placed_at = now
   544→
   545→    6. FOR EACH cart line:
   546→       CREATE order_line with snapshot data:
   547→       - title_snapshot = product title + variant option labels
   548→       - sku_snapshot = variant SKU at time of purchase
   549→       - unit_price_amount, quantity, total_amount
   550→       - tax_lines_json, discount_allocations_json
   551→
   552→    7. CREATE payment record:
   553→       - Link to order, provider = 'mock', method = checkout.payment_method
   554→       - provider_payment_id = mock reference ID
   555→       - status = paymentStatus, amount = order total
   556→
   557→    8. IF inventoryAction = 'commit':
   558→       FOR EACH cart line:
   559→           CALL InventoryService.commit(variant, quantity)
   560→       // Note: for bank_transfer, inventory stays reserved until admin confirms payment
   561→
   562→    9. IF checkout has a discount code:
   563→       INCREMENT discount's usage_count by 1
   564→
   565→    10. SET cart status = 'converted'
   566→
   567→    11. SET checkout status = 'completed'
   568→
   569→    12. IF inventoryAction = 'commit' AND all order lines are digital:
   570→        Auto-create fulfillment (see Section 11.7)
   571→
   572→    13. DISPATCH OrderCreated event
   573→
   574→    COMMIT TRANSACTION
   575→    RETURN order
   576→```
   577→
   578→#### Transition: Any active state -> expired
   579→
   580→**Triggered by:** `App\Jobs\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).
   581→
   582→**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.
   583→
   584→**Actions:**
   585→1. If inventory was reserved (status was `payment_selected`): release all reserved inventory via InventoryService.release.
   586→2. Transition status to `expired`.
   587→3. Dispatch `CheckoutExpired` event.
   588→
   589→### 6.3 Events
   590→
   591→| Event | Dispatched When |
   592→|---|---|
   593→| `CheckoutAddressed` | Address saved on checkout |
   594→| `CheckoutShippingSelected` | Shipping method selected |
   595→| `CheckoutCompleted` | Payment confirmed and order created |
   596→| `CheckoutExpired` | Checkout expired due to inactivity |
   597→
   598→---
   599→
   600→## 7. Discount Engine
   601→
   602→### 7.1 Discount Types
   603→
   604→**Enum:** `App\Enums\DiscountType` - values: `code`, `automatic`
   605→
   606→| Type | Behavior |
   607→|---|---|
   608→| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |
   609→| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |
   610→
   611→**Stacking rules:**
   612→- Only one code discount per checkout.
   613→- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).
   614→
   615→### Discount Status Lifecycle
   616→
   617→| Status | Description | Transition |
   618→|--------|-------------|------------|
   619→| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |
   620→| `active` | Live and applicable to carts (subject to date range and usage limits). | Automatic transition when `starts_at` is reached (if set). Admin can manually activate. |
   621→| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |
   622→| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |
   623→
   624→**Allowed transitions:**
   625→- `draft` -> `active`
   626→- `active` -> `disabled`
   627→- `active` -> `expired` (automatic)
   628→- `disabled` -> `active`
   629→
   630→### 7.2 Value Types
   631→
   632→**Enum:** `App\Enums\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`
   633→
   634→| Value Type | Calculation | Notes |
   635→|---|---|---|
   636→| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |
   637→| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |
   638→| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |
   639→
   640→### 7.3 Rules
   641→
   642→Rules are stored in `discounts.rules_json` with the following structure:
   643→
   644→| Rule Key | Type | Description |
   645→|---|---|---|
   646→| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |
   647→| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |
   648→| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |
   649→| `customer_eligibility` | string | `"all"` for all customers. Future expansion: `"returning"`, `"specific"`. |
   650→
   651→**Applicability logic:** When both `applicable_product_ids` and `applicable_collection_ids` are set, a product qualifies if it matches **either** condition (union, not intersection). When both are null or empty, the discount applies to the entire order.
   652→
   653→### 7.4 Validation Flow
   654→
   655→**Service:** `App\Services\DiscountService`
   656→
   657→**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object
   658→
   659→```mermaid
   660→flowchart TD
   661→    A[Input: discount code + cart] --> B{Code exists in store?
   662→    Case-insensitive lookup}
   663→    B -->|No| C["Error: discount_not_found"]
   664→    B -->|Yes| D{Status = active?}
   665→    D -->|No| E["Error: discount_expired"]
   666→    D -->|Yes| F{starts_at <= now?}
   667→    F -->|No| G["Error: discount_not_yet_active"]
   668→    F -->|Yes| H{ends_at is null OR ends_at >= now?}
   669→    H -->|No| I["Error: discount_expired"]
   670→    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}
   671→    J -->|Yes| K["Error: discount_usage_limit_reached"]
   672→    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}
   673→    L -->|Yes| M["Error: discount_min_purchase_not_met"]
   674→    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}
   675→    N -->|Yes| O["Error: discount_not_applicable"]
   676→    N -->|No| P["Valid - return discount"]
   677→```
   678→
   679→### 7.5 Error Codes
   680→
   681→| Code | Description |
   682→|---|---|
   683→| `discount_not_found` | Code does not exist in this store |
   684→| `discount_expired` | Past `ends_at` date or status is not active |
   685→| `discount_not_yet_active` | Before `starts_at` date |
   686→| `discount_usage_limit_reached` | `usage_count >= usage_limit` |
   687→| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |
   688→| `discount_not_applicable` | No qualifying products in cart |
   689→
   690→### 7.6 Discount Allocation
   691→
   692→For `percent` and `fixed` discounts with applicable product restrictions:
   693→
   694→```
   695→PROCEDURE allocateDiscount(discount, cartLines):
   696→    qualifyingLines = FILTER cartLines where product matches applicable_product_ids
   697→                      OR product belongs to applicable_collection_ids
   698→    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)
   699→
   700→    IF discount.value_type = 'percent':
   701→        totalDiscount = qualifyingSubtotal * discount.value_amount / 100
   702→    ELSE IF discount.value_type = 'fixed':
   703→        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)
   704→
   705→    remainingDiscount = totalDiscount
   706→
   707→    FOR EACH qualifyingLine (except last):
   708→        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)
   709→        remainingDiscount -= lineDiscount
   710→        SET line.line_discount_amount = lineDiscount
   711→
   712→    // Last line gets the remainder (largest-remainder method)
   713→    SET lastLine.line_discount_amount = remainingDiscount
   714→
   715→    RECALCULATE line_total_amount for each affected line
   716→```
   717→
   718→**Storage of allocations:**
   719→- During checkout: update `cart_lines.line_discount_amount`.
   720→- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.
   721→
   722→### 7.7 Usage Tracking
   723→
   724→When an order is successfully created with a discount code:
   725→- Increment `discounts.usage_count` by 1.
   726→- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).
   727→
   728→---
   729→
   730→## 8. Tax Calculation
   731→
   732→### 8.1 Tax Provider Architecture
   733→
   734→**Interface:** `App\Contracts\TaxProvider`
   735→
   736→- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.
   737→- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.
   738→- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.
   739→
   740→**Service:** `App\Services\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.
   741→
   742→**Implementations:**
   743→- `App\Services\Tax\ManualTaxProvider` - default, uses manually configured rates
   744→- `App\Services\Tax\StripeTaxProvider` - stub for Stripe Tax API integration
   745→
   746→### 8.2 Manual Tax Mode
   747→
   748→**Rate resolution:**
   749→1. Match shipping address against shipping zones (same zone matching algorithm as shipping - see Section 9.1).
   750→2. Use the tax rate defined in `tax_settings.config_json` for the matched zone, or the global default rate.
   751→3. Rate stored as integer basis points (e.g., 1900 = 19.00%).
   752→
   753→**Calculation per line:**
   754→
   755→```
   756→line_tax = ROUND(line_total_after_discount * rate / 10000)
   757→```
   758→
   759→Round each line's tax independently, then sum for the total tax amount.
   760→
   761→### 8.3 Prices Include Tax
   762→
   763→Controlled by `tax_settings.prices_include_tax` (boolean).
   764→
   765→#### Tax-inclusive pricing (prices_include_tax = true)
   766→
   767→- Displayed prices on the storefront are gross (include tax).
   768→- Subtotal shown to customer is gross.
   769→- Tax is "extracted" from the gross amount:
   770→
   771→```
   772→net_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)
   773→tax = gross_amount - net_amount
   774→```
   775→
   776→> **Note:** Integer division (truncation toward zero) is used rather than rounding to ensure deterministic tax extraction. This matches PHP's `intdiv()` function.
   777→
   778→**Example:** Price = 1190 cents, rate = 1900 (19%).
   779→- net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000
   780→- tax = 1190 - 1000 = 190
   781→
   782→#### Tax-exclusive pricing (prices_include_tax = false)
   783→
   784→- Displayed prices are net (excluding tax).
   785→- Tax added on top:
   786→
   787→```
   788→tax = ROUND(net_amount * rate / 10000)
   789→total = net_amount + tax
   790→```
   791→
   792→**Example:** Price = 1000 cents, rate = 1900.
   793→- tax = ROUND(1000 * 1900 / 10000) = 190
   794→- total = 1190
   795→
   796→### 8.4 Stripe Tax Provider (Stub)
   797→
   798→**Implementation:** `App\Services\Tax\StripeTaxProvider`
   799→
   800→- Sends line items and shipping address to Stripe Tax API.
   801→- Stores the full response in `checkouts.tax_provider_snapshot_json` for audit.
   802→- Fallback behavior (configurable per store in `tax_settings.config_json`):
   803→
   804→| Fallback Setting | Behavior |
   805→|---|---|
   806→| `"block"` | Block checkout progression if Stripe Tax API errors |
   807→| `"allow"` | Allow checkout to proceed without tax (log a warning) |
   808→
   809→### 8.5 Discount-Before-Tax Semantics
   810→
   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.
   812→
   813→For tax-inclusive pricing (`prices_include_tax = true`): The discount is subtracted from the gross (tax-inclusive) subtotal. Tax is then extracted from the post-discount gross amount: `tax = post_discount_gross * rate / (1 + rate)`. This means the tax portion decreases proportionally when a discount is applied.
   814→
   815→**Calculation order within the pricing pipeline:**
   816→1. Calculate line subtotals.
   817→2. Apply discount allocations to lines.
   818→3. Calculate tax on discounted line amounts.
   819→4. Sum everything for the total.
   820→
   821→---
   822→
   823→## 9. Shipping Calculation
   824→
   825→### 9.1 Zone Matching
   826→
   827→**Service:** `App\Services\ShippingCalculator`
   828→
   829→**Zone matching algorithm:**
   830→
   831→```
   832→PROCEDURE getMatchingZone(address, store):
   833→    zones = load all shipping zones for store
   834→
   835→    bestMatch = null
   836→    bestSpecificity = -1
   837→
   838→    FOR EACH zone IN zones:
   839→        countryMatch = address.country_code IN zone.countries_json
   840→        regionMatch = address.province_code IN zone.regions_json
   841→
   842→        IF countryMatch AND regionMatch:
   843→            specificity = 2  // Most specific
   844→        ELSE IF countryMatch:
   845→            specificity = 1  // Country-only match
   846→        ELSE:
   847→            CONTINUE  // No match
   848→
   849→        IF specificity > bestSpecificity:
   850→            bestMatch = zone
   851→            bestSpecificity = specificity
   852→        ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:
   853→            bestMatch = zone  // Tie-break: lowest ID wins
   854→
   855→    RETURN bestMatch  // null if no zone matches
   856→```
   857→
   858→**When no zone matches:** The address is unserviceable. Block checkout progression with error "Cannot ship to this address".
   859→
   860→### 9.2 Rate Types
   861→
   862→For the matched zone, return all active shipping rates (`is_active = 1`). Calculate cost for each:
   863→
   864→#### Flat rate
   865→
   866→Config structure: `{"amount": 500}`
   867→
   868→- Cost = fixed amount regardless of cart contents.
   869→
   870→#### Weight-based rate
   871→
   872→Config structure: `{"ranges": [{"min_g": 0, "max_g": 1000, "amount": 500}, {"min_g": 1001, "max_g": 5000, "amount": 1000}]}`
   873→
   874→```
   875→PROCEDURE calculateWeightRate(config, cart):
   876→    totalWeight = 0
   877→    FOR EACH line IN cart.lines:
   878→        IF line.variant.requires_shipping = true:
   879→            totalWeight += line.variant.weight_g * line.quantity
   880→
   881→    FOR EACH range IN config.ranges:
   882→        IF range.min_g <= totalWeight AND totalWeight <= range.max_g:
   883→            RETURN range.amount
   884→
   885→    RETURN null  // Rate not available for this weight
   886→```
   887→
   888→#### Price-based rate
   889→
   890→Config structure: `{"ranges": [{"min_amount": 0, "max_amount": 5000, "amount": 500}, {"min_amount": 5001, "amount": 0}]}`
   891→
   892→```
   893→PROCEDURE calculatePriceRate(config, cartSubtotal):
   894→    FOR EACH range IN config.ranges:
   895→        IF range.min_amount <= cartSubtotal:
   896→            IF range.max_amount IS NOT SET OR cartSubtotal <= range.max_amount:
   897→                RETURN range.amount
   898→
   899→    RETURN null  // Rate not available for this subtotal
   900→```
   901→
   902→A range without `max_amount` matches all subtotals above `min_amount` (useful for "free shipping over X" rules).
   903→
   904→#### Carrier-calculated rate (stub)
   905→
   906→Config structure: `{"carrier": "ups", "service": "ground"}`
   907→
   908→- Placeholder for future carrier API integration.
   909→- Returns a hardcoded rate or error until implemented.
   910→
   911→### 9.3 Requires Shipping Check
   912→
   913→- If no line in the cart has `requires_shipping = true` on its variant, the shipping step is skipped entirely.
   914→- shipping_amount = 0 in the pricing result.
   915→- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).
   916→
   917→**Mixed carts** (some physical, some digital):
   918→- Shipping is calculated only for physical items' weight.
   919→- Digital items are excluded from weight calculations.
   920→
   921→### 9.4 Free Shipping Discount Override
   922→
   923→If a validated discount has `value_type = 'free_shipping'`:
   924→- Override the calculated shipping amount to 0.
   925→- The shipping method is still selected (for fulfillment routing), but the customer is not charged.
   926→
   927→---
   928→
   929→## 10. Payment Processing (Mock PSP)
   930→
   931→### 10.1 Mock Payment Provider Architecture
   932→
   933→The platform uses a **Mock Payment Service Provider (PSP)** instead of real payment gateways. All payment processing happens in-process with no external API calls, no webhooks, and no redirects. This enables a fully functional checkout flow for development, testing, and demonstration purposes.
   934→
   935→**Interface:** `App\Contracts\PaymentProvider`
   936→
   937→Must implement:
   938→- `charge(checkout, paymentMethodData)` - returns a PaymentResult value object (success/failure, reference ID)
   939→- `refund(payment, amount)` - initiates a mock refund, returns a RefundResult
   940→
   941→**Service:** `App\Services\PaymentService` - orchestrates payment processing, delegates to MockPaymentProvider.
   942→
   943→**Implementation:** `App\Services\Payment\MockPaymentProvider`
   944→
   945→### 10.2 Supported Payment Methods
   946→
   947→**Enum:** `App\Enums\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)
   948→
   949→| Method | Behavior | Capture Timing |
   950→|---|---|---|
   951→| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |
   952→| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |
   953→| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |
   954→
   955→### 10.3 Magic Card Numbers (Credit Card Testing)
   956→
   957→When payment_method is `credit_card`, the mock provider inspects the card number submitted in the checkout form to determine the outcome:
   958→
   959→| Card Number | Outcome | PaymentResult |
   960→|---|---|---|
   961→| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |
   962→| `4000 0000 0000 0002` | Decline | `success = false`, error = `card_declined` |
   963→| `4000 0000 0000 9995` | Insufficient funds | `success = false`, error = `insufficient_funds` |
   964→| Any other valid-looking number | Success | `success = true`, status = `captured` |
   965→
   966→The mock provider does **not** validate card expiry, CVC, or cardholder name. These fields exist in the UI for realism but are ignored by the backend.
   967→
   968→### 10.4 Mock Payment Flow
   969→
   970→```mermaid
   971→sequenceDiagram
   972→    participant Customer
   973→    participant Storefront
   974→    participant Backend
   975→    participant MockPSP
   976→
   977→    Customer->>Storefront: Select payment method + enter details
   978→    Storefront->>Backend: POST /checkouts/{id}/pay (payment_method, card details if applicable)
   979→    Backend->>Backend: Reserve inventory (if not already reserved)
   980→    Backend->>MockPSP: charge(checkout, paymentMethodData)
   981→
   982→    alt Credit Card / PayPal (instant capture)
   983→        MockPSP-->>Backend: PaymentResult (success/failure)
   984→        alt Success
   985→            Backend->>Backend: Create order (financial_status = paid)
   986→            Backend->>Backend: Commit inventory, mark cart converted
   987→            Backend-->>Storefront: 200 OK (order confirmation)
   988→        else Failure (decline / insufficient funds)
   989→            Backend->>Backend: Release reserved inventory
   990→            Backend-->>Storefront: 422 (error code + message)
   991→        end
   992→    else Bank Transfer (deferred)
   993→        MockPSP-->>Backend: PaymentResult (success = true, status = pending)
   994→        Backend->>Backend: Create order (financial_status = pending)
   995→        Backend->>Backend: Inventory remains reserved (not committed)
   996→        Backend-->>Storefront: 200 OK (order confirmation with bank transfer instructions)
   997→    end
   998→```
   999→
  1000→### 10.5 Payment State Machine
  1001→
  1002→**Enum:** `App\Enums\PaymentStatus` - values: `pending`, `captured`, `failed`, `refunded`
  1003→
  1004→```mermaid
  1005→stateDiagram-v2
  1006→    [*] --> captured : Credit card / PayPal (success)
  1007→    [*] --> failed : Credit card (decline)
  1008→    [*] --> pending : Bank transfer (awaiting payment)
  1009→    pending --> captured : Admin confirms payment received
  1010→    pending --> failed : Auto-cancel job (unpaid timeout)
  1011→    captured --> refunded : Full refund processed
  1012→```
  1013→
  1014→### 10.6 Bank Transfer Instructions
  1015→
  1016→When `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:
  1017→
  1018→| Field | Value |
  1019→|---|---|
  1020→| Bank name | Mock Bank AG |
  1021→| IBAN | DE89 3704 0044 0532 0130 00 |
  1022→| BIC | COBADEFFXXX |
  1023→| Reference | Order number (e.g., #1001) |
  1024→| Amount | Order total formatted in EUR |
  1025→
  1026→These are static mock values. No real bank interaction occurs.
  1027→
  1028→### 10.7 Admin: Confirm Payment (Bank Transfer)
  1029→
  1030→Admin users can confirm that a bank transfer payment has been received via a "Confirm Payment" button on the order detail page (visible only when `payment_method = bank_transfer` AND `financial_status = pending`).
  1031→
  1032→**Action flow:**
  1033→1. Admin clicks "Confirm Payment" button.
  1034→2. Backend updates `payments.status` from `pending` to `captured`.
  1035→3. Backend updates `orders.financial_status` from `pending` to `paid`.
  1036→4. Backend updates `orders.status` from `pending` to `paid`.
  1037→5. Backend calls `InventoryService.commit()` for each order line (converts reserved stock to committed).
  1038→6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).
  1039→7. Dispatch `OrderPaid` event.
  1040→
  1041→### 10.8 Auto-Cancel Unpaid Bank Transfer Orders
  1042→
  1043→**Job:** `App\Jobs\CancelUnpaidBankTransferOrders`
  1044→
  1045→- Schedule: daily
  1046→- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.
  1047→- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.
  1048→
  1049→**Cancel action:**
  1050→1. Release reserved inventory via `InventoryService.release()`.
  1051→2. Update `orders.financial_status` to `voided`.
  1052→3. Update `orders.status` to `cancelled`.
  1053→4. Update `payments.status` to `failed`.
  1054→5. Dispatch `OrderCancelled` event.
  1055→
  1056→---
  1057→
  1058→## 11. Order Management
  1059→
  1060→### 11.1 Order Creation (from Completed Checkout)
  1061→
  1062→See Section 6.2 "Transition: payment_selected -> completed" for the full procedure. The order creation is the central action within the completeCheckout transaction.
  1063→
  1064→**Key requirements for the order record:**
  1065→- Snapshot all checkout data (addresses, email, totals) at time of order creation.
  1066→- Order lines must contain snapshot data (`title_snapshot`, `sku_snapshot`) so that order history survives product changes.
  1067→- The mock payment response is encrypted before storage.
  1068→- The entire operation must be atomic (single database transaction).
  1069→- The `payment_method` from the checkout is copied to the order record.
  1070→
  1071→**Payment-method-specific order creation:**
  1072→
  1073→| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |
  1074→|---|---|---|---|---|
  1075→| `credit_card` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |
  1076→| `paypal` | `paid` | `paid` | Commit (on_hand and reserved both decremented) | `captured` |
  1077→| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |
  1078→
  1079→### 11.2 Order Numbering
  1080→
  1081→- Sequential per store, starting at 1001.
  1082→- Format: configurable prefix (default: `#`) stored in `store_settings.settings_json` key `order_number_prefix`.
  1083→- Generation: within the order creation transaction, find the maximum existing order number for the store and increment by 1.
  1084→- Since this runs inside a SQLite transaction, there is no risk of duplicate numbers.
  1085→
  1086→### 11.3 Order Statuses
  1087→
  1088→Three independent status dimensions tracked on each order:
  1089→
  1090→**Order status** (`orders.status`):
  1091→
  1092→| Status | Description |
  1093→|---|---|
  1094→| `pending` | Order created, awaiting processing |
  1095→| `paid` | Payment confirmed |
  1096→| `fulfilled` | All items shipped |
  1097→| `cancelled` | Order cancelled before fulfillment |
  1098→| `refunded` | Fully refunded |
  1099→
  1100→**Financial status** (`orders.financial_status`):
  1101→
  1102→| Status | Description |
  1103→|---|---|
  1104→| `pending` | Payment not yet confirmed |
  1105→| `authorized` | Payment authorized but not captured |
  1106→| `paid` | Payment captured successfully |
  1107→| `partially_refunded` | Some items or partial amount refunded |
  1108→| `refunded` | Fully refunded |
  1109→| `voided` | Authorization voided (no capture occurred) |
  1110→
  1111→**Fulfillment status** (`orders.fulfillment_status`):
  1112→
  1113→| Status | Description |
  1114→|---|---|
  1115→| `unfulfilled` | No items have been shipped |
  1116→| `partial` | Some items shipped, others pending |
  1117→| `fulfilled` | All items shipped |
  1118→
  1119→### 11.4 Refund Processing
  1120→
  1121→**Service:** `App\Services\RefundService`
  1122→
  1123→**Input (RefundRequest):**
  1124→
  1125→| Field | Type | Required | Description |
  1126→|---|---|---|---|
  1127→| `amount` | integer (cents) | No | Custom refund amount. If not set, calculated from lines. |
  1128→| `lines` | map of order_line_id to quantity | No | Which lines and quantities to refund (for line-level refunds). |
  1129→| `reason` | string | No | Text reason for the refund. |
  1130→| `restock` | boolean | No | Whether to restock inventory for refunded items. |
  1131→
  1132→**Process:**
  1133→
  1134→```
  1135→PROCEDURE processRefund(order, request):
  1136→    1. Calculate remaining refundable amount:
  1137→       refundable = order.total_amount - SUM(existing refunds for this order)
  1138→
  1139→    2. IF request.amount is set:
  1140→           VALIDATE request.amount <= refundable
  1141→       ELSE IF request.lines is set:
  1142→           CALCULATE amount from specified lines and quantities
  1143→       ELSE:
  1144→           amount = refundable (full refund)
  1145→
  1146→    3. CALL PaymentProvider.refund(order's payment, amount)
  1147→
  1148→    4. CREATE Refund record with status = 'pending'
  1149→
  1150→    5. On provider confirmation:
  1151→       UPDATE refund status = 'processed'
  1152→
  1153→    6. Calculate total refunded for this order:
  1154→       IF total refunded = order.total_amount:
  1155→           SET order.financial_status = 'refunded'
  1156→           SET order.status = 'refunded'
  1157→       ELSE:
  1158→           SET order.financial_status = 'partially_refunded'
  1159→
  1160→    7. IF request.restock = true:
  1161→       FOR EACH refunded line and quantity:
  1162→           CALL InventoryService.restock(variant, quantity)
  1163→
  1164→    8. DISPATCH OrderRefunded event
  1165→```
  1166→
  1167→### 11.5 Fulfillment
  1168→
  1169→**Service:** `App\Services\FulfillmentService`
  1170→
  1171→#### Fulfillment Guard
  1172→
  1173→**CRITICAL:** A fulfillment cannot be created unless the order's `financial_status` is `paid` or `partially_refunded`. This prevents shipping goods before payment is confirmed.
  1174→
  1175→| `financial_status` | Fulfillment Allowed? | Reason |
  1176→|---|---|---|
  1177→| `pending` | **No** | Payment not yet received (e.g., bank transfer awaiting confirmation) |
  1178→| `authorized` | **No** | Payment authorized but not captured |
  1179→| `paid` | **Yes** | Payment confirmed |
  1180→| `partially_refunded` | **Yes** | Payment confirmed, partial refund does not block shipment |
  1181→| `refunded` | **No** | Fully refunded, nothing to ship |
  1182→| `voided` | **No** | Payment voided / cancelled |
  1183→
  1184→When fulfillment is blocked by the guard, the admin UI shows an info callout: "Fulfillment cannot be created until payment is confirmed." The "Create fulfillment" button is disabled.
  1185→
  1186→#### Creating a Fulfillment
  1187→
  1188→Input:
  1189→- `lines` - map of order_line_id to quantity, specifying which items are in this shipment
  1190→- `trackingData` - tracking_company, tracking_number, tracking_url
  1191→
  1192→**Process:**
  1193→
  1194→```
  1195→PROCEDURE createFulfillment(order, lines, trackingData):
  1196→    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')
  1197→       IF NOT: throw FulfillmentGuardException
  1198→
  1199→    2. FOR EACH line in request:
  1200→       fulfilledSoFar = SUM(fulfillment_lines quantities for this order_line)
  1201→       unfulfilled = order_line.quantity - fulfilledSoFar
  1202→       VALIDATE requested quantity <= unfulfilled
  1203→
  1204→    3. CREATE Fulfillment record with status = 'pending' and tracking data
  1205→
  1206→    4. CREATE fulfillment_lines for each order line and quantity
  1207→
  1208→    5. Determine new fulfillment status:
  1209→       FOR EACH order_line in order:
  1210→           totalFulfilled = SUM(all fulfillment_lines for this order_line)
  1211→           IF totalFulfilled < order_line.quantity:
  1212→               allFulfilled = false
  1213→
  1214→       IF allFulfilled:
  1215→           SET order.fulfillment_status = 'fulfilled'
  1216→           SET order.status = 'fulfilled'
  1217→       ELSE:
  1218→           SET order.fulfillment_status = 'partial'
  1219→
  1220→    6. DISPATCH FulfillmentCreated event
  1221→```
  1222→
  1223→#### Fulfillment Status Transitions (Admin Button Actions)
  1224→
  1225→Fulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.
  1226→
  1227→```mermaid
  1228→stateDiagram-v2
  1229→    [*] --> pending : Fulfillment created (admin clicks "Create fulfillment")
  1230→    pending --> shipped : Admin clicks "Mark as shipped"
  1231→    shipped --> delivered : Admin clicks "Mark as delivered"
  1232→
  1233→    note right of pending : Fulfillment record created with tracking data
  1234→    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer
  1235→    note right of delivered : Sets delivered_at timestamp
  1236→```
  1237→
  1238→**Admin buttons on each fulfillment card:**
  1239→
  1240→| Button | Shown When | Action |
  1241→|---|---|---|
  1242→| "Mark as shipped" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |
  1243→| "Mark as delivered" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |
  1244→
  1245→When transitioning to `shipped`:
  1246→- Set `shipped_at` timestamp.
  1247→- Dispatch `FulfillmentShipped` event.
  1248→- Send email notification to customer with tracking information.
  1249→
  1250→When transitioning to `delivered`:
  1251→- Dispatch `FulfillmentDelivered` event.
  1252→
  1253→### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method
  1254→
  1255→The fulfillment guard interacts with inventory differently depending on the payment method:
  1256→
  1257→| Payment Method | At Order Creation | At Payment Confirmation | At Fulfillment |
  1258→|---|---|---|---|
  1259→| `credit_card` / `paypal` | Inventory committed (on_hand decremented, reserved decremented) | N/A (already committed) | No inventory change |
  1260→| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |
  1261→
  1262→For bank transfer orders, inventory is held in a reserved state until the admin confirms payment. If the order is auto-cancelled (see Section 10.8), the reserved inventory is released back.
  1263→
  1264→### 11.7 Auto-Fulfillment for Digital Products
  1265→
  1266→When a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):
  1267→
  1268→1. Check if **all** order lines reference variants with `requires_shipping = false` (digital products).
  1269→2. If yes: automatically create a fulfillment record covering all order lines:
  1270→   - `status = 'delivered'` (skip pending and shipped)
  1271→   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`
  1272→   - `shipped_at = now`
  1273→   - Set `order.fulfillment_status = 'fulfilled'`
  1274→   - Set `order.status = 'fulfilled'`
  1275→3. If the order contains a mix of physical and digital products: do **not** auto-fulfill. The admin must create fulfillments manually (they can fulfill digital items immediately and physical items when shipped).
  1276→
  1277→This ensures digital product orders are immediately marked as complete after payment.
  1278→
  1279→### 11.6 Order Export
  1280→
  1281→`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.
  1282→
  1283→**CSV columns:** `order_number`, `created_at`, `status`, `financial_status`, `fulfillment_status`, `customer_email`, `customer_name`, `subtotal_amount`, `discount_amount`, `shipping_amount`, `tax_amount`, `total_amount`, `currency`, `shipping_method`, `tracking_number`
  1284→
  1285→All monetary values are exported in minor units (cents). Dates use ISO-8601 format. The response uses `Content-Type: text/csv` with `Content-Disposition: attachment; filename="orders-{date}.csv"`.
  1286→
  1287→---
  1288→
  1289→## 12. Customer Accounts
  1290→
  1291→### 12.1 Registration
  1292→
  1293→**Service:** `App\Services\CustomerService`
  1294→
  1295→**Required fields:**
  1296→
  1297→| Field | Validation |
  1298→|---|---|
  1299→| `name` | Required, string |
  1300→| `email` | Required, valid email, unique per store |
  1301→| `password` | Required, minimum 8 characters |
  1302→| `marketing_opt_in` | Optional, boolean, default false |
  1303→
  1304→- Email uniqueness enforced by the database unique index on `(store_id, email)`.
  1305→- Password hashed using Laravel's Hash facade (bcrypt by default).
  1306→
  1307→### 12.2 Authentication
  1308→
  1309→**Separate auth guard:** `customer`
  1310→
  1311→Requirements:
  1312→- Configure a dedicated `customer` guard in auth config, using session driver.
  1313→- Configure a dedicated `customers` provider using the Customer model.
  1314→- Login: email + password, scoped to the current store.
  1315→- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.
  1316→
  1317→**Store isolation:** Customer accounts are per-store. The same email address can exist in different stores as separate accounts. Authentication always scopes the query to the current store.
  1318→
  1319→### 12.3 Data Access
  1320→
  1321→- Customers can only view their own orders (scoped by customer_id match).
  1322→- Customers can only manage their own addresses (scoped by customer_id match).
  1323→- All customer queries include the StoreScope global scope for tenant isolation.
  1324→
  1325→### 12.4 Guest Cart to Customer Linking
  1326→
  1327→```
  1328→PROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):
  1329→    existingCustomer = find customer by email in store
  1330→    IF existingCustomer:
  1331→        LINK order to existingCustomer
  1332→    ELSE:
  1333→        CREATE new customer with:
  1334→            - email = checkoutEmail
  1335→            - password_hash = null (guest customer)
  1336→            - store_id = storeId
  1337→        LINK order to new customer
  1338→
  1339→    // Guest customer can later "claim" their account
  1340→    // by setting a password via registration or account-claim flow
  1341→```
  1342→
  1343→---
  1344→
  1345→## 13. Webhook Delivery System
  1346→
  1347→### 13.1 Subscription Model
  1348→
  1349→Stores (or app installations) subscribe to event types via `webhook_subscriptions`.
  1350→
  1351→**Supported event types:**
  1352→
  1353→| Event Type | Triggered By |
  1354→|---|---|
  1355→| `order.created` | OrderCreated event |
  1356→| `order.paid` | Payment captured successfully |
  1357→| `order.fulfilled` | All order lines fulfilled |
  1358→| `order.refunded` | Refund processed |
  1359→| `product.created` | New product saved |
  1360→| `product.updated` | Product modified |
  1361→| `product.deleted` | Product deleted or archived |
  1362→| `checkout.completed` | Checkout successfully completed |
  1363→
  1364→### 13.2 Delivery Process
  1365→
  1366→**Listener:** `App\Listeners\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.
  1367→
  1368→**Job:** `App\Jobs\DeliverWebhook`
  1369→
  1370→```mermaid
  1371→sequenceDiagram
  1372→    participant DomainEvent
  1373→    participant Listener
  1374→    participant Queue
  1375→    participant Job
  1376→    participant TargetURL
  1377→
  1378→    DomainEvent->>Listener: Event fired (e.g., OrderCreated)
  1379→    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active
  1380→    loop For each matching subscription
  1381→        Listener->>Queue: Dispatch DeliverWebhook job
  1382→    end
  1383→    Queue->>Job: Process job
  1384→    Job->>Job: Serialize event payload as JSON
  1385→    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)
  1386→    Job->>TargetURL: HTTP POST with headers and payload
  1387→    TargetURL-->>Job: Response
  1388→    Job->>Job: Record result in webhook_deliveries
  1389→```
  1390→
  1391→**Required HTTP headers on delivery:**
  1392→
  1393→| Header | Value |
  1394→|---|---|
  1395→| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |
  1396→| `X-Platform-Event` | Event type string (e.g., `order.created`) |
  1397→| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |
  1398→| `X-Platform-Timestamp` | Unix timestamp of the event |
  1399→| `Content-Type` | `application/json` |
  1400→
  1401→### 13.3 Retry Logic
  1402→
  1403→On non-2xx response or connection timeout:
  1404→
  1405→| Attempt | Backoff Delay |
  1406→|---|---|
  1407→| 1 (initial) | Immediate |
  1408→| 2 | 1 minute |
  1409→| 3 | 5 minutes |
  1410→| 4 | 30 minutes |
  1411→| 5 | 2 hours |
  1412→| 6 (final) | 12 hours |
  1413→
  1414→After 5 retry attempts (6 total), mark the delivery as `failed` (dead letter).
  1415→
  1416→Use Laravel's built-in job retry mechanism with the backoff delays configured as: 60, 300, 1800, 7200, 43200 seconds.
  1417→
  1418→### 13.4 Circuit Breaker
  1419→
  1420→- Track consecutive failures per subscription (across different events).
  1421→- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.
  1422→- Log a warning and (future) send an admin notification.
  1423→- Manual re-enable required by the merchant through the admin UI.
  1424→- On each successful delivery, reset the consecutive failure counter to 0.
  1425→
  1426→**Service:** `App\Services\WebhookService`
  1427→
  1428→---
  1429→
  1430→## 14. Analytics
  1431→
  1432→### 14.1 Event Ingestion
  1433→
  1434→**Endpoint:** `POST /api/storefront/v1/analytics/events`
  1435→
  1436→**Request structure:**
  1437→
  1438→| Field | Type | Required | Description |
  1439→|---|---|---|---|
  1440→Events are submitted in batches:
  1441→
  1442→```json
  1443→{
  1444→  "events": [
  1445→    {
  1446→      "type": "page_view",
  1447→      "session_id": "...",
  1448→      "client_event_id": "...",
  1449→      "occurred_at": "2025-01-15T10:30:00Z",
  1450→      "properties": { ... }
  1451→    }
  1452→  ]
  1453→}
  1454→```
  1455→
  1456→**Event fields:**
  1457→
  1458→| Field | Type | Required | Description |
  1459→|---|---|---|---|
  1460→| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |
  1461→| `session_id` | string | Yes | Client session identifier |
  1462→| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |
  1463→| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |
  1464→| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |
  1465→
  1466→**Deduplication:** Events are deduplicated by `client_event_id` within the store scope. If a `client_event_id` has already been recorded for the current store (enforced by the unique index on `store_id, client_event_id`), the duplicate event is silently dropped.
  1467→
  1468→**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.
  1469→
  1470→### 14.2 Aggregation
  1471→
  1472→**Job:** `App\Jobs\AggregateAnalytics`
  1473→
  1474→- Schedule: daily at 01:00 UTC
  1475→- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.
  1476→
  1477→**Aggregated metrics:**
  1478→
  1479→| Metric | Calculation |
  1480→|---|---|
  1481→| `orders_count` | Count of `checkout_completed` events for the day |
  1482→| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |
  1483→| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |
  1484→| `visits_count` | Count of distinct `session_id` values with `page_view` events |
  1485→| `add_to_cart_count` | Count of `add_to_cart` events |
  1486→| `checkout_started_count` | Count of `checkout_started` events |
  1487→
  1488→---
  1489→
  1490→## 15. Media Processing
  1491→
  1492→### 15.1 Upload Flow
  1493→
  1494→```mermaid
  1495→flowchart TD
  1496→    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]
  1497→    B --> C[Create product_media record with status = processing]
  1498→    C --> D[Dispatch ProcessMediaUpload job to queue]
  1499→    D --> E{Processing succeeds?}
  1500→    E -->|Yes| F[Store resized versions, set status = ready]
  1501→    E -->|No| G[Set status = failed, log error, keep original]
  1502→```
  1503→
  1504→### 15.2 Processing
  1505→
  1506→**Job:** `App\Jobs\ProcessMediaUpload`
  1507→
  1508→**Resize targets:**
  1509→
  1510→| Key | Max Dimensions | Use Case |
  1511→|---|---|---|
  1512→| `thumbnail` | 150x150 px | Admin grid, small previews |
  1513→| `small` | 300x300 px | Product cards, listings |
  1514→| `medium` | 600x600 px | Product detail page |
  1515→| `large` | 1200x1200 px | Full-size product image, zoom |
  1516→
  1517→**Processing rules:**
  1518→- Maintain aspect ratio (contain within target dimensions, no cropping).
  1519→- Generate WebP version alongside the original format if the server supports it.
  1520→- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`
  1521→- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.
  1522→- On failure: set `status = 'failed'` and log the error. Do not delete the original file.
  1523→- Maximum 3 retry attempts before permanent failure.
  1524→
  1525→### 15.3 Cleanup
  1526→
  1527→When a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).
  1528→
  1529→---
  1530→
  1531→## 16. Search (SQLite FTS5)
  1532→
  1533→### 16.1 Index Setup
  1534→
  1535→Create an FTS5 virtual table named `products_fts` with these columns:
  1536→
  1537→| Column | Indexed | Description |
  1538→|---|---|---|
  1539→| `store_id` | No (UNINDEXED) | Used for filtering, not searching |
  1540→| `product_id` | No (UNINDEXED) | Foreign key reference for loading results |
  1541→| `title` | Yes | Product title, fully searchable |
  1542→| `description` | Yes | Plain-text version of description_html (HTML tags stripped) |
  1543→| `vendor` | Yes | Vendor name |
  1544→| `product_type` | Yes | Product type |
  1545→| `tags` | Yes | Space-separated tag values |
  1546→
  1547→### 16.2 Index Synchronization
  1548→
  1549→**Observer:** `App\Observers\ProductObserver`
  1550→
  1551→| Model Event | FTS Action |
  1552→|---|---|
  1553→| `created` | Insert row into products_fts |
  1554→| `updated` | Delete old row, insert updated row (FTS5 does not support UPDATE) |
  1555→| `deleted` | Delete row from products_fts |
  1556→
  1557→**Data preparation before insert:**
  1558→- Strip HTML tags from `description_html` to get plain text.
  1559→- Convert JSON tags array to space-separated string.
  1560→- Use empty string for null vendor and product_type fields.
  1561→
  1562→### 16.3 Search Query
  1563→
  1564→**Service:** `App\Services\SearchService`
  1565→
  1566→**Search algorithm:**
  1567→
  1568→```
  1569→PROCEDURE search(store, queryString, limit = 20):
  1570→    1. Sanitize queryString: escape FTS5 special characters
  1571→    2. Append '*' to last token for prefix matching (autocomplete)
  1572→       Example: "running sh" becomes "running sh*"
  1573→    3. Execute FTS5 MATCH query:
  1574→       - Filter by store_id
  1575→       - Order by FTS5 relevance rank
  1576→       - Limit results
  1577→    4. Load matching Product models
  1578→    5. Exclude products with status != 'active' or published_at IS NULL
  1579→    6. RETURN ordered collection
  1580→```
  1581→
  1582→### 16.4 Search Logging
  1583→
  1584→Each search query is logged to `search_queries` with: store_id, query text, results_count, created_at. This data feeds into analytics and can inform autocomplete suggestions.
  1585→
  1586→---
  1587→
  1588→## 17. Event and Listener Registry
  1589→
  1590→Summary of all domain events and their listeners/side effects:
  1591→
  1592→| Event | Listeners / Side Effects |
  1593→|---|---|
  1594→| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |
  1595→| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |
  1596→| `CheckoutAddressed` | Recalculate pricing |
  1597→| `CheckoutShippingSelected` | Recalculate pricing |
  1598→| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |
  1599→| `CheckoutExpired` | Release reserved inventory |
  1600→| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |
  1601→| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |
  1602→| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |
  1603→| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |
  1604→| `OrderPaid` | Update order status, commit inventory (bank transfer confirmation), auto-fulfill digital products |
  1605→| `FulfillmentCreated` | Update order fulfillment status |
  1606→| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |
  1607→| `FulfillmentDelivered` | Update fulfillment record, dispatch webhooks |
  1608→| `OrderCancelled` | Release reserved inventory, send cancellation email |
  1609→
  1610→All events should be placed in `App\Events`. All listeners in `App\Listeners`.
  1611→
  1612→---
  1613→
  1614→## 18. Scheduled Jobs Registry
  1615→
  1616→| Job Class | Schedule | Description |
  1617→|---|---|---|
  1618→| `App\Jobs\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |
  1619→| `App\Jobs\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |
  1620→| `App\Jobs\AggregateAnalytics` | Daily at 01:00 UTC | Roll up raw analytics events into daily aggregates |
  1621→| `App\Jobs\CancelUnpaidBankTransferOrders` | Daily | Cancel bank transfer orders that remain unpaid after configurable days (default: 7) |
  1622→
  1623→**Queued jobs (dispatched on demand, not scheduled):**
  1624→
  1625→| Job Class | Description |
  1626→|---|---|
  1627→| `App\Jobs\ProcessMediaUpload` | Resize uploaded images to standard sizes |
  1628→| `App\Jobs\DeliverWebhook` | POST webhook payload to subscriber endpoint |
  1629→
  1630→Schedule registration belongs in `routes/console.php`.
  1631→
  1632→---
  1633→
  1634→## 19. Service Class Registry
  1635→
  1636→| Service | Namespace | Responsibility |
  1637→|---|---|---|
  1638→| ProductService | `App\Services` | Product CRUD, status transitions |
  1639→| VariantMatrixService | `App\Services` | Option/variant matrix management |
  1640→| InventoryService | `App\Services` | Reserve, release, commit, restock operations |
  1641→| CartService | `App\Services` | Cart CRUD, line management, merge on login |
  1642→| PricingEngine | `App\Services` | Deterministic total calculation pipeline |
  1643→| CheckoutService | `App\Services` | Checkout state machine transitions |
  1644→| DiscountService | `App\Services` | Discount validation and application |
  1645→| TaxCalculator | `App\Services` | Tax calculation orchestration (delegates to providers) |
  1646→| ManualTaxProvider | `App\Services\Tax` | Manual tax rate calculation |
  1647→| StripeTaxProvider | `App\Services\Tax` | Stripe Tax API integration (stub) |
  1648→| ShippingCalculator | `App\Services` | Zone matching, rate calculation |
  1649→| PaymentService | `App\Services` | Payment processing orchestration |
  1650→| MockPaymentProvider | `App\Services\Payment` | Mock PSP - simulates card/PayPal/bank transfer payments in-process |
  1651→| RefundService | `App\Services` | Refund processing and inventory restock |
  1652→| FulfillmentService | `App\Services` | Fulfillment creation and status management |
  1653→| CustomerService | `App\Services` | Customer registration, account management |
  1654→| WebhookService | `App\Services` | Webhook delivery, circuit breaker |
  1655→| SearchService | `App\Services` | FTS5 search queries |
  1656→| HandleGenerator | `App\Support` | Unique handle/slug generation |
  1657→
  1658→---
  1659→
  1660→## 20. Contracts (Interfaces)
  1661→
  1662→| Interface | Namespace | Implementing Classes |
  1663→|---|---|---|
  1664→| TaxProvider | `App\Contracts` | ManualTaxProvider, StripeTaxProvider |
  1665→| PaymentProvider | `App\Contracts` | MockPaymentProvider |
  1666→
  1667→---
  1668→
  1669→## 21. Enum Registry
  1670→
  1671→All enums live in `App\Enums` and are string-backed.
  1672→
  1673→| Enum | Values (TitleCase keys, lowercase string values) |
  1674→|---|---|
  1675→| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |
  1676→| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |
  1677→| VariantStatus | Active (`active`), Archived (`archived`) |
  1678→| DiscountType | Code (`code`), Automatic (`automatic`) |
  1679→| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |
  1680→| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |
  1681→| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentSelected (`payment_selected`), Completed (`completed`), Expired (`expired`) |
  1682→| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |
  1683→| PaymentStatus | Pending (`pending`), Captured (`captured`), Failed (`failed`), Refunded (`refunded`) |
  1684→| OrderStatus | Pending (`pending`), Paid (`paid`), Fulfilled (`fulfilled`), Cancelled (`cancelled`), Refunded (`refunded`) |
  1685→| FinancialStatus | Pending (`pending`), Authorized (`authorized`), Paid (`paid`), PartiallyRefunded (`partially_refunded`), Refunded (`refunded`), Voided (`voided`) |
  1686→| FulfillmentOrderStatus | Unfulfilled (`unfulfilled`), Partial (`partial`), Fulfilled (`fulfilled`) |
  1687→| FulfillmentShipmentStatus | Pending (`pending`), Shipped (`shipped`), Delivered (`delivered`) |
  1688→| ShippingRateType | Flat (`flat`), Weight (`weight`), Price (`price`), Carrier (`carrier`) |
  1689→| MediaStatus | Processing (`processing`), Ready (`ready`), Failed (`failed`) |
  1690→| WebhookSubscriptionStatus | Active (`active`), Paused (`paused`), Disabled (`disabled`) |
  1691→
  1692→---
  1693→
  1694→## 22. Value Object Registry
  1695→
  1696→All value objects live in `App\ValueObjects`.
  1697→
  1698→| Value Object | Fields | Purpose |
  1699→|---|---|---|
  1700→| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |
  1701→| TaxLine | name (string), rate (int, basis points), amount (int, cents) | Individual tax line item |
  1702→| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |
  1703→| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |
  1704→| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |
  1705→| PaymentResult | success (bool), referenceId (string), status (string: pending/captured/failed), errorCode (nullable string), errorMessage (nullable string) | Mock payment processing result |
  1706→| WebhookResult | success (bool), message (string) | Webhook processing result |
  1707→| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |
  1708→| Address | firstName, lastName, company, address1, address2, city, province, provinceCode, country, countryCode, postalCode, phone | Structured address fields |
  1709→| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |
  1710→

prompt:
Read the business logic spec at /Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md and extract ALL the rules and details related to:
1. Cart operations (versioning, session binding, merge on login)
2. Checkout state machine (all transitions, validation rules)
3. Pricing engine (pipeline steps, rounding rules)
4. Discount system (validation rules, allocation, types)
5. Shipping calculator (zone matching, rate types, calculations)
6. Tax calculator (exclusive vs inclusive, basis points)

Return the complete business logic rules with all details.


teamName: wild-sleeping-shell

userType: external

slug: iterative-seeking-pine

type: user

role: user

type: text

text:
Based on the database schema specification, here are the complete Phase 4 table definitions (Cart, Checkout, Discounts, Shipping, Taxes):

## Phase 4 Tables - Cart, Checkout, Discounts, Shipping, Taxes

### carts

Shopping cart, optionally linked to a customer.

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

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

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

---

### cart_lines

Individual line items within a cart.

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

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

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

---

### checkouts

Checkout session progressing through address, shipping, payment steps.

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

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

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

---

### shipping_zones

Geographic shipping zones for a store.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| name | TEXT | No | - | - | Zone display name |
| countries_json | TEXT | No | '[]' | - | JSON array of ISO 3166-1 alpha-2 codes, e.g. ["US","CA"] |
| regions_json | TEXT | No | '[]' | - | JSON array of region/state codes, e.g. ["US-NY","US-CA"] |

**Indexes:**
- `idx_shipping_zones_store_id` on (`store_id`) - FK lookup, listing zones per store

**Notes:** `countries_json` is an array of ISO 3166-1 alpha-2 codes. `regions_json` is an array of region/state codes.

---

### shipping_rates

Shipping rate options within a zone.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| zone_id | INTEGER | No | - | FK -> shipping_zones(id) ON DELETE CASCADE | Parent zone |
| name | TEXT | No | - | - | Rate display name |
| type | TEXT | No | 'flat' | CHECK IN ('flat', 'weight', 'price', 'carrier') | Calculation method |
| config_json | TEXT | No | '{}' | - | Type-dependent configuration (see notes) |
| is_active | INTEGER | No | 1 | - | Boolean: active flag |

**Indexes:**
- `idx_shipping_rates_zone_id` on (`zone_id`) - FK lookup, listing rates per zone
- `idx_shipping_rates_zone_active` on (`zone_id`, `is_active`) - Finding active rates per zone

**Notes:** `config_json` structure depends on `type`:
- Flat: `{"amount": 799}`
- Weight: `{"ranges": [{"min_g": 0, "max_g": 500, "amount": 499}]}`
- Price: `{"ranges": [{"min_amount": 0, "max_amount": 5000, "amount": 799}]}`
- Carrier: `{"carrier": "ups", "service": "ground"}`

---

### tax_settings

Per-store tax configuration.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |
| mode | TEXT | No | 'manual' | CHECK IN ('manual', 'provider') | Tax calculation mode |
| provider | TEXT | No | 'none' | CHECK IN ('stripe_tax', 'none') | External tax provider |
| prices_include_tax | INTEGER | No | 0 | - | Boolean: tax-inclusive pricing |
| config_json | TEXT | No | '{}' | - | Provider-specific config |

---

### discounts

Discount codes and automatic discounts.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| type | TEXT | No | 'code' | CHECK IN ('code', 'automatic') | Activation method |
| code | TEXT | Yes | NULL | UNIQUE per (store_id, code) | Discount code (null for automatic) |
| value_type | TEXT | No | - | CHECK IN ('fixed', 'percent', 'free_shipping') | Discount calculation type |
| value_amount | INTEGER | No | 0 | - | See notes for interpretation |
| starts_at | TEXT | No | - | - | ISO-8601 start timestamp |
| ends_at | TEXT | Yes | NULL | - | ISO-8601 end timestamp; null = no expiry |
| usage_limit | INTEGER | Yes | NULL | - | Max redemptions; null = unlimited |
| usage_count | INTEGER | No | 0 | - | Current redemption count |
| rules_json | TEXT | No | '{}' | - | Eligibility rules (min purchase, products, etc.) |
| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'expired', 'disabled') | |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**
- `idx_discounts_store_code` UNIQUE on (`store_id`, `code`) - Unique discount code per store
- `idx_discounts_store_id` on (`store_id`) - FK lookup
- `idx_discounts_store_status` on (`store_id`, `status`) - Filtering discounts by status
- `idx_discounts_store_type` on (`store_id`, `type`) - Filtering by code vs automatic

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

text:
agentId: a897d13 (for resuming to continue this agent's work if needed)
<usage>total_tokens: 55281
tool_uses: 1
duration_ms: 20718</usage>

status: completed

prompt:
Read the database schema spec at /Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md and extract ONLY the table definitions for the following tables that belong to Phase 4 (Cart, Checkout, Discounts, Shipping, Taxes):
- carts
- cart_lines
- checkouts
- shipping_zones
- shipping_rates
- tax_settings
- discounts

Return the complete table definitions including columns, types, constraints, indexes, and any notes.

service_tier: standard

speed: standard