command: rg -n -C 5 "applicable_product|applicable_collection|min_purchase|customer|discounts" specs/01-DATABASE-SCHEMA.md specs/05-BUSINESS-LOGIC.md | head -320

cmd: rg -n -C 5 'applicable_product|applicable_collection|min_purchase|customer|discounts' specs/01-DATABASE-SCHEMA.md specs/05-BUSINESS-LOGIC.md

query: applicable_product|applicable_collection|min_purchase|customer|discounts

stdout:
specs/05-BUSINESS-LOGIC.md-7-## Conventions
specs/05-BUSINESS-LOGIC.md-8-
specs/05-BUSINESS-LOGIC.md-9-| Convention | Detail |
specs/05-BUSINESS-LOGIC.md-10-|---|---|
specs/05-BUSINESS-LOGIC.md-11-| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |
specs/05-BUSINESS-LOGIC.md:12:| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |
specs/05-BUSINESS-LOGIC.md-13-| Rounding | Round each line independently, then sum (prevents rounding drift) |
specs/05-BUSINESS-LOGIC.md-14-| Database | SQLite - single-writer, WAL mode, no advisory locks needed |
specs/05-BUSINESS-LOGIC.md-15-| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |
specs/05-BUSINESS-LOGIC.md-16-| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |
specs/05-BUSINESS-LOGIC.md-17-
--
specs/05-BUSINESS-LOGIC.md-75-- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).
specs/05-BUSINESS-LOGIC.md-76-- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.
specs/05-BUSINESS-LOGIC.md-77-- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.
specs/05-BUSINESS-LOGIC.md-78-
specs/05-BUSINESS-LOGIC.md-79-**Models that must use this trait:**
specs/05-BUSINESS-LOGIC.md:80:products, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.
specs/05-BUSINESS-LOGIC.md-81-
specs/05-BUSINESS-LOGIC.md-82-### 1.3 Role-Based Access Control
specs/05-BUSINESS-LOGIC.md-83-
specs/05-BUSINESS-LOGIC.md-84-**Enum:** `App\Enums\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`
specs/05-BUSINESS-LOGIC.md-85-
--
specs/05-BUSINESS-LOGIC.md-89-|---|---|---|---|---|
specs/05-BUSINESS-LOGIC.md-90-| Manage store settings | Yes | Yes | No | No |
specs/05-BUSINESS-LOGIC.md-91-| Manage staff | Yes | Yes | No | No |
specs/05-BUSINESS-LOGIC.md-92-| Manage products | Yes | Yes | Yes | No |
specs/05-BUSINESS-LOGIC.md-93-| Manage orders | Yes | Yes | Yes | Yes (read-only) |
specs/05-BUSINESS-LOGIC.md:94:| Manage discounts | Yes | Yes | Yes | No |
specs/05-BUSINESS-LOGIC.md-95-| Manage themes | Yes | Yes | No | No |
specs/05-BUSINESS-LOGIC.md-96-| Manage apps | Yes | Yes | No | No |
specs/05-BUSINESS-LOGIC.md-97-| View analytics | Yes | Yes | Yes | No |
specs/05-BUSINESS-LOGIC.md-98-| Manage shipping/tax | Yes | Yes | No | No |
specs/05-BUSINESS-LOGIC.md-99-| Process refunds | Yes | Yes | No | No |
--
specs/05-BUSINESS-LOGIC.md-261-    active --> abandoned : Cleanup job (14 days inactive)
specs/05-BUSINESS-LOGIC.md-262-```
specs/05-BUSINESS-LOGIC.md-263-
specs/05-BUSINESS-LOGIC.md-264-**Cart identification:**
specs/05-BUSINESS-LOGIC.md-265-- Guest users: cart ID stored in session cookie.
specs/05-BUSINESS-LOGIC.md:266:- Authenticated customers: cart linked via `customer_id` foreign key.
specs/05-BUSINESS-LOGIC.md-267-
specs/05-BUSINESS-LOGIC.md: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.
specs/05-BUSINESS-LOGIC.md-269-
specs/05-BUSINESS-LOGIC.md-270-**Cart merge on login:**
specs/05-BUSINESS-LOGIC.md-271-
specs/05-BUSINESS-LOGIC.md-272-```
specs/05-BUSINESS-LOGIC.md:273:PROCEDURE mergeGuestCart(guestCart, customerCart):
specs/05-BUSINESS-LOGIC.md-274-    FOR EACH line IN guestCart.lines:
specs/05-BUSINESS-LOGIC.md:275:        existingLine = customerCart.findLineByVariant(line.variant_id)
specs/05-BUSINESS-LOGIC.md-276-        IF existingLine EXISTS:
specs/05-BUSINESS-LOGIC.md-277-            existingLine.quantity = MAX(existingLine.quantity, line.quantity)
specs/05-BUSINESS-LOGIC.md-278-            RECALCULATE existingLine amounts
specs/05-BUSINESS-LOGIC.md-279-        ELSE:
specs/05-BUSINESS-LOGIC.md:280:            MOVE line to customerCart
specs/05-BUSINESS-LOGIC.md-281-    MARK guestCart as abandoned
specs/05-BUSINESS-LOGIC.md:282:    RECALCULATE all line amounts on customerCart
specs/05-BUSINESS-LOGIC.md-283-```
specs/05-BUSINESS-LOGIC.md-284-
specs/05-BUSINESS-LOGIC.md-285-### 4.2 Cart Operations
specs/05-BUSINESS-LOGIC.md-286-
specs/05-BUSINESS-LOGIC.md-287-**Create cart:**
specs/05-BUSINESS-LOGIC.md:288:- Accepts: store, optional customer
specs/05-BUSINESS-LOGIC.md-289-- Sets: store_id, currency from store's default_currency, initial cart_version = 1
specs/05-BUSINESS-LOGIC.md-290-
specs/05-BUSINESS-LOGIC.md-291-**Add line - validations (in order):**
specs/05-BUSINESS-LOGIC.md-292-
specs/05-BUSINESS-LOGIC.md-293-1. Variant exists and belongs to the current store (through its product).
--
specs/05-BUSINESS-LOGIC.md-603-
specs/05-BUSINESS-LOGIC.md-604-**Enum:** `App\Enums\DiscountType` - values: `code`, `automatic`
specs/05-BUSINESS-LOGIC.md-605-
specs/05-BUSINESS-LOGIC.md-606-| Type | Behavior |
specs/05-BUSINESS-LOGIC.md-607-|---|---|
specs/05-BUSINESS-LOGIC.md:608:| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |
specs/05-BUSINESS-LOGIC.md-609-| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |
specs/05-BUSINESS-LOGIC.md-610-
specs/05-BUSINESS-LOGIC.md-611-**Stacking rules:**
specs/05-BUSINESS-LOGIC.md-612-- Only one code discount per checkout.
specs/05-BUSINESS-LOGIC.md:613:- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).
specs/05-BUSINESS-LOGIC.md-614-
specs/05-BUSINESS-LOGIC.md-615-### Discount Status Lifecycle
specs/05-BUSINESS-LOGIC.md-616-
specs/05-BUSINESS-LOGIC.md-617-| Status | Description | Transition |
specs/05-BUSINESS-LOGIC.md-618-|--------|-------------|------------|
specs/05-BUSINESS-LOGIC.md:619:| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |
specs/05-BUSINESS-LOGIC.md-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. |
specs/05-BUSINESS-LOGIC.md-621-| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |
specs/05-BUSINESS-LOGIC.md-622-| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |
specs/05-BUSINESS-LOGIC.md-623-
specs/05-BUSINESS-LOGIC.md-624-**Allowed transitions:**
--
specs/05-BUSINESS-LOGIC.md-637-| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |
specs/05-BUSINESS-LOGIC.md-638-| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |
specs/05-BUSINESS-LOGIC.md-639-
specs/05-BUSINESS-LOGIC.md-640-### 7.3 Rules
specs/05-BUSINESS-LOGIC.md-641-
specs/05-BUSINESS-LOGIC.md:642:Rules are stored in `discounts.rules_json` with the following structure:
specs/05-BUSINESS-LOGIC.md-643-
specs/05-BUSINESS-LOGIC.md-644-| Rule Key | Type | Description |
specs/05-BUSINESS-LOGIC.md-645-|---|---|---|
specs/05-BUSINESS-LOGIC.md:646:| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |
specs/05-BUSINESS-LOGIC.md:647:| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |
specs/05-BUSINESS-LOGIC.md:648:| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |
specs/05-BUSINESS-LOGIC.md:649:| `customer_eligibility` | string | `"all"` for all customers. Future expansion: `"returning"`, `"specific"`. |
specs/05-BUSINESS-LOGIC.md-650-
specs/05-BUSINESS-LOGIC.md: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.
specs/05-BUSINESS-LOGIC.md-652-
specs/05-BUSINESS-LOGIC.md-653-### 7.4 Validation Flow
specs/05-BUSINESS-LOGIC.md-654-
specs/05-BUSINESS-LOGIC.md-655-**Service:** `App\Services\DiscountService`
specs/05-BUSINESS-LOGIC.md-656-
--
specs/05-BUSINESS-LOGIC.md-667-    F -->|No| G["Error: discount_not_yet_active"]
specs/05-BUSINESS-LOGIC.md-668-    F -->|Yes| H{ends_at is null OR ends_at >= now?}
specs/05-BUSINESS-LOGIC.md-669-    H -->|No| I["Error: discount_expired"]
specs/05-BUSINESS-LOGIC.md-670-    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}
specs/05-BUSINESS-LOGIC.md-671-    J -->|Yes| K["Error: discount_usage_limit_reached"]
specs/05-BUSINESS-LOGIC.md:672:    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}
specs/05-BUSINESS-LOGIC.md:673:    L -->|Yes| M["Error: discount_min_purchase_not_met"]
specs/05-BUSINESS-LOGIC.md-674-    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}
specs/05-BUSINESS-LOGIC.md-675-    N -->|Yes| O["Error: discount_not_applicable"]
specs/05-BUSINESS-LOGIC.md-676-    N -->|No| P["Valid - return discount"]
specs/05-BUSINESS-LOGIC.md-677-```
specs/05-BUSINESS-LOGIC.md-678-
--
specs/05-BUSINESS-LOGIC.md-682-|---|---|
specs/05-BUSINESS-LOGIC.md-683-| `discount_not_found` | Code does not exist in this store |
specs/05-BUSINESS-LOGIC.md-684-| `discount_expired` | Past `ends_at` date or status is not active |
specs/05-BUSINESS-LOGIC.md-685-| `discount_not_yet_active` | Before `starts_at` date |
specs/05-BUSINESS-LOGIC.md-686-| `discount_usage_limit_reached` | `usage_count >= usage_limit` |
specs/05-BUSINESS-LOGIC.md:687:| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |
specs/05-BUSINESS-LOGIC.md-688-| `discount_not_applicable` | No qualifying products in cart |
specs/05-BUSINESS-LOGIC.md-689-
specs/05-BUSINESS-LOGIC.md-690-### 7.6 Discount Allocation
specs/05-BUSINESS-LOGIC.md-691-
specs/05-BUSINESS-LOGIC.md:692:For `percent` and `fixed` discounts with applicable product restrictions:
specs/05-BUSINESS-LOGIC.md-693-
specs/05-BUSINESS-LOGIC.md-694-```
specs/05-BUSINESS-LOGIC.md-695-PROCEDURE allocateDiscount(discount, cartLines):
specs/05-BUSINESS-LOGIC.md:696:    qualifyingLines = FILTER cartLines where product matches applicable_product_ids
specs/05-BUSINESS-LOGIC.md:697:                      OR product belongs to applicable_collection_ids
specs/05-BUSINESS-LOGIC.md-698-    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)
specs/05-BUSINESS-LOGIC.md-699-
specs/05-BUSINESS-LOGIC.md-700-    IF discount.value_type = 'percent':
specs/05-BUSINESS-LOGIC.md-701-        totalDiscount = qualifyingSubtotal * discount.value_amount / 100
specs/05-BUSINESS-LOGIC.md-702-    ELSE IF discount.value_type = 'fixed':
--
specs/05-BUSINESS-LOGIC.md-720-- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.
specs/05-BUSINESS-LOGIC.md-721-
specs/05-BUSINESS-LOGIC.md-722-### 7.7 Usage Tracking
specs/05-BUSINESS-LOGIC.md-723-
specs/05-BUSINESS-LOGIC.md-724-When an order is successfully created with a discount code:
specs/05-BUSINESS-LOGIC.md:725:- Increment `discounts.usage_count` by 1.
specs/05-BUSINESS-LOGIC.md-726-- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).
specs/05-BUSINESS-LOGIC.md-727-
specs/05-BUSINESS-LOGIC.md-728----
specs/05-BUSINESS-LOGIC.md-729-
specs/05-BUSINESS-LOGIC.md-730-## 8. Tax Calculation
--
specs/05-BUSINESS-LOGIC.md-763-Controlled by `tax_settings.prices_include_tax` (boolean).
specs/05-BUSINESS-LOGIC.md-764-
specs/05-BUSINESS-LOGIC.md-765-#### Tax-inclusive pricing (prices_include_tax = true)
specs/05-BUSINESS-LOGIC.md-766-
specs/05-BUSINESS-LOGIC.md-767-- Displayed prices on the storefront are gross (include tax).
specs/05-BUSINESS-LOGIC.md:768:- Subtotal shown to customer is gross.
specs/05-BUSINESS-LOGIC.md-769-- Tax is "extracted" from the gross amount:
specs/05-BUSINESS-LOGIC.md-770-
specs/05-BUSINESS-LOGIC.md-771-```
specs/05-BUSINESS-LOGIC.md-772-net_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)
specs/05-BUSINESS-LOGIC.md-773-tax = gross_amount - net_amount
--
specs/05-BUSINESS-LOGIC.md-920-
specs/05-BUSINESS-LOGIC.md-921-### 9.4 Free Shipping Discount Override
specs/05-BUSINESS-LOGIC.md-922-
specs/05-BUSINESS-LOGIC.md-923-If a validated discount has `value_type = 'free_shipping'`:
specs/05-BUSINESS-LOGIC.md-924-- Override the calculated shipping amount to 0.
specs/05-BUSINESS-LOGIC.md:925:- The shipping method is still selected (for fulfillment routing), but the customer is not charged.
specs/05-BUSINESS-LOGIC.md-926-
specs/05-BUSINESS-LOGIC.md-927----
specs/05-BUSINESS-LOGIC.md-928-
specs/05-BUSINESS-LOGIC.md-929-## 10. Payment Processing (Mock PSP)
specs/05-BUSINESS-LOGIC.md-930-
--
specs/05-BUSINESS-LOGIC.md-1229-    [*] --> pending : Fulfillment created (admin clicks "Create fulfillment")
specs/05-BUSINESS-LOGIC.md-1230-    pending --> shipped : Admin clicks "Mark as shipped"
specs/05-BUSINESS-LOGIC.md-1231-    shipped --> delivered : Admin clicks "Mark as delivered"
specs/05-BUSINESS-LOGIC.md-1232-
specs/05-BUSINESS-LOGIC.md-1233-    note right of pending : Fulfillment record created with tracking data
specs/05-BUSINESS-LOGIC.md:1234:    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer
specs/05-BUSINESS-LOGIC.md-1235-    note right of delivered : Sets delivered_at timestamp
specs/05-BUSINESS-LOGIC.md-1236-```
specs/05-BUSINESS-LOGIC.md-1237-
specs/05-BUSINESS-LOGIC.md-1238-**Admin buttons on each fulfillment card:**
specs/05-BUSINESS-LOGIC.md-1239-
--
specs/05-BUSINESS-LOGIC.md-1243-| "Mark as delivered" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |
specs/05-BUSINESS-LOGIC.md-1244-
specs/05-BUSINESS-LOGIC.md-1245-When transitioning to `shipped`:
specs/05-BUSINESS-LOGIC.md-1246-- Set `shipped_at` timestamp.
specs/05-BUSINESS-LOGIC.md-1247-- Dispatch `FulfillmentShipped` event.
specs/05-BUSINESS-LOGIC.md:1248:- Send email notification to customer with tracking information.
specs/05-BUSINESS-LOGIC.md-1249-
specs/05-BUSINESS-LOGIC.md-1250-When transitioning to `delivered`:
specs/05-BUSINESS-LOGIC.md-1251-- Dispatch `FulfillmentDelivered` event.
specs/05-BUSINESS-LOGIC.md-1252-
specs/05-BUSINESS-LOGIC.md-1253-### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method
--
specs/05-BUSINESS-LOGIC.md-1278-
specs/05-BUSINESS-LOGIC.md-1279-### 11.6 Order Export
specs/05-BUSINESS-LOGIC.md-1280-
specs/05-BUSINESS-LOGIC.md-1281-`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.
specs/05-BUSINESS-LOGIC.md-1282-
specs/05-BUSINESS-LOGIC.md: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`
specs/05-BUSINESS-LOGIC.md-1284-
specs/05-BUSINESS-LOGIC.md-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"`.
specs/05-BUSINESS-LOGIC.md-1286-
specs/05-BUSINESS-LOGIC.md-1287----
specs/05-BUSINESS-LOGIC.md-1288-
--
specs/05-BUSINESS-LOGIC.md-1304-- Email uniqueness enforced by the database unique index on `(store_id, email)`.
specs/05-BUSINESS-LOGIC.md-1305-- Password hashed using Laravel's Hash facade (bcrypt by default).
specs/05-BUSINESS-LOGIC.md-1306-
specs/05-BUSINESS-LOGIC.md-1307-### 12.2 Authentication
specs/05-BUSINESS-LOGIC.md-1308-
specs/05-BUSINESS-LOGIC.md:1309:**Separate auth guard:** `customer`
specs/05-BUSINESS-LOGIC.md-1310-
specs/05-BUSINESS-LOGIC.md-1311-Requirements:
specs/05-BUSINESS-LOGIC.md:1312:- Configure a dedicated `customer` guard in auth config, using session driver.
specs/05-BUSINESS-LOGIC.md:1313:- Configure a dedicated `customers` provider using the Customer model.
specs/05-BUSINESS-LOGIC.md-1314-- Login: email + password, scoped to the current store.
specs/05-BUSINESS-LOGIC.md:1315:- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.
specs/05-BUSINESS-LOGIC.md-1316-
specs/05-BUSINESS-LOGIC.md-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.
specs/05-BUSINESS-LOGIC.md-1318-
specs/05-BUSINESS-LOGIC.md-1319-### 12.3 Data Access
specs/05-BUSINESS-LOGIC.md-1320-
specs/05-BUSINESS-LOGIC.md:1321:- Customers can only view their own orders (scoped by customer_id match).
specs/05-BUSINESS-LOGIC.md:1322:- Customers can only manage their own addresses (scoped by customer_id match).
specs/05-BUSINESS-LOGIC.md:1323:- All customer queries include the StoreScope global scope for tenant isolation.
specs/05-BUSINESS-LOGIC.md-1324-
specs/05-BUSINESS-LOGIC.md-1325-### 12.4 Guest Cart to Customer Linking
specs/05-BUSINESS-LOGIC.md-1326-
specs/05-BUSINESS-LOGIC.md-1327-```
specs/05-BUSINESS-LOGIC.md-1328-PROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):
specs/05-BUSINESS-LOGIC.md:1329:    existingCustomer = find customer by email in store
specs/05-BUSINESS-LOGIC.md-1330-    IF existingCustomer:
specs/05-BUSINESS-LOGIC.md-1331-        LINK order to existingCustomer
specs/05-BUSINESS-LOGIC.md-1332-    ELSE:
specs/05-BUSINESS-LOGIC.md:1333:        CREATE new customer with:
specs/05-BUSINESS-LOGIC.md-1334-            - email = checkoutEmail
specs/05-BUSINESS-LOGIC.md:1335:            - password_hash = null (guest customer)
specs/05-BUSINESS-LOGIC.md-1336-            - store_id = storeId
specs/05-BUSINESS-LOGIC.md:1337:        LINK order to new customer
specs/05-BUSINESS-LOGIC.md-1338-
specs/05-BUSINESS-LOGIC.md:1339:    // Guest customer can later "claim" their account
specs/05-BUSINESS-LOGIC.md-1340-    // by setting a password via registration or account-claim flow
specs/05-BUSINESS-LOGIC.md-1341-```
specs/05-BUSINESS-LOGIC.md-1342-
specs/05-BUSINESS-LOGIC.md-1343----
specs/05-BUSINESS-LOGIC.md-1344-
--
specs/01-DATABASE-SCHEMA.md-87-
specs/01-DATABASE-SCHEMA.md-88----
specs/01-DATABASE-SCHEMA.md-89-
specs/01-DATABASE-SCHEMA.md-90-### users
specs/01-DATABASE-SCHEMA.md-91-
specs/01-DATABASE-SCHEMA.md:92:Admin/staff users who manage stores (not storefront customers).
specs/01-DATABASE-SCHEMA.md-93-
specs/01-DATABASE-SCHEMA.md-94-| Column | Type | Nullable | Default | Constraints | Notes |
specs/01-DATABASE-SCHEMA.md-95-|---|---|---|---|---|---|
specs/01-DATABASE-SCHEMA.md-96-| id | INTEGER | No | autoincrement | PRIMARY KEY | |
specs/01-DATABASE-SCHEMA.md-97-| email | TEXT | No | - | UNIQUE per table | Login email |
--
specs/01-DATABASE-SCHEMA.md-525-
specs/01-DATABASE-SCHEMA.md-526-## Epic 5 - Cart, Checkout, Discounts, Shipping, and Taxes
specs/01-DATABASE-SCHEMA.md-527-
specs/01-DATABASE-SCHEMA.md-528-### carts
specs/01-DATABASE-SCHEMA.md-529-
specs/01-DATABASE-SCHEMA.md:530:Shopping cart, optionally linked to a customer.
specs/01-DATABASE-SCHEMA.md-531-
specs/01-DATABASE-SCHEMA.md-532-| Column | Type | Nullable | Default | Constraints | Notes |
specs/01-DATABASE-SCHEMA.md-533-|---|---|---|---|---|---|
specs/01-DATABASE-SCHEMA.md-534-| id | INTEGER | No | autoincrement | PRIMARY KEY | |
specs/01-DATABASE-SCHEMA.md-535-| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:536:| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest carts |
specs/01-DATABASE-SCHEMA.md-537-| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |
specs/01-DATABASE-SCHEMA.md-538-| cart_version | INTEGER | No | 1 | - | Optimistic concurrency version counter |
specs/01-DATABASE-SCHEMA.md-539-| status | TEXT | No | 'active' | CHECK IN ('active', 'converted', 'abandoned') | |
specs/01-DATABASE-SCHEMA.md-540-| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
specs/01-DATABASE-SCHEMA.md-541-| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
specs/01-DATABASE-SCHEMA.md-542-
specs/01-DATABASE-SCHEMA.md-543-**Indexes:**
specs/01-DATABASE-SCHEMA.md-544-
specs/01-DATABASE-SCHEMA.md-545-- `idx_carts_store_id` on (`store_id`) - FK lookup
specs/01-DATABASE-SCHEMA.md:546:- `idx_carts_customer_id` on (`customer_id`) - Finding carts by customer
specs/01-DATABASE-SCHEMA.md-547-- `idx_carts_store_status` on (`store_id`, `status`) - Filtering carts by status (e.g. abandoned cart recovery)
specs/01-DATABASE-SCHEMA.md-548-
specs/01-DATABASE-SCHEMA.md-549-**Notes:** `cart_version` is incremented on every mutation and used for optimistic concurrency control.
specs/01-DATABASE-SCHEMA.md-550-
specs/01-DATABASE-SCHEMA.md-551----
--
specs/01-DATABASE-SCHEMA.md-581-| Column | Type | Nullable | Default | Constraints | Notes |
specs/01-DATABASE-SCHEMA.md-582-|---|---|---|---|---|---|
specs/01-DATABASE-SCHEMA.md-583-| id | INTEGER | No | autoincrement | PRIMARY KEY | |
specs/01-DATABASE-SCHEMA.md-584-| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md-585-| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Source cart |
specs/01-DATABASE-SCHEMA.md:586:| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest checkout |
specs/01-DATABASE-SCHEMA.md-587-| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired') | |
specs/01-DATABASE-SCHEMA.md-588-| payment_method | TEXT | Yes | NULL | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Selected payment method |
specs/01-DATABASE-SCHEMA.md-589-| email | TEXT | Yes | NULL | - | Contact email |
specs/01-DATABASE-SCHEMA.md-590-| shipping_address_json | TEXT | Yes | NULL | - | JSON address object |
specs/01-DATABASE-SCHEMA.md-591-| billing_address_json | TEXT | Yes | NULL | - | JSON address object |
--
specs/01-DATABASE-SCHEMA.md-599-
specs/01-DATABASE-SCHEMA.md-600-**Indexes:**
specs/01-DATABASE-SCHEMA.md-601-
specs/01-DATABASE-SCHEMA.md-602-- `idx_checkouts_store_id` on (`store_id`) - FK lookup
specs/01-DATABASE-SCHEMA.md-603-- `idx_checkouts_cart_id` on (`cart_id`) - Finding checkout for a cart


text:
Script completed
Wall time 0.1 seconds
Output:

text:
specs/05-BUSINESS-LOGIC.md-7-## Conventions
specs/05-BUSINESS-LOGIC.md-8-
specs/05-BUSINESS-LOGIC.md-9-| Convention | Detail |
specs/05-BUSINESS-LOGIC.md-10-|---|---|
specs/05-BUSINESS-LOGIC.md-11-| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |
specs/05-BUSINESS-LOGIC.md:12:| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |
specs/05-BUSINESS-LOGIC.md-13-| Rounding | Round each line independently, then sum (prevents rounding drift) |
specs/05-BUSINESS-LOGIC.md-14-| Database | SQLite - single-writer, WAL mode, no advisory locks needed |
specs/05-BUSINESS-LOGIC.md-15-| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |
specs/05-BUSINESS-LOGIC.md-16-| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |
specs/05-BUSINESS-LOGIC.md-17-
--
specs/05-BUSINESS-LOGIC.md-75-- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).
specs/05-BUSINESS-LOGIC.md-76-- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.
specs/05-BUSINESS-LOGIC.md-77-- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.
specs/05-BUSINESS-LOGIC.md-78-
specs/05-BUSINESS-LOGIC.md-79-**Models that must use this trait:**
specs/05-BUSINESS-LOGIC.md:80:products, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.
specs/05-BUSINESS-LOGIC.md-81-
specs/05-BUSINESS-LOGIC.md-82-### 1.3 Role-Based Access Control
specs/05-BUSINESS-LOGIC.md-83-
specs/05-BUSINESS-LOGIC.md-84-**Enum:** `App\Enums\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`
specs/05-BUSINESS-LOGIC.md-85-
--
specs/05-BUSINESS-LOGIC.md-89-|---|---|---|---|---|
specs/05-BUSINESS-LOGIC.md-90-| Manage store settings | Yes | Yes | No | No |
specs/05-BUSINESS-LOGIC.md-91-| Manage staff | Yes | Yes | No | No |
specs/05-BUSINESS-LOGIC.md-92-| Manage products | Yes | Yes | Yes | No |
specs/05-BUSINESS-LOGIC.md-93-| Manage orders | Yes | Yes | Yes | Yes (read-only) |
specs/05-BUSINESS-LOGIC.md:94:| Manage discounts | Yes | Yes | Yes | No |
specs/05-BUSINESS-LOGIC.md-95-| Manage themes | Yes | Yes | No | No |
specs/05-BUSINESS-LOGIC.md-96-| Manage apps | Yes | Yes | No | No |
specs/05-BUSINESS-LOGIC.md-97-| View analytics | Yes | Yes | Yes | No |
specs/05-BUSINESS-LOGIC.md-98-| Manage shipping/tax | Yes | Yes | No | No |
specs/05-BUSINESS-LOGIC.md-99-| Process refunds | Yes | Yes | No | No |
--
specs/05-BUSINESS-LOGIC.md-261-    active --> abandoned : Cleanup job (14 days inactive)
specs/05-BUSINESS-LOGIC.md-262-```
specs/05-BUSINESS-LOGIC.md-263-
specs/05-BUSINESS-LOGIC.md-264-**Cart identification:**
specs/05-BUSINESS-LOGIC.md-265-- Guest users: cart ID stored in session cookie.
specs/05-BUSINESS-LOGIC.md:266:- Authenticated customers: cart linked via `customer_id` foreign key.
specs/05-BUSINESS-LOGIC.md-267-
specs/05-BUSINESS-LOGIC.md: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.
specs/05-BUSINESS-LOGIC.md-269-
specs/05-BUSINESS-LOGIC.md-270-**Cart merge on login:**
specs/05-BUSINESS-LOGIC.md-271-
specs/05-BUSINESS-LOGIC.md-272-```
specs/05-BUSINESS-LOGIC.md:273:PROCEDURE mergeGuestCart(guestCart, customerCart):
specs/05-BUSINESS-LOGIC.md-274-    FOR EACH line IN guestCart.lines:
specs/05-BUSINESS-LOGIC.md:275:        existingLine = customerCart.findLineByVariant(line.variant_id)
specs/05-BUSINESS-LOGIC.md-276-        IF existingLine EXISTS:
specs/05-BUSINESS-LOGIC.md-277-            existingLine.quantity = MAX(existingLine.quantity, line.quantity)
specs/05-BUSINESS-LOGIC.md-278-            RECALCULATE existingLine amounts
specs/05-BUSINESS-LOGIC.md-279-        ELSE:
specs/05-BUSINESS-LOGIC.md:280:            MOVE line to customerCart
specs/05-BUSINESS-LOGIC.md-281-    MARK guestCart as abandoned
specs/05-BUSINESS-LOGIC.md:282:    RECALCULATE all line amounts on customerCart
specs/05-BUSINESS-LOGIC.md-283-```
specs/05-BUSINESS-LOGIC.md-284-
specs/05-BUSINESS-LOGIC.md-285-### 4.2 Cart Operations
specs/05-BUSINESS-LOGIC.md-286-
specs/05-BUSINESS-LOGIC.md-287-**Create cart:**
specs/05-BUSINESS-LOGIC.md:288:- Accepts: store, optional customer
specs/05-BUSINESS-LOGIC.md-289-- Sets: store_id, currency from store's default_currency, initial cart_version = 1
specs/05-BUSINESS-LOGIC.md-290-
specs/05-BUSINESS-LOGIC.md-291-**Add line - validations (in order):**
specs/05-BUSINESS-LOGIC.md-292-
specs/05-BUSINESS-LOGIC.md-293-1. Variant exists and belongs to the current store (through its product).
--
specs/05-BUSINESS-LOGIC.md-603-
specs/05-BUSINESS-LOGIC.md-604-**Enum:** `App\Enums\DiscountType` - values: `code`, `automatic`
specs/05-BUSINESS-LOGIC.md-605-
specs/05-BUSINESS-LOGIC.md-606-| Type | Behavior |
specs/05-BUSINESS-LOGIC.md-607-|---|---|
specs/05-BUSINESS-LOGIC.md:608:| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |
specs/05-BUSINESS-LOGIC.md-609-| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |
specs/05-BUSINESS-LOGIC.md-610-
specs/05-BUSINESS-LOGIC.md-611-**Stacking rules:**
specs/05-BUSINESS-LOGIC.md-612-- Only one code discount per checkout.
specs/05-BUSINESS-LOGIC.md:613:- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).
specs/05-BUSINESS-LOGIC.md-614-
specs/05-BUSINESS-LOGIC.md-615-### Discount Status Lifecycle
specs/05-BUSINESS-LOGIC.md-616-
specs/05-BUSINESS-LOGIC.md-617-| Status | Description | Transition |
specs/05-BUSINESS-LOGIC.md-618-|--------|-------------|------------|
specs/05-BUSINESS-LOGIC.md:619:| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |
specs/05-BUSINESS-LOGIC.md-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. |
specs/05-BUSINESS-LOGIC.md-621-| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |
specs/05-BUSINESS-LOGIC.md-622-| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |
specs/05-BUSINESS-LOGIC.md-623-
specs/05-BUSINESS-LOGIC.md-624-**Allowed transitions:**
--
specs/05-BUSINESS-LOGIC.md-637-| `fixed` | `discount = value_amount` (cents) | Cannot exceed the applicable subtotal |
specs/05-BUSINESS-LOGIC.md-638-| `free_shipping` | Override shipping amount to 0 | No monetary discount on items. `value_amount` is ignored. |
specs/05-BUSINESS-LOGIC.md-639-
specs/05-BUSINESS-LOGIC.md-640-### 7.3 Rules
specs/05-BUSINESS-LOGIC.md-641-
specs/05-BUSINESS-LOGIC.md:642:Rules are stored in `discounts.rules_json` with the following structure:
specs/05-BUSINESS-LOGIC.md-643-
specs/05-BUSINESS-LOGIC.md-644-| Rule Key | Type | Description |
specs/05-BUSINESS-LOGIC.md-645-|---|---|---|
specs/05-BUSINESS-LOGIC.md:646:| `min_purchase_amount` | integer (cents) or null | Cart subtotal must be >= this value. Null means no minimum. |
specs/05-BUSINESS-LOGIC.md:647:| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |
specs/05-BUSINESS-LOGIC.md:648:| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |
specs/05-BUSINESS-LOGIC.md:649:| `customer_eligibility` | string | `"all"` for all customers. Future expansion: `"returning"`, `"specific"`. |
specs/05-BUSINESS-LOGIC.md-650-
specs/05-BUSINESS-LOGIC.md: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.
specs/05-BUSINESS-LOGIC.md-652-
specs/05-BUSINESS-LOGIC.md-653-### 7.4 Validation Flow
specs/05-BUSINESS-LOGIC.md-654-
specs/05-BUSINESS-LOGIC.md-655-**Service:** `App\Services\DiscountService`
specs/05-BUSINESS-LOGIC.md-656-
--
specs/05-BUSINESS-LOGIC.md-667-    F -->|No| G["Error: discount_not_yet_active"]
specs/05-BUSINESS-LOGIC.md-668-    F -->|Yes| H{ends_at is null OR ends_at >= now?}
specs/05-BUSINESS-LOGIC.md-669-    H -->|No| I["Error: discount_expired"]
specs/05-BUSINESS-LOGIC.md-670-    H -->|Yes| J{usage_limit set AND usage_count >= usage_limit?}
specs/05-BUSINESS-LOGIC.md-671-    J -->|Yes| K["Error: discount_usage_limit_reached"]
specs/05-BUSINESS-LOGIC.md:672:    J -->|No| L{min_purchase_amount set AND subtotal < minimum?}
specs/05-BUSINESS-LOGIC.md:673:    L -->|Yes| M["Error: discount_min_purchase_not_met"]
specs/05-BUSINESS-LOGIC.md-674-    L -->|No| N{Product/collection restrictions set AND no cart lines qualify?}
specs/05-BUSINESS-LOGIC.md-675-    N -->|Yes| O["Error: discount_not_applicable"]
specs/05-BUSINESS-LOGIC.md-676-    N -->|No| P["Valid - return discount"]
specs/05-BUSINESS-LOGIC.md-677-```
specs/05-BUSINESS-LOGIC.md-678-
--
specs/05-BUSINESS-LOGIC.md-682-|---|---|
specs/05-BUSINESS-LOGIC.md-683-| `discount_not_found` | Code does not exist in this store |
specs/05-BUSINESS-LOGIC.md-684-| `discount_expired` | Past `ends_at` date or status is not active |
specs/05-BUSINESS-LOGIC.md-685-| `discount_not_yet_active` | Before `starts_at` date |
specs/05-BUSINESS-LOGIC.md-686-| `discount_usage_limit_reached` | `usage_count >= usage_limit` |
specs/05-BUSINESS-LOGIC.md:687:| `discount_min_purchase_not_met` | Cart subtotal < `min_purchase_amount` |
specs/05-BUSINESS-LOGIC.md-688-| `discount_not_applicable` | No qualifying products in cart |
specs/05-BUSINESS-LOGIC.md-689-
specs/05-BUSINESS-LOGIC.md-690-### 7.6 Discount Allocation
specs/05-BUSINESS-LOGIC.md-691-
specs/05-BUSINESS-LOGIC.md:692:For `percent` and `fixed` discounts with applicable product restrictions:
specs/05-BUSINESS-LOGIC.md-693-
specs/05-BUSINESS-LOGIC.md-694-```
specs/05-BUSINESS-LOGIC.md-695-PROCEDURE allocateDiscount(discount, cartLines):
specs/05-BUSINESS-LOGIC.md:696:    qualifyingLines = FILTER cartLines where product matches applicable_product_ids
specs/05-BUSINESS-LOGIC.md:697:                      OR product belongs to applicable_collection_ids
specs/05-BUSINESS-LOGIC.md-698-    qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)
specs/05-BUSINESS-LOGIC.md-699-
specs/05-BUSINESS-LOGIC.md-700-    IF discount.value_type = 'percent':
specs/05-BUSINESS-LOGIC.md-701-        totalDiscount = qualifyingSubtotal * discount.value_amount / 100
specs/05-BUSINESS-LOGIC.md-702-    ELSE IF discount.value_type = 'fixed':
--
specs/05-BUSINESS-LOGIC.md-720-- On order creation: store in `order_lines.discount_allocations_json` as an array of `{discount_id, amount}`.
specs/05-BUSINESS-LOGIC.md-721-
specs/05-BUSINESS-LOGIC.md-722-### 7.7 Usage Tracking
specs/05-BUSINESS-LOGIC.md-723-
specs/05-BUSINESS-LOGIC.md-724-When an order is successfully created with a discount code:
specs/05-BUSINESS-LOGIC.md:725:- Increment `discounts.usage_count` by 1.
specs/05-BUSINESS-LOGIC.md-726-- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).
specs/05-BUSINESS-LOGIC.md-727-
specs/05-BUSINESS-LOGIC.md-728----
specs/05-BUSINESS-LOGIC.md-729-
specs/05-BUSINESS-LOGIC.md-730-## 8. Tax Calculation
--
specs/05-BUSINESS-LOGIC.md-763-Controlled by `tax_settings.prices_include_tax` (boolean).
specs/05-BUSINESS-LOGIC.md-764-
specs/05-BUSINESS-LOGIC.md-765-#### Tax-inclusive pricing (prices_include_tax = true)
specs/05-BUSINESS-LOGIC.md-766-
specs/05-BUSINESS-LOGIC.md-767-- Displayed prices on the storefront are gross (include tax).
specs/05-BUSINESS-LOGIC.md:768:- Subtotal shown to customer is gross.
specs/05-BUSINESS-LOGIC.md-769-- Tax is "extracted" from the gross amount:
specs/05-BUSINESS-LOGIC.md-770-
specs/05-BUSINESS-LOGIC.md-771-```
specs/05-BUSINESS-LOGIC.md-772-net_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)
specs/05-BUSINESS-LOGIC.md-773-tax = gross_amount - net_amount
--
specs/05-BUSINESS-LOGIC.md-920-
specs/05-BUSINESS-LOGIC.md-921-### 9.4 Free Shipping Discount Override
specs/05-BUSINESS-LOGIC.md-922-
specs/05-BUSINESS-LOGIC.md-923-If a validated discount has `value_type = 'free_shipping'`:
specs/05-BUSINESS-LOGIC.md-924-- Override the calculated shipping amount to 0.
specs/05-BUSINESS-LOGIC.md:925:- The shipping method is still selected (for fulfillment routing), but the customer is not charged.
specs/05-BUSINESS-LOGIC.md-926-
specs/05-BUSINESS-LOGIC.md-927----
specs/05-BUSINESS-LOGIC.md-928-
specs/05-BUSINESS-LOGIC.md-929-## 10. Payment Processing (Mock PSP)
specs/05-BUSINESS-LOGIC.md-930-
--
specs/05-BUSINESS-LOGIC.md-1229-    [*] --> pending : Fulfillment created (admin clicks "Create fulfillment")
specs/05-BUSINESS-LOGIC.md-1230-    pending --> shipped : Admin clicks "Mark as shipped"
specs/05-BUSINESS-LOGIC.md-1231-    shipped --> delivered : Admin clicks "Mark as delivered"
specs/05-BUSINESS-LOGIC.md-1232-
specs/05-BUSINESS-LOGIC.md-1233-    note right of pending : Fulfillment record created with tracking data
specs/05-BUSINESS-LOGIC.md:1234:    note right of shipped : Sets shipped_at timestamp, sends tracking email to customer
specs/05-BUSINESS-LOGIC.md-1235-    note right of delivered : Sets delivered_at timestamp
specs/05-BUSINESS-LOGIC.md-1236-```
specs/05-BUSINESS-LOGIC.md-1237-
specs/05-BUSINESS-LOGIC.md-1238-**Admin buttons on each fulfillment card:**
specs/05-BUSINESS-LOGIC.md-1239-
--
specs/05-BUSINESS-LOGIC.md-1243-| "Mark as delivered" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |
specs/05-BUSINESS-LOGIC.md-1244-
specs/05-BUSINESS-LOGIC.md-1245-When transitioning to `shipped`:
specs/05-BUSINESS-LOGIC.md-1246-- Set `shipped_at` timestamp.
specs/05-BUSINESS-LOGIC.md-1247-- Dispatch `FulfillmentShipped` event.
specs/05-BUSINESS-LOGIC.md:1248:- Send email notification to customer with tracking information.
specs/05-BUSINESS-LOGIC.md-1249-
specs/05-BUSINESS-LOGIC.md-1250-When transitioning to `delivered`:
specs/05-BUSINESS-LOGIC.md-1251-- Dispatch `FulfillmentDelivered` event.
specs/05-BUSINESS-LOGIC.md-1252-
specs/05-BUSINESS-LOGIC.md-1253-### 11.6 Fulfillment Guard - Inventory Behavior per Payment Method
--
specs/05-BUSINESS-LOGIC.md-1278-
specs/05-BUSINESS-LOGIC.md-1279-### 11.6 Order Export
specs/05-BUSINESS-LOGIC.md-1280-
specs/05-BUSINESS-LOGIC.md-1281-`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.
specs/05-BUSINESS-LOGIC.md-1282-
specs/05-BUSINESS-LOGIC.md: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`
specs/05-BUSINESS-LOGIC.md-1284-
specs/05-BUSINESS-LOGIC.md-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"`.
specs/05-BUSINESS-LOGIC.md-1286-
specs/05-BUSINESS-LOGIC.md-1287----
specs/05-BUSINESS-LOGIC.md-1288-
--
specs/05-BUSINESS-LOGIC.md-1304-- Email uniqueness enforced by the database unique index on `(store_id, email)`.
specs/05-BUSINESS-LOGIC.md-1305-- Password hashed using Laravel's Hash facade (bcrypt by default).
specs/05-BUSINESS-LOGIC.md-1306-
specs/05-BUSINESS-LOGIC.md-1307-### 12.2 Authentication
specs/05-BUSINESS-LOGIC.md-1308-
specs/05-BUSINESS-LOGIC.md:1309:**Separate auth guard:** `customer`
specs/05-BUSINESS-LOGIC.md-1310-
specs/05-BUSINESS-LOGIC.md-1311-Requirements:
specs/05-BUSINESS-LOGIC.md:1312:- Configure a dedicated `customer` guard in auth config, using session driver.
specs/05-BUSINESS-LOGIC.md:1313:- Configure a dedicated `customers` provider using the Customer model.
specs/05-BUSINESS-LOGIC.md-1314-- Login: email + password, scoped to the current store.
specs/05-BUSINESS-LOGIC.md:1315:- Password reset: standard Laravel password reset flow adapted for the customer guard and broker.
specs/05-BUSINESS-LOGIC.md-1316-
specs/05-BUSINESS-LOGIC.md-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.
specs/05-BUSINESS-LOGIC.md-1318-
specs/05-BUSINESS-LOGIC.md-1319-### 12.3 Data Access
specs/05-BUSINESS-LOGIC.md-1320-
specs/05-BUSINESS-LOGIC.md:1321:- Customers can only view their own orders (scoped by customer_id match).
specs/05-BUSINESS-LOGIC.md:1322:- Customers can only manage their own addresses (scoped by customer_id match).
specs/05-BUSINESS-LOGIC.md:1323:- All customer queries include the StoreScope global scope for tenant isolation.
specs/05-BUSINESS-LOGIC.md-1324-
specs/05-BUSINESS-LOGIC.md-1325-### 12.4 Guest Cart to Customer Linking
specs/05-BUSINESS-LOGIC.md-1326-
specs/05-BUSINESS-LOGIC.md-1327-```
specs/05-BUSINESS-LOGIC.md-1328-PROCEDURE linkGuestToCustomer(checkoutEmail, storeId, order):
specs/05-BUSINESS-LOGIC.md:1329:    existingCustomer = find customer by email in store
specs/05-BUSINESS-LOGIC.md-1330-    IF existingCustomer:
specs/05-BUSINESS-LOGIC.md-1331-        LINK order to existingCustomer
specs/05-BUSINESS-LOGIC.md-1332-    ELSE:
specs/05-BUSINESS-LOGIC.md:1333:        CREATE new customer with:
specs/05-BUSINESS-LOGIC.md-1334-            - email = checkoutEmail
specs/05-BUSINESS-LOGIC.md:1335:            - password_hash = null (guest customer)
specs/05-BUSINESS-LOGIC.md-1336-            - store_id = storeId
specs/05-BUSINESS-LOGIC.md:1337:        LINK order to new customer
specs/05-BUSINESS-LOGIC.md-1338-
specs/05-BUSINESS-LOGIC.md:1339:    // Guest customer can later "claim" their account
specs/05-BUSINESS-LOGIC.md-1340-    // by setting a password via registration or account-claim flow
specs/05-BUSINESS-LOGIC.md-1341-```
specs/05-BUSINESS-LOGIC.md-1342-
specs/05-BUSINESS-LOGIC.md-1343----
specs/05-BUSINESS-LOGIC.md-1344-
--
specs/01-DATABASE-SCHEMA.md-87-
specs/01-DATABASE-SCHEMA.md-88----
specs/01-DATABASE-SCHEMA.md-89-
specs/01-DATABASE-SCHEMA.md-90-### users
specs/01-DATABASE-SCHEMA.md-91-
specs/01-DATABASE-SCHEMA.md:92:Admin/staff users who manage stores (not storefront customers).
specs/01-DATABASE-SCHEMA.md-93-
specs/01-DATABASE-SCHEMA.md-94-| Column | Type | Nullable | Default | Constraints | Notes |
specs/01-DATABASE-SCHEMA.md-95-|---|---|---|---|---|---|
specs/01-DATABASE-SCHEMA.md-96-| id | INTEGER | No | autoincrement | PRIMARY KEY | |
specs/01-DATABASE-SCHEMA.md-97-| email | TEXT | No | - | UNIQUE per table | Login email |
--
specs/01-DATABASE-SCHEMA.md-525-
specs/01-DATABASE-SCHEMA.md-526-## Epic 5 - Cart, Checkout, Discounts, Shipping, and Taxes
specs/01-DATABASE-SCHEMA.md-527-
specs/01-DATABASE-SCHEMA.md-528-### carts
specs/01-DATABASE-SCHEMA.md-529-
specs/01-DATABASE-SCHEMA.md:530:Shopping cart, optionally linked to a customer.
specs/01-DATABASE-SCHEMA.md-531-
specs/01-DATABASE-SCHEMA.md-532-| Column | Type | Nullable | Default | Constraints | Notes |
specs/01-DATABASE-SCHEMA.md-533-|---|---|---|---|---|---|
specs/01-DATABASE-SCHEMA.md-534-| id | INTEGER | No | autoincrement | PRIMARY KEY | |
specs/01-DATABASE-SCHEMA.md-535-| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md:536:| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest carts |
specs/01-DATABASE-SCHEMA.md-537-| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |
specs/01-DATABASE-SCHEMA.md-538-| cart_version | INTEGER | No | 1 | - | Optimistic concurrency version counter |
specs/01-DATABASE-SCHEMA.md-539-| status | TEXT | No | 'active' | CHECK IN ('active', 'converted', 'abandoned') | |
specs/01-DATABASE-SCHEMA.md-540-| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
specs/01-DATABASE-SCHEMA.md-541-| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
specs/01-DATABASE-SCHEMA.md-542-
specs/01-DATABASE-SCHEMA.md-543-**Indexes:**
specs/01-DATABASE-SCHEMA.md-544-
specs/01-DATABASE-SCHEMA.md-545-- `idx_carts_store_id` on (`store_id`) - FK lookup
specs/01-DATABASE-SCHEMA.md:546:- `idx_carts_customer_id` on (`customer_id`) - Finding carts by customer
specs/01-DATABASE-SCHEMA.md-547-- `idx_carts_store_status` on (`store_id`, `status`) - Filtering carts by status (e.g. abandoned cart recovery)
specs/01-DATABASE-SCHEMA.md-548-
specs/01-DATABASE-SCHEMA.md-549-**Notes:** `cart_version` is incremented on every mutation and used for optimistic concurrency control.
specs/01-DATABASE-SCHEMA.md-550-
specs/01-DATABASE-SCHEMA.md-551----
--
specs/01-DATABASE-SCHEMA.md-581-| Column | Type | Nullable | Default | Constraints | Notes |
specs/01-DATABASE-SCHEMA.md-582-|---|---|---|---|---|---|
specs/01-DATABASE-SCHEMA.md-583-| id | INTEGER | No | autoincrement | PRIMARY KEY | |
specs/01-DATABASE-SCHEMA.md-584-| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
specs/01-DATABASE-SCHEMA.md-585-| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Source cart |
specs/01-DATABASE-SCHEMA.md:586:| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest checkout |
specs/01-DATABASE-SCHEMA.md-587-| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired') | |
specs/01-DATABASE-SCHEMA.md-588-| payment_method | TEXT | Yes | NULL | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Selected payment method |
specs/01-DATABASE-SCHEMA.md-589-| email | TEXT | Yes | NULL | - | Contact email |
specs/01-DATABASE-SCHEMA.md-590-| shipping_address_json | TEXT | Yes | NULL | - | JSON address object |
specs/01-DATABASE-SCHEMA.md-591-| billing_address_json | TEXT | Yes | NULL | - | JSON address object |
--
specs/01-DATABASE-SCHEMA.md-599-
specs/01-DATABASE-SCHEMA.md-600-**Indexes:**
specs/01-DATABASE-SCHEMA.md-601-
specs/01-DATABASE-SCHEMA.md-602-- `idx_checkouts_store_id` on (`store_id`) - FK lookup
specs/01-DATABASE-SCHEMA.md-603-- `idx_checkouts_cart_id` on (`cart_id`) - Finding checkout for a cart


exec

status: completed

input:
const r = await tools.exec_command({cmd: "sed -n '1,220p' app/ValueObjects/PricingResult.php; sed -n '1,180p' app/ValueObjects/TaxLine.php; sed -n '1,160p' app/ValueObjects/TaxResult.php; sed -n '1,220p' app/Models/TaxSettings.php; sed -n '1,180p' database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php", workdir: "/Users/wesner/Herd/shop", max_output_tokens: 10000, yield_time_ms: 10000});
text(r.output);


type: CommandExecution

command: sed -n '1,220p' app/ValueObjects/PricingResult.php; sed -n '1,180p' app/ValueObjects/TaxLine.php; sed -n '1,160p' app/ValueObjects/TaxResult.php; sed -n '1,220p' app/Models/TaxSettings.php; sed -n '1,180p' database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php

PricingResult.php

type: read

TaxSettings.php

status: completed

stdout:
<?php

namespace App\ValueObjects;

readonly class PricingResult
{
    /** @param list<TaxLine> $taxLines */
    public function __construct(public int $subtotal, public int $discount, public int $shipping, public array $taxLines, public int $taxTotal, public int $total, public string $currency) {}

    public function toArray(): array
    {
        return ['subtotal' => $this->subtotal, 'discount' => $this->discount, 'shipping' => $this->shipping, 'tax_lines' => array_map(fn (TaxLine $line): array => $line->toArray(), $this->taxLines), 'tax' => $this->taxTotal, 'total' => $this->total, 'currency' => $this->currency];
    }
}
<?php

namespace App\ValueObjects;

readonly class TaxLine
{
    public function __construct(public string $name, public int $rate, public int $amount) {}

    public function toArray(): array
    {
        return ['name' => $this->name, 'rate' => $this->rate, 'amount' => $this->amount];
    }
}
<?php

namespace App\ValueObjects;

readonly class TaxResult
{
    /** @param list<TaxLine> $lines */
    public function __construct(public int $total, public array $lines = []) {}
}
<?php

namespace App\Models;

use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class TaxSettings extends Model
{
    use BelongsToStore;

    protected $primaryKey = 'store_id';

    public $incrementing = false;

    protected $fillable = ['store_id', 'mode', 'provider', 'prices_include_tax', 'config_json', 'default_rate_basis_points', 'rates_json', 'provider_config_json'];

    protected function casts(): array
    {
        return ['rates_json' => 'array', 'provider_config_json' => 'array', 'config_json' => 'array', 'prices_include_tax' => 'boolean'];
    }

    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }
}
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('products', function (Blueprint $table): void {
            $table->longText('description_html')->nullable();
        });
        Schema::table('product_variants', function (Blueprint $table): void {
            $table->string('currency', 3)->nullable();
            $table->unsignedInteger('weight_g')->nullable();
            $table->string('status')->default('active')->index();
        });
        Schema::table('product_media', function (Blueprint $table): void {
            $table->string('type')->default('image');
            $table->string('storage_key')->nullable();
            $table->string('mime_type')->nullable();
            $table->unsignedBigInteger('byte_size')->nullable();
            $table->string('checksum')->nullable();
        });
        Schema::table('shipping_rates', function (Blueprint $table): void {
            $table->foreignId('zone_id')->nullable()->constrained('shipping_zones')->nullOnDelete();
        });
        Schema::table('tax_settings', function (Blueprint $table): void {
            $table->string('provider')->default('none');
            $table->boolean('prices_include_tax')->default(false);
            $table->json('config_json')->nullable();
        });
        Schema::table('checkouts', function (Blueprint $table): void {
            $table->foreignId('shipping_method_id')->nullable()->constrained('shipping_rates')->nullOnDelete();
        });
        Schema::table('order_lines', function (Blueprint $table): void {
            $table->string('title_snapshot')->nullable();
            $table->string('sku_snapshot')->nullable();
        });
        Schema::table('refunds', function (Blueprint $table): void {
            $table->string('provider_refund_id')->nullable();
        });
    }

    public function down(): void
    {
        Schema::table('refunds', function (Blueprint $table): void {
            $table->dropColumn('provider_refund_id');
        });
        Schema::table('order_lines', function (Blueprint $table): void {
            $table->dropColumn(['title_snapshot', 'sku_snapshot']);
        });
        Schema::table('checkouts', function (Blueprint $table): void {
            $table->dropForeign(['shipping_method_id']);
            $table->dropColumn('shipping_method_id');
        });
        Schema::table('tax_settings', function (Blueprint $table): void {
            $table->dropColumn(['provider', 'prices_include_tax', 'config_json']);
        });
        Schema::table('shipping_rates', function (Blueprint $table): void {
            $table->dropForeign(['zone_id']);
            $table->dropColumn('zone_id');
        });
        Schema::table('product_media', function (Blueprint $table): void {
            $table->dropColumn(['type', 'storage_key', 'mime_type', 'byte_size', 'checksum']);
        });
        Schema::table('product_variants', function (Blueprint $table): void {
            $table->dropColumn(['currency', 'weight_g', 'status']);
        });
        Schema::table('products', function (Blueprint $table): void {
            $table->dropColumn('description_html');
        });
    }
};


text:
Script completed
Wall time 0.1 seconds
Output:

text:
<?php

namespace App\ValueObjects;

readonly class PricingResult
{
    /** @param list<TaxLine> $taxLines */
    public function __construct(public int $subtotal, public int $discount, public int $shipping, public array $taxLines, public int $taxTotal, public int $total, public string $currency) {}

    public function toArray(): array
    {
        return ['subtotal' => $this->subtotal, 'discount' => $this->discount, 'shipping' => $this->shipping, 'tax_lines' => array_map(fn (TaxLine $line): array => $line->toArray(), $this->taxLines), 'tax' => $this->taxTotal, 'total' => $this->total, 'currency' => $this->currency];
    }
}
<?php

namespace App\ValueObjects;

readonly class TaxLine
{
    public function __construct(public string $name, public int $rate, public int $amount) {}

    public function toArray(): array
    {
        return ['name' => $this->name, 'rate' => $this->rate, 'amount' => $this->amount];
    }
}
<?php

namespace App\ValueObjects;

readonly class TaxResult
{
    /** @param list<TaxLine> $lines */
    public function __construct(public int $total, public array $lines = []) {}
}
<?php

namespace App\Models;

use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class TaxSettings extends Model
{
    use BelongsToStore;

    protected $primaryKey = 'store_id';

    public $incrementing = false;

    protected $fillable = ['store_id', 'mode', 'provider', 'prices_include_tax', 'config_json', 'default_rate_basis_points', 'rates_json', 'provider_config_json'];

    protected function casts(): array
    {
        return ['rates_json' => 'array', 'provider_config_json' => 'array', 'config_json' => 'array', 'prices_include_tax' => 'boolean'];
    }

    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }
}
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('products', function (Blueprint $table): void {
            $table->longText('description_html')->nullable();
        });
        Schema::table('product_variants', function (Blueprint $table): void {
            $table->string('currency', 3)->nullable();
            $table->unsignedInteger('weight_g')->nullable();
            $table->string('status')->default('active')->index();
        });
        Schema::table('product_media', function (Blueprint $table): void {
            $table->string('type')->default('image');
            $table->string('storage_key')->nullable();
            $table->string('mime_type')->nullable();
            $table->unsignedBigInteger('byte_size')->nullable();
            $table->string('checksum')->nullable();
        });
        Schema::table('shipping_rates', function (Blueprint $table): void {
            $table->foreignId('zone_id')->nullable()->constrained('shipping_zones')->nullOnDelete();
        });
        Schema::table('tax_settings', function (Blueprint $table): void {
            $table->string('provider')->default('none');
            $table->boolean('prices_include_tax')->default(false);
            $table->json('config_json')->nullable();
        });
        Schema::table('checkouts', function (Blueprint $table): void {
            $table->foreignId('shipping_method_id')->nullable()->constrained('shipping_rates')->nullOnDelete();
        });
        Schema::table('order_lines', function (Blueprint $table): void {
            $table->string('title_snapshot')->nullable();
            $table->string('sku_snapshot')->nullable();
        });
        Schema::table('refunds', function (Blueprint $table): void {
            $table->string('provider_refund_id')->nullable();
        });
    }

    public function down(): void
    {
        Schema::table('refunds', function (Blueprint $table): void {
            $table->dropColumn('provider_refund_id');
        });
        Schema::table('order_lines', function (Blueprint $table): void {
            $table->dropColumn(['title_snapshot', 'sku_snapshot']);
        });
        Schema::table('checkouts', function (Blueprint $table): void {
            $table->dropForeign(['shipping_method_id']);
            $table->dropColumn('shipping_method_id');
        });
        Schema::table('tax_settings', function (Blueprint $table): void {
            $table->dropColumn(['provider', 'prices_include_tax', 'config_json']);
        });
        Schema::table('shipping_rates', function (Blueprint $table): void {
            $table->dropForeign(['zone_id']);
            $table->dropColumn('zone_id');
        });
        Schema::table('product_media', function (Blueprint $table): void {
            $table->dropColumn(['type', 'storage_key', 'mime_type', 'byte_size', 'checksum']);
        });
        Schema::table('product_variants', function (Blueprint $table): void {
            $table->dropColumn(['currency', 'weight_g', 'status']);
        });
        Schema::table('products', function (Blueprint $table): void {
            $table->dropColumn('description_html');
        });
    }
};


exec

status: completed

input:
const r = await tools.exec_command({cmd: "ls app/Events && sed -n '1,120p' app/Events/CheckoutExpired.php 2>/dev/null || true && sed -n '1,140p' app/Events/CheckoutAddressed.php 2>/dev/null || true && rg -n \"CheckoutExpired|CheckoutAddressed|ShippingSelected\" app", workdir: "/Users/wesner/Herd/shop", max_output_tokens: 8000, yield_time_ms: 10000});
text(r.output);


command: ls app/Events && sed -n '1,120p' app/Events/CheckoutExpired.php 2>/dev/null || true && sed -n '1,140p' app/Events/CheckoutAddressed.php 2>/dev/null || true && rg -n "CheckoutExpired|CheckoutAddressed|ShippingSelected" app

type: unknown

stdout:
FulfillmentDelivered.php
OrderCancelled.php
OrderCreated.php
OrderFulfilled.php
OrderPaid.php
OrderRefunded.php
ProductStatusChanged.php
app/Services/CheckoutService.php:45:        $checkout->update(['shipping_rate_id' => $rate->getKey(), 'shipping_method_id' => $rate->getKey(), 'status' => CheckoutStatus::ShippingSelected]);
app/Services/PaymentService.php:49:                $checkout->update(['status' => CheckoutStatus::ShippingSelected]);
app/Enums/CheckoutStatus.php:9:    case ShippingSelected = 'shipping_selected';


text:
Script completed
Wall time 0.1 seconds
Output:

text:
FulfillmentDelivered.php
OrderCancelled.php
OrderCreated.php
OrderFulfilled.php
OrderPaid.php
OrderRefunded.php
ProductStatusChanged.php
app/Services/CheckoutService.php:45:        $checkout->update(['shipping_rate_id' => $rate->getKey(), 'shipping_method_id' => $rate->getKey(), 'status' => CheckoutStatus::ShippingSelected]);
app/Services/PaymentService.php:49:                $checkout->update(['status' => CheckoutStatus::ShippingSelected]);
app/Enums/CheckoutStatus.php:9:    case ShippingSelected = 'shipping_selected';


status: completed

input:
const r = await tools.exec_command({cmd: "sed -n '1,240p' app/Services/PaymentService.php; sed -n '1,180p' app/Models/Payment.php; sed -n '1,200p' app/Http/Controllers/Api/StorefrontCheckoutController.php", workdir: "/Users/wesner/Herd/shop", max_output_tokens: 12000, yield_time_ms: 10000});
text(r.output);


command: sed -n '1,240p' app/Services/PaymentService.php; sed -n '1,180p' app/Models/Payment.php; sed -n '1,200p' app/Http/Controllers/Api/StorefrontCheckoutController.php

Payment.php

cmd: sed -n '1,180p' app/Models/Payment.php

StorefrontCheckoutController.php

stdout:
<?php

namespace App\Services;

use App\Contracts\PaymentProvider;
use App\Enums\CheckoutStatus;
use App\Enums\PaymentMethod;
use App\Enums\PaymentStatus;
use App\Models\Checkout;
use App\Models\Discount;
use App\Models\Order;
use App\Models\Payment;
use Illuminate\Support\Facades\DB;

class PaymentService
{
    public function __construct(private readonly PaymentProvider $provider, private readonly InventoryService $inventory, private readonly OrderService $orders, private readonly PricingEngine $pricing) {}

    public function pay(Checkout $checkout, PaymentMethod $method, array $details = []): ?Order
    {
        return DB::transaction(function () use ($checkout, $method, $details): ?Order {
            $existing = Order::withoutGlobalScopes()->where('checkout_id', $checkout->getKey())->first();

            if ($existing !== null) {
                return $existing->load(['lines', 'payments']);
            }

            $checkout->update(['payment_method' => $method]);
            $this->pricing->calculate($checkout->refresh());
            $checkout->load('cart.lines.variant.inventory');

            if ($checkout->status !== CheckoutStatus::PaymentSelected) {
                foreach ($checkout->cart->lines as $line) {
                    if ($line->variant->inventory !== null) {
                        $this->inventory->reserve($line->variant->inventory, $line->quantity);
                    }
                }
            }

            $result = $this->provider->charge($checkout, $method, $details);

            if ($result->status === PaymentStatus::Failed) {
                foreach ($checkout->cart->lines as $line) {
                    if ($line->variant->inventory !== null) {
                        $this->inventory->release($line->variant->inventory, $line->quantity);
                    }
                }

                $checkout->update(['status' => CheckoutStatus::ShippingSelected]);

                return null;
            }

            $order = $this->orders->createFromCheckout($checkout, $result);
            Payment::create(['order_id' => $order->getKey(), 'provider' => 'mock', 'provider_payment_id' => $result->reference, 'method' => $method, 'status' => $result->status, 'amount' => $order->total_amount, 'raw_json_encrypted' => json_encode(['reference' => $result->reference, 'message' => $result->message])]);

            if ($checkout->discount_code !== null) {
                Discount::withoutGlobalScopes()->where('store_id', $checkout->store_id)->where('code', $checkout->discount_code)->increment('usage_count');
            }

            return $order->refresh()->load(['lines', 'payments']);
        });
    }
}
<?php

namespace App\Models;

use App\Enums\PaymentMethod;
use App\Enums\PaymentStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Payment extends Model
{
    protected $fillable = ['order_id', 'provider', 'provider_payment_id', 'method', 'status', 'amount', 'raw_json_encrypted'];

    protected $hidden = ['raw_json_encrypted'];

    protected function casts(): array
    {
        return ['method' => PaymentMethod::class, 'status' => PaymentStatus::class, 'raw_json_encrypted' => 'encrypted'];
    }

    public function order(): BelongsTo
    {
        return $this->belongsTo(Order::class);
    }
}
<?php

namespace App\Http\Controllers\Api;

use App\Enums\PaymentMethod;
use App\Exceptions\InsufficientInventoryException;
use App\Exceptions\InvalidDiscountException;
use App\Http\Controllers\Controller;
use App\Models\Cart;
use App\Models\Checkout;
use App\Services\CheckoutService;
use App\Services\DiscountService;
use App\Services\PaymentService;
use App\Services\PricingEngine;
use App\Services\ShippingCalculator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class StorefrontCheckoutController extends Controller
{
    public function __construct(private readonly CheckoutService $checkouts, private readonly PricingEngine $pricing, private readonly ShippingCalculator $shipping, private readonly DiscountService $discounts, private readonly PaymentService $payments) {}

    public function store(Request $request): JsonResponse
    {
        $data = $request->validate(['cart_id' => ['required', 'integer'], 'email' => ['required', 'email']]);
        $cart = Cart::query()->with('lines')->findOrFail($data['cart_id']);
        $checkout = $this->checkouts->create($cart, $data['email'], $request->user('customer'));
        $this->pricing->calculate($checkout);

        return response()->json($this->payload($checkout->refresh()), 201);
    }

    public function show(int $checkoutId): JsonResponse
    {
        $checkout = $this->checkout($checkoutId);

        if ($checkout->isExpired()) {
            return response()->json(['message' => 'Checkout expired.'], 410);
        }

        return response()->json($this->payload($checkout));
    }

    public function address(Request $request, int $checkoutId): JsonResponse
    {
        $data = $request->validate(['shipping_address' => ['required', 'array'], 'billing_address' => ['nullable', 'array'], 'use_shipping_as_billing' => ['nullable', 'boolean']]);
        $checkout = $this->checkouts->setAddress($this->checkout($checkoutId), $data['shipping_address'], $data['billing_address'] ?? null, $data['use_shipping_as_billing'] ?? true);

        return response()->json($this->payload($checkout));
    }

    public function shippingMethod(Request $request, int $checkoutId): JsonResponse
    {
        $data = $request->validate(['shipping_method_id' => ['required', 'integer'], 'shipping_rate_id' => ['nullable', 'integer']]);
        $checkout = $this->checkouts->setShippingMethod($this->checkout($checkoutId), $data['shipping_rate_id'] ?? $data['shipping_method_id']);

        return response()->json($this->payload($checkout));
    }

    public function applyDiscount(Request $request, int $checkoutId): JsonResponse
    {
        $data = $request->validate(['code' => ['required', 'string', 'max:64']]);
        $checkout = $this->checkout($checkoutId);
        try {
            $discount = $this->discounts->validate($data['code'], app('current_store'), $checkout->cart);
        } catch (InvalidDiscountException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => $exception->reason], 422);
        }
        $checkout->update(['discount_code' => $discount->code]);
        $this->pricing->calculate($checkout->refresh());

        return response()->json($this->payload($checkout->refresh()));
    }

    public function pay(Request $request, int $checkoutId): JsonResponse
    {
        $data = $request->validate(['payment_method' => ['required', 'in:credit_card,paypal,bank_transfer'], 'card_number' => ['nullable', 'string'], 'card_expiry' => ['nullable', 'string'], 'card_cvc' => ['nullable', 'string']]);
        try {
            $checkout = $this->checkouts->selectPaymentMethod($this->checkout($checkoutId), $data['payment_method']);
            $order = $this->payments->pay($checkout, PaymentMethod::from($data['payment_method']), $data);
        } catch (InsufficientInventoryException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => 'insufficient_inventory'], 422);
        }

        if ($order === null) {
            return response()->json(['message' => 'Payment failed.', 'code' => 'payment_failed'], 422);
        }

        return response()->json(['order' => ['id' => $order->id, 'order_number' => $order->order_number, 'status' => $order->status, 'financial_status' => $order->financial_status, 'total_amount' => $order->total_amount], 'message' => $order->financial_status->value === 'pending' ? 'Bank transfer instructions generated.' : 'Order confirmed.']);
    }

    public function paymentMethod(Request $request, int $checkoutId): JsonResponse
    {
        $data = $request->validate(['payment_method' => ['required', 'in:credit_card,paypal,bank_transfer']]);
        try {
            $checkout = $this->checkouts->selectPaymentMethod($this->checkout($checkoutId), $data['payment_method']);
        } catch (InsufficientInventoryException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => 'insufficient_inventory'], 422);
        }

        return response()->json($this->payload($checkout));
    }

    private function checkout(int $checkoutId): Checkout
    {
        return Checkout::query()->with(['cart.lines.variant.product', 'shippingRate'])->findOrFail($checkoutId);
    }

    /** @return array<string, mixed> */
    private function payload(Checkout $checkout): array
    {
        $totals = $checkout->totals_json ?? $this->pricing->calculate($checkout)->toArray();
        $rates = $checkout->shipping_address_json === null ? collect() : $this->shipping->getAvailableRates(app('current_store'), $checkout->shipping_address_json);

        return ['id' => $checkout->id, 'store_id' => $checkout->store_id, 'cart_id' => $checkout->cart_id, 'customer_id' => $checkout->customer_id, 'status' => $checkout->status, 'email' => $checkout->email, 'payment_method' => $checkout->payment_method, 'shipping_address_json' => $checkout->shipping_address_json, 'billing_address_json' => $checkout->billing_address_json, 'shipping_method_id' => $checkout->shipping_rate_id, 'discount_code' => $checkout->discount_code, 'lines' => $checkout->cart->lines->map(fn ($line): array => ['variant_id' => $line->variant_id, 'product_title' => $line->variant->product->title, 'variant_title' => $line->variant->title, 'sku' => $line->variant->sku, 'quantity' => $line->quantity, 'unit_price_amount' => $line->unit_price_amount, 'line_total_amount' => $line->line_total_amount])->all(), 'totals' => $totals, 'available_shipping_methods' => $rates->map(fn ($rate): array => ['id' => $rate->id, 'name' => $rate->name, 'type' => $rate->type, 'price_amount' => $rate->price_amount, 'currency' => $rate->currency, 'estimated_days_min' => $rate->estimated_days_min, 'estimated_days_max' => $rate->estimated_days_max])->all(), 'expires_at' => $checkout->expires_at, 'created_at' => $checkout->created_at];
    }
}


text:
Script completed
Wall time 0.1 seconds
Output:

text:
<?php

namespace App\Services;

use App\Contracts\PaymentProvider;
use App\Enums\CheckoutStatus;
use App\Enums\PaymentMethod;
use App\Enums\PaymentStatus;
use App\Models\Checkout;
use App\Models\Discount;
use App\Models\Order;
use App\Models\Payment;
use Illuminate\Support\Facades\DB;

class PaymentService
{
    public function __construct(private readonly PaymentProvider $provider, private readonly InventoryService $inventory, private readonly OrderService $orders, private readonly PricingEngine $pricing) {}

    public function pay(Checkout $checkout, PaymentMethod $method, array $details = []): ?Order
    {
        return DB::transaction(function () use ($checkout, $method, $details): ?Order {
            $existing = Order::withoutGlobalScopes()->where('checkout_id', $checkout->getKey())->first();

            if ($existing !== null) {
                return $existing->load(['lines', 'payments']);
            }

            $checkout->update(['payment_method' => $method]);
            $this->pricing->calculate($checkout->refresh());
            $checkout->load('cart.lines.variant.inventory');

            if ($checkout->status !== CheckoutStatus::PaymentSelected) {
                foreach ($checkout->cart->lines as $line) {
                    if ($line->variant->inventory !== null) {
                        $this->inventory->reserve($line->variant->inventory, $line->quantity);
                    }
                }
            }

            $result = $this->provider->charge($checkout, $method, $details);

            if ($result->status === PaymentStatus::Failed) {
                foreach ($checkout->cart->lines as $line) {
                    if ($line->variant->inventory !== null) {
                        $this->inventory->release($line->variant->inventory, $line->quantity);
                    }
                }

                $checkout->update(['status' => CheckoutStatus::ShippingSelected]);

                return null;
            }

            $order = $this->orders->createFromCheckout($checkout, $result);
            Payment::create(['order_id' => $order->getKey(), 'provider' => 'mock', 'provider_payment_id' => $result->reference, 'method' => $method, 'status' => $result->status, 'amount' => $order->total_amount, 'raw_json_encrypted' => json_encode(['reference' => $result->reference, 'message' => $result->message])]);

            if ($checkout->discount_code !== null) {
                Discount::withoutGlobalScopes()->where('store_id', $checkout->store_id)->where('code', $checkout->discount_code)->increment('usage_count');
            }

            return $order->refresh()->load(['lines', 'payments']);
        });
    }
}
<?php

namespace App\Models;

use App\Enums\PaymentMethod;
use App\Enums\PaymentStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Payment extends Model
{
    protected $fillable = ['order_id', 'provider', 'provider_payment_id', 'method', 'status', 'amount', 'raw_json_encrypted'];

    protected $hidden = ['raw_json_encrypted'];

    protected function casts(): array
    {
        return ['method' => PaymentMethod::class, 'status' => PaymentStatus::class, 'raw_json_encrypted' => 'encrypted'];
    }

    public function order(): BelongsTo
    {
        return $this->belongsTo(Order::class);
    }
}
<?php

namespace App\Http\Controllers\Api;

use App\Enums\PaymentMethod;
use App\Exceptions\InsufficientInventoryException;
use App\Exceptions\InvalidDiscountException;
use App\Http\Controllers\Controller;
use App\Models\Cart;
use App\Models\Checkout;
use App\Services\CheckoutService;
use App\Services\DiscountService;
use App\Services\PaymentService;
use App\Services\PricingEngine;
use App\Services\ShippingCalculator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class StorefrontCheckoutController extends Controller
{
    public function __construct(private readonly CheckoutService $checkouts, private readonly PricingEngine $pricing, private readonly ShippingCalculator $shipping, private readonly DiscountService $discounts, private readonly PaymentService $payments) {}

    public function store(Request $request): JsonResponse
    {
        $data = $request->validate(['cart_id' => ['required', 'integer'], 'email' => ['required', 'email']]);
        $cart = Cart::query()->with('lines')->findOrFail($data['cart_id']);
        $checkout = $this->checkouts->create($cart, $data['email'], $request->user('customer'));
        $this->pricing->calculate($checkout);

        return response()->json($this->payload($checkout->refresh()), 201);
    }

    public function show(int $checkoutId): JsonResponse
    {
        $checkout = $this->checkout($checkoutId);

        if ($checkout->isExpired()) {
            return response()->json(['message' => 'Checkout expired.'], 410);
        }

        return response()->json($this->payload($checkout));
    }

    public function address(Request $request, int $checkoutId): JsonResponse
    {
        $data = $request->validate(['shipping_address' => ['required', 'array'], 'billing_address' => ['nullable', 'array'], 'use_shipping_as_billing' => ['nullable', 'boolean']]);
        $checkout = $this->checkouts->setAddress($this->checkout($checkoutId), $data['shipping_address'], $data['billing_address'] ?? null, $data['use_shipping_as_billing'] ?? true);

        return response()->json($this->payload($checkout));
    }

    public function shippingMethod(Request $request, int $checkoutId): JsonResponse
    {
        $data = $request->validate(['shipping_method_id' => ['required', 'integer'], 'shipping_rate_id' => ['nullable', 'integer']]);
        $checkout = $this->checkouts->setShippingMethod($this->checkout($checkoutId), $data['shipping_rate_id'] ?? $data['shipping_method_id']);

        return response()->json($this->payload($checkout));
    }

    public function applyDiscount(Request $request, int $checkoutId): JsonResponse
    {
        $data = $request->validate(['code' => ['required', 'string', 'max:64']]);
        $checkout = $this->checkout($checkoutId);
        try {
            $discount = $this->discounts->validate($data['code'], app('current_store'), $checkout->cart);
        } catch (InvalidDiscountException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => $exception->reason], 422);
        }
        $checkout->update(['discount_code' => $discount->code]);
        $this->pricing->calculate($checkout->refresh());

        return response()->json($this->payload($checkout->refresh()));
    }

    public function pay(Request $request, int $checkoutId): JsonResponse
    {
        $data = $request->validate(['payment_method' => ['required', 'in:credit_card,paypal,bank_transfer'], 'card_number' => ['nullable', 'string'], 'card_expiry' => ['nullable', 'string'], 'card_cvc' => ['nullable', 'string']]);
        try {
            $checkout = $this->checkouts->selectPaymentMethod($this->checkout($checkoutId), $data['payment_method']);
            $order = $this->payments->pay($checkout, PaymentMethod::from($data['payment_method']), $data);
        } catch (InsufficientInventoryException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => 'insufficient_inventory'], 422);
        }

        if ($order === null) {
            return response()->json(['message' => 'Payment failed.', 'code' => 'payment_failed'], 422);
        }

        return response()->json(['order' => ['id' => $order->id, 'order_number' => $order->order_number, 'status' => $order->status, 'financial_status' => $order->financial_status, 'total_amount' => $order->total_amount], 'message' => $order->financial_status->value === 'pending' ? 'Bank transfer instructions generated.' : 'Order confirmed.']);
    }

    public function paymentMethod(Request $request, int $checkoutId): JsonResponse
    {
        $data = $request->validate(['payment_method' => ['required', 'in:credit_card,paypal,bank_transfer']]);
        try {
            $checkout = $this->checkouts->selectPaymentMethod($this->checkout($checkoutId), $data['payment_method']);
        } catch (InsufficientInventoryException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'code' => 'insufficient_inventory'], 422);
        }

        return response()->json($this->payload($checkout));
    }

    private function checkout(int $checkoutId): Checkout
    {
        return Checkout::query()->with(['cart.lines.variant.product', 'shippingRate'])->findOrFail($checkoutId);
    }

    /** @return array<string, mixed> */
    private function payload(Checkout $checkout): array
    {
        $totals = $checkout->totals_json ?? $this->pricing->calculate($checkout)->toArray();
        $rates = $checkout->shipping_address_json === null ? collect() : $this->shipping->getAvailableRates(app('current_store'), $checkout->shipping_address_json);

        return ['id' => $checkout->id, 'store_id' => $checkout->store_id, 'cart_id' => $checkout->cart_id, 'customer_id' => $checkout->customer_id, 'status' => $checkout->status, 'email' => $checkout->email, 'payment_method' => $checkout->payment_method, 'shipping_address_json' => $checkout->shipping_address_json, 'billing_address_json' => $checkout->billing_address_json, 'shipping_method_id' => $checkout->shipping_rate_id, 'discount_code' => $checkout->discount_code, 'lines' => $checkout->cart->lines->map(fn ($line): array => ['variant_id' => $line->variant_id, 'product_title' => $line->variant->product->title, 'variant_title' => $line->variant->title, 'sku' => $line->variant->sku, 'quantity' => $line->quantity, 'unit_price_amount' => $line->unit_price_amount, 'line_total_amount' => $line->line_total_amount])->all(), 'totals' => $totals, 'available_shipping_methods' => $rates->map(fn ($rate): array => ['id' => $rate->id, 'name' => $rate->name, 'type' => $rate->type, 'price_amount' => $rate->price_amount, 'currency' => $rate->currency, 'estimated_days_min' => $rate->estimated_days_min, 'estimated_days_max' => $rate->estimated_days_max])->all(), 'expires_at' => $checkout->expires_at, 'created_at' => $checkout->created_at];
    }
}


type: AgentMessage

type: Text

text: The spec comparison found several concrete mismatches rather than broad architectural gaps: inactive variants could enter carts, guest-cart merges summed duplicates, discount failures used non-spec codes, and address changes did not refresh totals. I’m correcting those paths first, while preserving the existing public service signatures for current callers.

phase: commentary


type: message

role: assistant


exec

status: completed

input:
const patch = "*** Begin Patch\n*** Update File: /Users/wesner/Herd/shop/app/Models/ProductVariant.php\n@@\n namespace App\\Models;\n \n+use App\\Enums\\VariantStatus;\n use Illuminate\\Database\\Eloquent\\Model;\n@@\n     protected function casts(): array\n     {\n-        return ['requires_shipping' => 'boolean', 'is_default' => 'boolean', 'metadata' => 'array'];\n+        return ['requires_shipping' => 'boolean', 'is_default' => 'boolean', 'metadata' => 'array', 'status' => VariantStatus::class];\n     }\n*** Update File: /Users/wesner/Herd/shop/app/Services/CartService.php\n@@\n namespace App\\Services;\n \n use App\\Enums\\ProductStatus;\n+use App\\Enums\\VariantStatus;\n@@\n-            if ($variant->product->store_id !== $cart->store_id || $variant->product->status !== ProductStatus::Active) {\n+            if ($variant->product->store_id !== $cart->store_id || $variant->product->status !== ProductStatus::Active || $variant->status !== VariantStatus::Active) {\n                 abort(404);\n             }\n@@\n-            if ($variant->inventory === null || ! $variant->inventory->canSell($newQuantity)) {\n+            if ($variant->inventory !== null && ! $variant->inventory->canSell($newQuantity)) {\n                 throw new InsufficientInventoryException;\n             }\n@@\n-            if ($line->variant->inventory === null || ! $line->variant->inventory->canSell($quantity)) {\n+            if ($line->variant->inventory !== null && ! $line->variant->inventory->canSell($quantity)) {\n                 throw new InsufficientInventoryException;\n             }\n@@\n     public function mergeOnLogin(Cart $guest, Cart $customer): Cart\n     {\n-        foreach ($guest->load('lines')->lines as $line) {\n-            try {\n-                $this->addLine($customer, $line->variant_id, $line->quantity);\n-            } catch (InsufficientInventoryException) {\n-                continue;\n-            }\n-        }\n-\n-        $guest->update(['status' => 'converted']);\n-\n-        return $customer->refresh()->load('lines');\n+        if ($guest->store_id !== $customer->store_id) {\n+            throw new \\InvalidArgumentException('Carts must belong to the same store.');\n+        }\n+\n+        DB::transaction(function () use ($guest, $customer): void {\n+            $guest = Cart::withoutGlobalScopes()->lockForUpdate()->with('lines.variant.product')->findOrFail($guest->getKey());\n+            $customer = Cart::withoutGlobalScopes()->lockForUpdate()->with('lines')->findOrFail($customer->getKey());\n+            $customerLines = $customer->lines->keyBy('variant_id');\n+\n+            foreach ($guest->lines as $guestLine) {\n+                $variant = $guestLine->variant;\n+\n+                if ($variant === null || $variant->product === null || $variant->product->status !== ProductStatus::Active || $variant->status !== VariantStatus::Active) {\n+                    continue;\n+                }\n+\n+                $customerLine = $customerLines->get($guestLine->variant_id);\n+                $quantity = max((int) ($customerLine?->quantity ?? 0), (int) $guestLine->quantity);\n+\n+                if ($variant->inventory !== null && ! $variant->inventory->canSell($quantity)) {\n+                    continue;\n+                }\n+\n+                $attributes = [\n+                    'quantity' => $quantity,\n+                    'unit_price_amount' => $variant->price_amount,\n+                    'line_subtotal_amount' => $variant->price_amount * $quantity,\n+                    'line_discount_amount' => 0,\n+                    'line_total_amount' => $variant->price_amount * $quantity,\n+                ];\n+\n+                if ($customerLine === null) {\n+                    $customerLine = $customer->lines()->create(['variant_id' => $variant->getKey(), ...$attributes]);\n+                    $customerLines->put($variant->getKey(), $customerLine);\n+                } else {\n+                    $customerLine->update($attributes);\n+                }\n+\n+                $customer->increment('cart_version');\n+            }\n+\n+            $guest->update(['status' => 'abandoned']);\n+            session()->forget('cart_id_'.$guest->store_id);\n+        });\n+\n+        return $customer->refresh()->load('lines');\n     }\n }\n*** Update File: /Users/wesner/Herd/shop/app/Services/ProductService.php\n@@\n     public function create(Store $store, array $data): Product\n     {\n         return DB::transaction(function () use ($store, $data): Product {\n+            $status = $data['status'] ?? ProductStatus::Draft;\n+            $status = $status instanceof ProductStatus ? $status : ProductStatus::from($status);\n             $product = Product::withoutGlobalScopes()->create([\n@@\n-                'status' => $data['status'] ?? ProductStatus::Draft,\n-                'published_at' => ($data['status'] ?? ProductStatus::Draft) === ProductStatus::Active ? now() : null,\n+                'status' => $status,\n+                'published_at' => $status === ProductStatus::Active ? now() : null,\n@@\n     public function update(Product $product, array $data): Product\n     {\n         $updates = array_intersect_key($data, array_flip(['title', 'description', 'description_html', 'vendor', 'product_type', 'tags', 'status', 'published_at']));\n+\n+        if (isset($updates['status']) && ! $updates['status'] instanceof ProductStatus) {\n+            $updates['status'] = ProductStatus::from($updates['status']);\n+        }\n*** Update File: /Users/wesner/Herd/shop/app/Services/CheckoutService.php\n@@\n     public function setAddress(Checkout $checkout, array $address, ?array $billing = null, bool $useShippingAsBilling = true): Checkout\n     {\n         Validator::make($address, ['first_name' => ['required', 'string', 'max:255'], 'last_name' => ['required', 'string', 'max:255'], 'address1' => ['required', 'string', 'max:500'], 'city' => ['required', 'string', 'max:255'], 'country_code' => ['required', 'string', 'size:2'], 'postal_code' => ['required', 'string', 'max:20']])->validate();\n         $checkout->update(['shipping_address_json' => $address, 'billing_address_json' => $useShippingAsBilling ? $address : $billing, 'status' => CheckoutStatus::Addressed]);\n+        $this->pricing->calculate($checkout->refresh());\n \n         return $checkout->refresh();\n@@\n     public function expireCheckout(Checkout $checkout): void\n     {\n-        $checkout->update(['status' => CheckoutStatus::Expired]);\n+        if ($checkout->status === CheckoutStatus::Expired || $checkout->status === CheckoutStatus::Completed) {\n+            return;\n+        }\n+\n+        DB::transaction(function () use ($checkout): void {\n+            if ($checkout->status === CheckoutStatus::PaymentSelected) {\n+                $checkout->load('cart.lines.variant.inventory');\n+\n+                foreach ($checkout->cart->lines as $line) {\n+                    if ($line->variant->inventory !== null) {\n+                        $this->inventory->release($line->variant->inventory, $line->quantity);\n+                    }\n+                }\n+            }\n+\n+            $checkout->update(['status' => CheckoutStatus::Expired]);\n+        });\n     }\n }\n*** End Patch";
text(await tools.apply_patch(patch));


type: FileChange

type: update

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

+use App\Enums\VariantStatus;
 use Illuminate\Database\Eloquent\Model;
@@ -15,3 +16,3 @@
     {
-        return ['requires_shipping' => 'boolean', 'is_default' => 'boolean', 'metadata' => 'array'];
+        return ['requires_shipping' => 'boolean', 'is_default' => 'boolean', 'metadata' => 'array', 'status' => VariantStatus::class];
     }

unified_diff:
@@ -20,2 +20,4 @@
         return DB::transaction(function () use ($store, $data): Product {
+            $status = $data['status'] ?? ProductStatus::Draft;
+            $status = $status instanceof ProductStatus ? $status : ProductStatus::from($status);
             $product = Product::withoutGlobalScopes()->create([
@@ -29,4 +31,4 @@
                 'tags' => $data['tags'] ?? [],
-                'status' => $data['status'] ?? ProductStatus::Draft,
-                'published_at' => ($data['status'] ?? ProductStatus::Draft) === ProductStatus::Active ? now() : null,
+                'status' => $status,
+                'published_at' => $status === ProductStatus::Active ? now() : null,
             ]);
@@ -45,2 +47,6 @@

+        if (isset($updates['status']) && ! $updates['status'] instanceof ProductStatus) {
+            $updates['status'] = ProductStatus::from($updates['status']);
+        }
+
         if (array_key_exists('description', $data)) {

unified_diff:
@@ -32,2 +32,3 @@
         $checkout->update(['shipping_address_json' => $address, 'billing_address_json' => $useShippingAsBilling ? $address : $billing, 'status' => CheckoutStatus::Addressed]);
+        $this->pricing->calculate($checkout->refresh());

@@ -75,3 +76,19 @@
     {
-        $checkout->update(['status' => CheckoutStatus::Expired]);
+        if ($checkout->status === CheckoutStatus::Expired || $checkout->status === CheckoutStatus::Completed) {
+            return;
+        }
+
+        DB::transaction(function () use ($checkout): void {
+            if ($checkout->status === CheckoutStatus::PaymentSelected) {
+                $checkout->load('cart.lines.variant.inventory');
+
+                foreach ($checkout->cart->lines as $line) {
+                    if ($line->variant->inventory !== null) {
+                        $this->inventory->release($line->variant->inventory, $line->quantity);
+                    }
+                }
+            }
+
+            $checkout->update(['status' => CheckoutStatus::Expired]);
+        });
     }

unified_diff:
@@ -5,2 +5,3 @@
 use App\Enums\ProductStatus;
+use App\Enums\VariantStatus;
 use App\Exceptions\CartVersionConflictException;
@@ -33,3 +34,3 @@

-            if ($variant->product->store_id !== $cart->store_id || $variant->product->status !== ProductStatus::Active) {
+            if ($variant->product->store_id !== $cart->store_id || $variant->product->status !== ProductStatus::Active || $variant->status !== VariantStatus::Active) {
                 abort(404);
@@ -40,3 +41,3 @@

-            if ($variant->inventory === null || ! $variant->inventory->canSell($newQuantity)) {
+            if ($variant->inventory !== null && ! $variant->inventory->canSell($newQuantity)) {
                 throw new InsufficientInventoryException;
@@ -67,3 +68,3 @@

-            if ($line->variant->inventory === null || ! $line->variant->inventory->canSell($quantity)) {
+            if ($line->variant->inventory !== null && ! $line->variant->inventory->canSell($quantity)) {
                 throw new InsufficientInventoryException;
@@ -110,11 +111,46 @@
     {
-        foreach ($guest->load('lines')->lines as $line) {
-            try {
-                $this->addLine($customer, $line->variant_id, $line->quantity);
-            } catch (InsufficientInventoryException) {
-                continue;
+        if ($guest->store_id !== $customer->store_id) {
+            throw new \InvalidArgumentException('Carts must belong to the same store.');
+        }
+
+        DB::transaction(function () use ($guest, $customer): void {
+            $guest = Cart::withoutGlobalScopes()->lockForUpdate()->with('lines.variant.product')->findOrFail($guest->getKey());
+            $customer = Cart::withoutGlobalScopes()->lockForUpdate()->with('lines')->findOrFail($customer->getKey());
+            $customerLines = $customer->lines->keyBy('variant_id');
+
+            foreach ($guest->lines as $guestLine) {
+                $variant = $guestLine->variant;
+
+                if ($variant === null || $variant->product === null || $variant->product->status !== ProductStatus::Active || $variant->status !== VariantStatus::Active) {
+                    continue;
+                }
+
+                $customerLine = $customerLines->get($guestLine->variant_id);
+                $quantity = max((int) ($customerLine?->quantity ?? 0), (int) $guestLine->quantity);
+
+                if ($variant->inventory !== null && ! $variant->inventory->canSell($quantity)) {
+                    continue;
+                }
+
+                $attributes = [
+                    'quantity' => $quantity,
+                    'unit_price_amount' => $variant->price_amount,
+                    'line_subtotal_amount' => $variant->price_amount * $quantity,
+                    'line_discount_amount' => 0,
+                    'line_total_amount' => $variant->price_amount * $quantity,
+                ];
+
+                if ($customerLine === null) {
+                    $customerLine = $customer->lines()->create(['variant_id' => $variant->getKey(), ...$attributes]);
+                    $customerLines->put($variant->getKey(), $customerLine);
+                } else {
+                    $customerLine->update($attributes);
+                }
+
+                $customer->increment('cart_version');
             }
-        }

-        $guest->update(['status' => 'converted']);
+            $guest->update(['status' => 'abandoned']);
+            session()->forget('cart_id_'.$guest->store_id);
+        });

stdout:
Success. Updated the following files:
M /Users/wesner/Herd/shop/app/Models/ProductVariant.php
M /Users/wesner/Herd/shop/app/Services/CartService.php
M /Users/wesner/Herd/shop/app/Services/ProductService.php
M /Users/wesner/Herd/shop/app/Services/CheckoutService.php


text:
Script completed
Wall time 0.0 seconds
Output: