Output:
Chunk ID: f60047
Wall time: 0.1871 seconds
Process exited with code 0
Original token count: 58
Output:

   INFO  Model [app/Models/Discount.php] created successfully.

   INFO  Factory [database/factories/DiscountFactory.php] created successfully.

   INFO  Seeder [database/seeders/DiscountSeeder.php] created successfully.


php artisan make:model TaxSettings --factory --seed --no-interaction

   INFO  Model [app/Models/TaxSettings.php] created successfully.

   INFO  Factory [database/factories/TaxSettingsFactory.php] created successfully.

   INFO  Seeder [database/seeders/TaxSettingsSeeder.php] created successfully.


php artisan make:model Checkout --factory --seed --no-interaction

   INFO  Model [app/Models/Checkout.php] created successfully.

   INFO  Factory [database/factories/CheckoutFactory.php] created successfully.

   INFO  Seeder [database/seeders/CheckoutSeeder.php] created successfully.


php artisan make:model Discount --factory --seed --no-interaction

   INFO  Model [app/Models/Discount.php] created successfully.

   INFO  Factory [database/factories/DiscountFactory.php] created successfully.

   INFO  Seeder [database/seeders/DiscountSeeder.php] created successfully.


php artisan make:model ShippingZone --factory --seed --no-interaction

   INFO  Model [app/Models/ShippingZone.php] created successfully.

   INFO  Factory [database/factories/ShippingZoneFactory.php] created successfully.

   INFO  Seeder [database/seeders/ShippingZoneSeeder.php] created successfully.


php artisan make:model Cart --factory --seed --no-interaction

   INFO  Model [app/Models/Cart.php] created successfully.

   INFO  Factory [database/factories/CartFactory.php] created successfully.

   INFO  Seeder [database/seeders/CartSeeder.php] created successfully.


php artisan make:model ShippingRate --factory --seed --no-interaction

   INFO  Model [app/Models/ShippingRate.php] created successfully.

   INFO  Factory [database/factories/ShippingRateFactory.php] created successfully.

   INFO  Seeder [database/seeders/ShippingRateSeeder.php] created successfully.


php artisan make:model CartLine --factory --seed --no-interaction

   INFO  Model [app/Models/CartLine.php] created successfully.

   INFO  Factory [database/factories/CartLineFactory.php] created successfully.

   INFO  Seeder [database/seeders/CartLineSeeder.php] created successfully.


php artisan make:migration create_carts_table --create=carts --no-interaction


Output:
Chunk ID: 1d638f
Wall time: 0.0285 seconds
Process exited with code 0
Original token count: 27
Output:

   INFO  Migration [database/migrations/2026_05_03_225400_create_carts_table.php] created successfully.


php artisan make:migration create_carts_table --create=carts --no-interaction

INFO  Migration [database/migrations/2026_05_03_225400_create_carts_table.php] created successfully.


php artisan make:migration create_cart_lines_table --create=cart_lines --no-interaction


Output:
Chunk ID: 7b0a77
Wall time: 0.0423 seconds
Process exited with code 0
Original token count: 29
Output:

   INFO  Migration [database/migrations/2026_05_03_225405_create_cart_lines_table.php] created successfully.


php artisan make:migration create_cart_lines_table --create=cart_lines --no-interaction

INFO  Migration [database/migrations/2026_05_03_225405_create_cart_lines_table.php] created successfully.


php artisan make:migration create_checkouts_table --create=checkouts --no-interaction


Output:
Chunk ID: 1566ea
Wall time: 0.0277 seconds
Process exited with code 0
Original token count: 28
Output:

   INFO  Migration [database/migrations/2026_05_03_225409_create_checkouts_table.php] created successfully.


php artisan make:migration create_checkouts_table --create=checkouts --no-interaction

INFO  Migration [database/migrations/2026_05_03_225409_create_checkouts_table.php] created successfully.


php artisan make:migration create_shipping_zones_table --create=shipping_zones --no-interaction


Output:
Chunk ID: d10b1e
Wall time: 0.0581 seconds
Process exited with code 0
Original token count: 30
Output:

   INFO  Migration [database/migrations/2026_05_03_225413_create_shipping_zones_table.php] created successfully.


php artisan make:migration create_shipping_zones_table --create=shipping_zones --no-interaction

INFO  Migration [database/migrations/2026_05_03_225413_create_shipping_zones_table.php] created successfully.


php artisan make:migration create_shipping_rates_table --create=shipping_rates --no-interaction


Output:
Chunk ID: e17f9d
Wall time: 0.0457 seconds
Process exited with code 0
Original token count: 30
Output:

   INFO  Migration [database/migrations/2026_05_03_225418_create_shipping_rates_table.php] created successfully.


php artisan make:migration create_shipping_rates_table --create=shipping_rates --no-interaction

INFO  Migration [database/migrations/2026_05_03_225418_create_shipping_rates_table.php] created successfully.


php artisan make:migration create_tax_settings_table --create=tax_settings --no-interaction


Output:
Chunk ID: 6f8eaf
Wall time: 0.0438 seconds
Process exited with code 0
Original token count: 29
Output:

   INFO  Migration [database/migrations/2026_05_03_225422_create_tax_settings_table.php] created successfully.


php artisan make:migration create_tax_settings_table --create=tax_settings --no-interaction

INFO  Migration [database/migrations/2026_05_03_225422_create_tax_settings_table.php] created successfully.


php artisan make:migration create_discounts_table --create=discounts --no-interaction


Output:
Chunk ID: 9b96fd
Wall time: 0.0636 seconds
Process exited with code 0
Original token count: 28
Output:

   INFO  Migration [database/migrations/2026_05_03_225427_create_discounts_table.php] created successfully.


php artisan make:migration create_discounts_table --create=discounts --no-interaction

INFO  Migration [database/migrations/2026_05_03_225427_create_discounts_table.php] created successfully.


php artisan make:job ExpireAbandonedCheckouts --no-interaction


php artisan make:job CleanupAbandonedCarts --no-interaction


php artisan make:test Cart/CartServiceTest --pest --no-interaction


php artisan make:test Checkout/PricingServicesTest --pest --no-interaction


php artisan make:test Checkout/CheckoutServiceTest --pest --no-interaction


Output:
Chunk ID: 12067b
Wall time: 0.0548 seconds
Process exited with code 0
Original token count: 20
Output:

   INFO  Job [app/Jobs/ExpireAbandonedCheckouts.php] created successfully.


Output:
Chunk ID: 7c28a4
Wall time: 0.0457 seconds
Process exited with code 0
Original token count: 19
Output:

   INFO  Job [app/Jobs/CleanupAbandonedCarts.php] created successfully.


Output:
Chunk ID: 6eac5a
Wall time: 0.0456 seconds
Process exited with code 0
Original token count: 21
Output:

   INFO  Test [tests/Feature/Cart/CartServiceTest.php] created successfully.


Output:
Chunk ID: fb855e
Wall time: 0.0407 seconds
Process exited with code 0
Original token count: 23
Output:

   INFO  Test [tests/Feature/Checkout/PricingServicesTest.php] created successfully.


Output:
Chunk ID: cb2a6d
Wall time: 0.0399 seconds
Process exited with code 0
Original token count: 23
Output:

   INFO  Test [tests/Feature/Checkout/CheckoutServiceTest.php] created successfully.


php artisan make:job ExpireAbandonedCheckouts --no-interaction

INFO  Job [app/Jobs/ExpireAbandonedCheckouts.php] created successfully.


php artisan make:test Checkout/PricingServicesTest --pest --no-interaction

INFO  Test [tests/Feature/Checkout/PricingServicesTest.php] created successfully.


php artisan make:job CleanupAbandonedCarts --no-interaction

INFO  Job [app/Jobs/CleanupAbandonedCarts.php] created successfully.


php artisan make:test Checkout/CheckoutServiceTest --pest --no-interaction

INFO  Test [tests/Feature/Checkout/CheckoutServiceTest.php] created successfully.


php artisan make:test Cart/CartServiceTest --pest --no-interaction

INFO  Test [tests/Feature/Cart/CartServiceTest.php] created successfully.


<subagent_notification>
{"agent_path":"019df00b-76eb-7621-85c6-3c9ddae08f97","status":{"completed":"Read-only review completed. I did not edit files or run tests.\n\n**Implementation Checklist**\n\n1. Add Phase 4 schema from [specs/01-DATABASE-SCHEMA.md](/Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md:526):\n   `carts`, `cart_lines`, `checkouts`, `shipping_zones`, `shipping_rates`, `tax_settings`, `discounts`.\n   Use existing migration style, indexes, foreign keys, integer money fields, JSON text columns cast through models.\n\n2. Add enums:\n   `CartStatus`, `CheckoutStatus`, `DiscountType`, `DiscountValueType`, `DiscountStatus`, `ShippingRateType`, `TaxMode`.\n\n3. Add models/factories/seeders:\n   `Cart`, `CartLine`, `Checkout`, `ShippingZone`, `ShippingRate`, `TaxSettings`, `Discount`.\n   Follow current conventions: `casts()` method, `$attributes`, `$fillable`, typed relationships, `HasFactory`, `BelongsToStore` where `store_id` exists.\n\n4. Add `CartService`:\n   create cart, session cart lookup, add/update/remove lines, merge guest/customer carts, recalculate line amounts, increment `cart_version` on every mutation.\n\n5. Add discount foundation:\n   `DiscountService`, `InvalidDiscountException`, likely `DiscountResult` / `DiscountValidationResult`.\n   Implement case-insensitive code lookup, date/status/usage/minimum checks, product/collection applicability, proportional allocation.\n\n6. Add shipping foundation:\n   `ShippingCalculator` with zone matching, active rate filtering, flat/weight/price/carrier-stub calculations, digital-only skip, mixed-cart physical weight only.\n\n7. Add tax foundation:\n   `TaxCalculator`, `TaxProvider` contract, `ManualTaxProvider`, `StripeTaxProvider` stub, `TaxLine` / tax result objects.\n   Implement integer basis-point math, inclusive extraction with `intdiv()`, exclusive tax rounding per line.\n\n8. Add `PricingEngine`:\n   deterministic subtotal -> discount -> shipping -> tax -> total pipeline, persist result snapshot to `checkouts.totals_json`.\n\n9. Add `CheckoutService` state machine:\n   address, shipping method, payment method selection, inventory reservation, expiry.\n   Keep payment/order creation as Phase 5 boundary unless stubbing only what Phase 4 tests require.\n\n10. Add jobs/schedule:\n   `ExpireAbandonedCheckouts` and `CleanupAbandonedCarts` in `routes/console.php`.\n\n**Service Edge Cases To Test**\n\n- Cart duplicate variant add increments quantity, does not create duplicate line.\n- Cart line price snapshots do not change when variant price changes later.\n- Cart mutation with stale `expected_version` returns/conflicts before changing data.\n- Guest/customer merge uses max quantity for duplicate variants and abandons guest cart.\n- Inactive product, archived variant, wrong-store variant, and insufficient stock are rejected.\n- `InventoryPolicy::Continue` allows reservation beyond stock.\n- Updating line quantity to zero removes the line.\n- Discount codes are matched case-insensitively and scoped per store.\n- Discount status/date/usage/min purchase/product restriction failures return stable reason codes.\n- Fixed discount never exceeds qualifying subtotal.\n- Percentage/fixed allocations handle rounding remainder deterministically.\n- Product and collection restrictions are union logic, not intersection.\n- Free-shipping discount zeroes shipping but still keeps selected shipping method.\n- Shipping zone specificity prefers country+region over country-only; tied zones use lowest id.\n- No matching shipping zone blocks checkout for physical carts.\n- Digital-only cart skips shipping; mixed cart weights only physical lines.\n- Weight/price ranges handle open-ended max and boundary values exactly.\n- Tax-exclusive rounds per discounted line, then sums.\n- Tax-inclusive extracts tax after discounts using `intdiv()`.\n- Tax on shipping is clearly decided and tested because the spec says “plus shipping if taxable” but the schema lacks an explicit shipping-taxable flag.\n- Checkout cannot skip invalid state transitions.\n- Selecting payment twice does not double-reserve inventory.\n- Expiring checkout releases reserved inventory only when it had been reserved.\n- Cleanup abandoned carts also handles active checkout reservations for those carts.\n\n**Naming / Convention Risks**\n\n- Spec mismatch: roadmap Step 4.2 lists `CheckoutStatus::PaymentPending`, but business logic/schema use `payment_selected`. Use `PaymentSelected` backed by `payment_selected` to match schema and Section 6.\n- `TaxSettings` is plural as a model name. Laravel convention would normally be singular `TaxSetting`, but the roadmap explicitly says `TaxSettings`; choose deliberately and keep relation names consistent.\n- `checkouts.shipping_method_id` actually points to a `shipping_rates` row. Consider model relation name `shippingRate()` even if the DB column remains spec-compatible.\n- `DiscountService::validate()` conflicts across specs: roadmap says it returns `Discount` and throws `InvalidDiscountException`; business logic says it returns `DiscountValidationResult`. Pick one API before implementation to avoid duplicate patterns.\n- Error codes differ in wording: roadmap examples like `expired`, business spec uses `discount_expired`. Prefer the full spec codes for API/UI stability.\n- Current `InventoryService` methods accept `InventoryItem`, while checkout pseudocode says commit/reserve variant. Phase 4 services should resolve `$line->variant->inventoryItem` before calling inventory.\n- Existing tenant scoping uses `current_store`; service jobs and checkout/order flows may need `withoutGlobalScopes()` or explicit store binding to avoid hidden cross-store bugs.\n- Existing tests use Pest `test(...)` with `RefreshDatabase`; new Phase 4 tests should match that style."}}
</subagent_notification>


Erdos confirms the same service boundary and a few important edge cases. I’m implementing with `payment_selected` as the enum backing value, stable full discount reason codes, and explicit `withoutGlobalScopes()` in jobs where tenant context is absent.


The Artisan stubs for Phase 4 models, migrations, jobs, and tests are created. I’m filling in the migrations and enum set now so the database shape is fixed before implementing services against it.


Input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/app/Enums/CartStatus.php
+<?php
+
+namespace App\Enums;
+
+enum CartStatus: string
+{
+    case Active = 'active';
+    case Converted = 'converted';
+    case Abandoned = 'abandoned';
+}
*** Add File: /Users/fabianwesner/Herd/shop/app/Enums/CheckoutStatus.php
+<?php
+
+namespace App\Enums;
+
+enum CheckoutStatus: string
+{
+    case Started = 'started';
+    case Addressed = 'addressed';
+    case ShippingSelected = 'shipping_selected';
+    case PaymentSelected = 'payment_selected';
+    case Completed = 'completed';
+    case Expired = 'expired';
+}
*** Add File: /Users/fabianwesner/Herd/shop/app/Enums/DiscountType.php
+<?php
+
+namespace App\Enums;
+
+enum DiscountType: string
+{
+    case Code = 'code';
+    case Automatic = 'automatic';
+}
*** Add File: /Users/fabianwesner/Herd/shop/app/Enums/DiscountValueType.php
+<?php
+
+namespace App\Enums;
+
+enum DiscountValueType: string
+{
+    case Percent = 'percent';
+    case Fixed = 'fixed';
+    case FreeShipping = 'free_shipping';
+}
*** Add File: /Users/fabianwesner/Herd/shop/app/Enums/DiscountStatus.php
+<?php
+
+namespace App\Enums;
+
+enum DiscountStatus: string
+{
+    case Draft = 'draft';
+    case Active = 'active';
+    case Expired = 'expired';
+    case Disabled = 'disabled';
+}
*** Add File: /Users/fabianwesner/Herd/shop/app/Enums/ShippingRateType.php
+<?php
+
+namespace App\Enums;
+
+enum ShippingRateType: string
+{
+    case Flat = 'flat';
+    case Weight = 'weight';
+    case Price = 'price';
+    case Carrier = 'carrier';
+}
*** Add File: /Users/fabianwesner/Herd/shop/app/Enums/TaxMode.php
+<?php
+
+namespace App\Enums;
+
+enum TaxMode: string
+{
+    case Manual = 'manual';
+    case Provider = 'provider';
+}
*** Update File: /Users/fabianwesner/Herd/shop/database/migrations/2026_05_03_225400_create_carts_table.php
@@
     public function up(): void
     {
         Schema::create('carts', function (Blueprint $table) {
             $table->id();
+            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+            $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete();
+            $table->string('currency', 3)->default('USD');
+            $table->unsignedInteger('cart_version')->default(1);
+            $table->enum('status', ['active', 'converted', 'abandoned'])->default('active');
             $table->timestamps();
+
+            $table->index('store_id');
+            $table->index('customer_id');
+            $table->index(['store_id', 'status']);
         });
     }
*** Update File: /Users/fabianwesner/Herd/shop/database/migrations/2026_05_03_225405_create_cart_lines_table.php
@@
     public function up(): void
     {
         Schema::create('cart_lines', function (Blueprint $table) {
             $table->id();
-            $table->timestamps();
+            $table->foreignId('cart_id')->constrained()->cascadeOnDelete();
+            $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete();
+            $table->unsignedInteger('quantity')->default(1);
+            $table->integer('unit_price_amount')->default(0);
+            $table->integer('line_subtotal_amount')->default(0);
+            $table->integer('line_discount_amount')->default(0);
+            $table->integer('line_total_amount')->default(0);
+
+            $table->index('cart_id');
+            $table->unique(['cart_id', 'variant_id']);
         });
     }
*** Update File: /Users/fabianwesner/Herd/shop/database/migrations/2026_05_03_225409_create_checkouts_table.php
@@
     public function up(): void
     {
         Schema::create('checkouts', function (Blueprint $table) {
             $table->id();
+            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+            $table->foreignId('cart_id')->constrained()->cascadeOnDelete();
+            $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete();
+            $table->enum('status', ['started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired'])->default('started');
+            $table->enum('payment_method', ['credit_card', 'paypal', 'bank_transfer'])->nullable();
+            $table->string('email')->nullable();
+            $table->text('shipping_address_json')->nullable();
+            $table->text('billing_address_json')->nullable();
+            $table->unsignedBigInteger('shipping_method_id')->nullable();
+            $table->string('discount_code')->nullable();
+            $table->text('tax_provider_snapshot_json')->nullable();
+            $table->text('totals_json')->nullable();
+            $table->timestamp('expires_at')->nullable();
             $table->timestamps();
+
+            $table->index('store_id');
+            $table->index('cart_id');
+            $table->index('customer_id');
+            $table->index(['store_id', 'status']);
+            $table->index('expires_at');
         });
     }
*** Update File: /Users/fabianwesner/Herd/shop/database/migrations/2026_05_03_225413_create_shipping_zones_table.php
@@
     public function up(): void
     {
         Schema::create('shipping_zones', function (Blueprint $table) {
             $table->id();
-            $table->timestamps();
+            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+            $table->string('name');
+            $table->text('countries_json')->default('[]');
+            $table->text('regions_json')->default('[]');
+
+            $table->index('store_id');
         });
     }
*** Update File: /Users/fabianwesner/Herd/shop/database/migrations/2026_05_03_225418_create_shipping_rates_table.php
@@
     public function up(): void
     {
         Schema::create('shipping_rates', function (Blueprint $table) {
             $table->id();
-            $table->timestamps();
+            $table->foreignId('zone_id')->constrained('shipping_zones')->cascadeOnDelete();
+            $table->string('name');
+            $table->enum('type', ['flat', 'weight', 'price', 'carrier'])->default('flat');
+            $table->text('config_json')->default('{}');
+            $table->boolean('is_active')->default(true);
+
+            $table->index('zone_id');
+            $table->index(['zone_id', 'is_active']);
         });
     }
*** Update File: /Users/fabianwesner/Herd/shop/database/migrations/2026_05_03_225422_create_tax_settings_table.php
@@
     public function up(): void
     {
         Schema::create('tax_settings', function (Blueprint $table) {
-            $table->id();
-            $table->timestamps();
+            $table->foreignId('store_id')->primary()->constrained()->cascadeOnDelete();
+            $table->enum('mode', ['manual', 'provider'])->default('manual');
+            $table->enum('provider', ['stripe_tax', 'none'])->default('none');
+            $table->boolean('prices_include_tax')->default(false);
+            $table->text('config_json')->default('{}');
         });
     }
*** Update File: /Users/fabianwesner/Herd/shop/database/migrations/2026_05_03_225427_create_discounts_table.php
@@
     public function up(): void
     {
         Schema::create('discounts', function (Blueprint $table) {
             $table->id();
+            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+            $table->enum('type', ['code', 'automatic'])->default('code');
+            $table->string('code')->nullable();
+            $table->enum('value_type', ['fixed', 'percent', 'free_shipping']);
+            $table->integer('value_amount')->default(0);
+            $table->timestamp('starts_at');
+            $table->timestamp('ends_at')->nullable();
+            $table->unsignedInteger('usage_limit')->nullable();
+            $table->unsignedInteger('usage_count')->default(0);
+            $table->text('rules_json')->default('{}');
+            $table->enum('status', ['draft', 'active', 'expired', 'disabled'])->default('active');
             $table->timestamps();
+
+            $table->unique(['store_id', 'code']);
+            $table->index('store_id');
+            $table->index(['store_id', 'status']);
+            $table->index(['store_id', 'type']);
         });
     }
*** End Patch


Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/app/Enums/CartStatus.php
A /Users/fabianwesner/Herd/shop/app/Enums/CheckoutStatus.php
A /Users/fabianwesner/Herd/shop/app/Enums/DiscountType.php
A /Users/fabianwesner/Herd/shop/app/Enums/DiscountValueType.php
A /Users/fabianwesner/Herd/shop/app/Enums/DiscountStatus.php
A /Users/fabianwesner/Herd/shop/app/Enums/ShippingRateType.php
A /Users/fabianwesner/Herd/shop/app/Enums/TaxMode.php
M /Users/fabianwesner/Herd/shop/database/migrations/2026_05_03_225400_create_carts_table.php
M /Users/fabianwesner/Herd/shop/database/migrations/2026_05_03_225405_create_cart_lines_table.php
M /Users/fabianwesner/Herd/shop/database/migrations/2026_05_03_225409_create_checkouts_table.php
M /Users/fabianwesner/Herd/shop/database/migrations/2026_05_03_225413_create_shipping_zones_table.php
M /Users/fabianwesner/Herd/shop/database/migrations/2026_05_03_225418_create_shipping_rates_table.php
M /Users/fabianwesner/Herd/shop/database/migrations/2026_05_03_225422_create_tax_settings_table.php
M /Users/fabianwesner/Herd/shop/database/migrations/2026_05_03_225427_create_discounts_table.php

@@ -15,3 +15,8 @@
             $table->id();
-            $table->timestamps();
+            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+            $table->string('name');
+            $table->text('countries_json')->default('[]');
+            $table->text('regions_json')->default('[]');
+
+            $table->index('store_id');
         });

<?php

namespace App\Enums;

enum DiscountType: string
{
    case Code = 'code';
    case Automatic = 'automatic';
}

<?php

namespace App\Enums;

enum DiscountValueType: string
{
    case Percent = 'percent';
    case Fixed = 'fixed';
    case FreeShipping = 'free_shipping';
}

@@ -14,4 +14,7 @@
         Schema::create('tax_settings', function (Blueprint $table) {
-            $table->id();
-            $table->timestamps();
+            $table->foreignId('store_id')->primary()->constrained()->cascadeOnDelete();
+            $table->enum('mode', ['manual', 'provider'])->default('manual');
+            $table->enum('provider', ['stripe_tax', 'none'])->default('none');
+            $table->boolean('prices_include_tax')->default(false);
+            $table->text('config_json')->default('{}');
         });

<?php

namespace App\Enums;

enum DiscountStatus: string
{
    case Draft = 'draft';
    case Active = 'active';
    case Expired = 'expired';
    case Disabled = 'disabled';
}

<?php

namespace App\Enums;

enum CartStatus: string
{
    case Active = 'active';
    case Converted = 'converted';
    case Abandoned = 'abandoned';
}

@@ -15,3 +15,12 @@
             $table->id();
-            $table->timestamps();
+            $table->foreignId('cart_id')->constrained()->cascadeOnDelete();
+            $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete();
+            $table->unsignedInteger('quantity')->default(1);
+            $table->integer('unit_price_amount')->default(0);
+            $table->integer('line_subtotal_amount')->default(0);
+            $table->integer('line_discount_amount')->default(0);
+            $table->integer('line_total_amount')->default(0);
+
+            $table->index('cart_id');
+            $table->unique(['cart_id', 'variant_id']);
         });

@@ -15,3 +15,22 @@
             $table->id();
+            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+            $table->foreignId('cart_id')->constrained()->cascadeOnDelete();
+            $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete();
+            $table->enum('status', ['started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired'])->default('started');
+            $table->enum('payment_method', ['credit_card', 'paypal', 'bank_transfer'])->nullable();
+            $table->string('email')->nullable();
+            $table->text('shipping_address_json')->nullable();
+            $table->text('billing_address_json')->nullable();
+            $table->unsignedBigInteger('shipping_method_id')->nullable();
+            $table->string('discount_code')->nullable();
+            $table->text('tax_provider_snapshot_json')->nullable();
+            $table->text('totals_json')->nullable();
+            $table->timestamp('expires_at')->nullable();
             $table->timestamps();
+
+            $table->index('store_id');
+            $table->index('cart_id');
+            $table->index('customer_id');
+            $table->index(['store_id', 'status']);
+            $table->index('expires_at');
         });

@@ -15,3 +15,10 @@
             $table->id();
-            $table->timestamps();
+            $table->foreignId('zone_id')->constrained('shipping_zones')->cascadeOnDelete();
+            $table->string('name');
+            $table->enum('type', ['flat', 'weight', 'price', 'carrier'])->default('flat');
+            $table->text('config_json')->default('{}');
+            $table->boolean('is_active')->default(true);
+
+            $table->index('zone_id');
+            $table->index(['zone_id', 'is_active']);
         });

@@ -15,3 +15,12 @@
             $table->id();
+            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+            $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete();
+            $table->string('currency', 3)->default('USD');
+            $table->unsignedInteger('cart_version')->default(1);
+            $table->enum('status', ['active', 'converted', 'abandoned'])->default('active');
             $table->timestamps();
+
+            $table->index('store_id');
+            $table->index('customer_id');
+            $table->index(['store_id', 'status']);
         });

@@ -15,3 +15,19 @@
             $table->id();
+            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+            $table->enum('type', ['code', 'automatic'])->default('code');
+            $table->string('code')->nullable();
+            $table->enum('value_type', ['fixed', 'percent', 'free_shipping']);
+            $table->integer('value_amount')->default(0);
+            $table->timestamp('starts_at');
+            $table->timestamp('ends_at')->nullable();
+            $table->unsignedInteger('usage_limit')->nullable();
+            $table->unsignedInteger('usage_count')->default(0);
+            $table->text('rules_json')->default('{}');
+            $table->enum('status', ['draft', 'active', 'expired', 'disabled'])->default('active');
             $table->timestamps();
+
+            $table->unique(['store_id', 'code']);
+            $table->index('store_id');
+            $table->index(['store_id', 'status']);
+            $table->index(['store_id', 'type']);
         });

<?php

namespace App\Enums;

enum ShippingRateType: string
{
    case Flat = 'flat';
    case Weight = 'weight';
    case Price = 'price';
    case Carrier = 'carrier';
}

<?php

namespace App\Enums;

enum CheckoutStatus: string
{
    case Started = 'started';
    case Addressed = 'addressed';
    case ShippingSelected = 'shipping_selected';
    case PaymentSelected = 'payment_selected';
    case Completed = 'completed';
    case Expired = 'expired';
}

<?php

namespace App\Enums;

enum TaxMode: string
{
    case Manual = 'manual';
    case Provider = 'provider';
}


The Phase 4 tables now match the spec columns and indexes. I’m filling in the Eloquent model graph next, including tenant scoping for child tables that don’t carry `store_id` directly.


Input:
*** Begin Patch
*** Delete File: /Users/fabianwesner/Herd/shop/app/Models/Cart.php
*** Add File: /Users/fabianwesner/Herd/shop/app/Models/Cart.php
+<?php
+
+namespace App\Models;
+
+use App\Enums\CartStatus;
+use App\Models\Concerns\BelongsToStore;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+
+class Cart extends Model
+{
+    /** @use HasFactory<\Database\Factories\CartFactory> */
+    use BelongsToStore, HasFactory;
+
+    /**
+     * @var list<string>
+     */
+    protected $fillable = [
+        'store_id',
+        'customer_id',
+        'currency',
+        'cart_version',
+        'status',
+    ];
+
+    /**
+     * @var array<string, mixed>
+     */
+    protected $attributes = [
+        'currency' => 'USD',
+        'cart_version' => 1,
+        'status' => 'active',
+    ];
+
+    /**
+     * @return BelongsTo<Store, $this>
+     */
+    public function store(): BelongsTo
+    {
+        return $this->belongsTo(Store::class);
+    }
+
+    /**
+     * @return BelongsTo<Customer, $this>
+     */
+    public function customer(): BelongsTo
+    {
+        return $this->belongsTo(Customer::class);
+    }
+
+    /**
+     * @return HasMany<CartLine, $this>
+     */
+    public function lines(): HasMany
+    {
+        return $this->hasMany(CartLine::class);
+    }
+
+    /**
+     * @return HasMany<Checkout, $this>
+     */
+    public function checkouts(): HasMany
+    {
+        return $this->hasMany(Checkout::class);
+    }
+
+    /**
+     * @return array<string, string>
+     */
+    protected function casts(): array
+    {
+        return [
+            'cart_version' => 'integer',
+            'status' => CartStatus::class,
+        ];
+    }
+}
*** Delete File: /Users/fabianwesner/Herd/shop/app/Models/CartLine.php
*** Add File: /Users/fabianwesner/Herd/shop/app/Models/CartLine.php
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class CartLine extends Model
+{
+    /** @use HasFactory<\Database\Factories\CartLineFactory> */
+    use HasFactory;
+
+    public $timestamps = false;
+
+    /**
+     * @var list<string>
+     */
+    protected $fillable = [
+        'cart_id',
+        'variant_id',
+        'quantity',
+        'unit_price_amount',
+        'line_subtotal_amount',
+        'line_discount_amount',
+        'line_total_amount',
+    ];
+
+    /**
+     * @var array<string, mixed>
+     */
+    protected $attributes = [
+        'quantity' => 1,
+        'unit_price_amount' => 0,
+        'line_subtotal_amount' => 0,
+        'line_discount_amount' => 0,
+        'line_total_amount' => 0,
+    ];
+
+    protected static function booted(): void
+    {
+        static::addGlobalScope('current_store', function (Builder $builder): void {
+            if (! app()->bound('current_store')) {
+                return;
+            }
+
+            $store = app('current_store');
+
+            if (! $store instanceof Store) {
+                return;
+            }
+
+            $builder->whereHas('cart', function (Builder $query) use ($store): void {
+                $query
+                    ->withoutGlobalScopes()
+                    ->where('store_id', $store->getKey());
+            });
+        });
+    }
+
+    /**
+     * @return BelongsTo<Cart, $this>
+     */
+    public function cart(): BelongsTo
+    {
+        return $this->belongsTo(Cart::class);
+    }
+
+    /**
+     * @return BelongsTo<ProductVariant, $this>
+     */
+    public function variant(): BelongsTo
+    {
+        return $this->belongsTo(ProductVariant::class, 'variant_id');
+    }
+
+    /**
+     * @return array<string, string>
+     */
+    protected function casts(): array
+    {
+        return [
+            'quantity' => 'integer',
+            'unit_price_amount' => 'integer',
+            'line_subtotal_amount' => 'integer',
+            'line_discount_amount' => 'integer',
+            'line_total_amount' => 'integer',
+        ];
+    }
+}
*** Delete File: /Users/fabianwesner/Herd/shop/app/Models/Checkout.php
*** Add File: /Users/fabianwesner/Herd/shop/app/Models/Checkout.php
+<?php
+
+namespace App\Models;
+
+use App\Enums\CheckoutStatus;
+use App\Models\Concerns\BelongsToStore;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class Checkout extends Model
+{
+    /** @use HasFactory<\Database\Factories\CheckoutFactory> */
+    use BelongsToStore, HasFactory;
+
+    /**
+     * @var list<string>
+     */
+    protected $fillable = [
+        'store_id',
+        'cart_id',
+        'customer_id',
+        'status',
+        'payment_method',
+        'email',
+        'shipping_address_json',
+        'billing_address_json',
+        'shipping_method_id',
+        'discount_code',
+        'tax_provider_snapshot_json',
+        'totals_json',
+        'expires_at',
+    ];
+
+    /**
+     * @var array<string, mixed>
+     */
+    protected $attributes = [
+        'status' => 'started',
+    ];
+
+    /**
+     * @return BelongsTo<Store, $this>
+     */
+    public function store(): BelongsTo
+    {
+        return $this->belongsTo(Store::class);
+    }
+
+    /**
+     * @return BelongsTo<Cart, $this>
+     */
+    public function cart(): BelongsTo
+    {
+        return $this->belongsTo(Cart::class);
+    }
+
+    /**
+     * @return BelongsTo<Customer, $this>
+     */
+    public function customer(): BelongsTo
+    {
+        return $this->belongsTo(Customer::class);
+    }
+
+    /**
+     * @return BelongsTo<ShippingRate, $this>
+     */
+    public function shippingRate(): BelongsTo
+    {
+        return $this->belongsTo(ShippingRate::class, 'shipping_method_id');
+    }
+
+    /**
+     * @return array<string, string>
+     */
+    protected function casts(): array
+    {
+        return [
+            'status' => CheckoutStatus::class,
+            'shipping_address_json' => 'array',
+            'billing_address_json' => 'array',
+            'shipping_method_id' => 'integer',
+            'tax_provider_snapshot_json' => 'array',
+            'totals_json' => 'array',
+            'expires_at' => 'datetime',
+        ];
+    }
+}
*** Delete File: /Users/fabianwesner/Herd/shop/app/Models/ShippingZone.php
*** Add File: /Users/fabianwesner/Herd/shop/app/Models/ShippingZone.php
+<?php
+
+namespace App\Models;
+
+use App\Models\Concerns\BelongsToStore;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+
+class ShippingZone extends Model
+{
+    /** @use HasFactory<\Database\Factories\ShippingZoneFactory> */
+    use BelongsToStore, HasFactory;
+
+    public $timestamps = false;
+
+    /**
+     * @var list<string>
+     */
+    protected $fillable = [
+        'store_id',
+        'name',
+        'countries_json',
+        'regions_json',
+    ];
+
+    /**
+     * @var array<string, mixed>
+     */
+    protected $attributes = [
+        'countries_json' => '[]',
+        'regions_json' => '[]',
+    ];
+
+    /**
+     * @return BelongsTo<Store, $this>
+     */
+    public function store(): BelongsTo
+    {
+        return $this->belongsTo(Store::class);
+    }
+
+    /**
+     * @return HasMany<ShippingRate, $this>
+     */
+    public function rates(): HasMany
+    {
+        return $this->hasMany(ShippingRate::class, 'zone_id');
+    }
+
+    /**
+     * @return array<string, string>
+     */
+    protected function casts(): array
+    {
+        return [
+            'countries_json' => 'array',
+            'regions_json' => 'array',
+        ];
+    }
+}
*** Delete File: /Users/fabianwesner/Herd/shop/app/Models/ShippingRate.php
*** Add File: /Users/fabianwesner/Herd/shop/app/Models/ShippingRate.php
+<?php
+
+namespace App\Models;
+
+use App\Enums\ShippingRateType;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class ShippingRate extends Model
+{
+    /** @use HasFactory<\Database\Factories\ShippingRateFactory> */
+    use HasFactory;
+
+    public $timestamps = false;
+
+    /**
+     * @var list<string>
+     */
+    protected $fillable = [
+        'zone_id',
+        'name',
+        'type',
+        'config_json',
+        'is_active',
+    ];
+
+    /**
+     * @var array<string, mixed>
+     */
+    protected $attributes = [
+        'type' => 'flat',
+        'config_json' => '{}',
+        'is_active' => true,
+    ];
+
+    protected static function booted(): void
+    {
+        static::addGlobalScope('current_store', function (Builder $builder): void {
+            if (! app()->bound('current_store')) {
+                return;
+            }
+
+            $store = app('current_store');
+
+            if (! $store instanceof Store) {
+                return;
+            }
+
+            $builder->whereHas('zone', function (Builder $query) use ($store): void {
+                $query
+                    ->withoutGlobalScopes()
+                    ->where('store_id', $store->getKey());
+            });
+        });
+    }
+
+    /**
+     * @return BelongsTo<ShippingZone, $this>
+     */
+    public function zone(): BelongsTo
+    {
+        return $this->belongsTo(ShippingZone::class, 'zone_id');
+    }
+
+    /**
+     * @return array<string, string>
+     */
+    protected function casts(): array
+    {
+        return [
+            'type' => ShippingRateType::class,
+            'config_json' => 'array',
+            'is_active' => 'bool',
+        ];
+    }
+}
*** Delete File: /Users/fabianwesner/Herd/shop/app/Models/TaxSettings.php
*** Add File: /Users/fabianwesner/Herd/shop/app/Models/TaxSettings.php
+<?php
+
+namespace App\Models;
+
+use App\Enums\TaxMode;
+use App\Models\Concerns\BelongsToStore;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class TaxSettings extends Model
+{
+    /** @use HasFactory<\Database\Factories\TaxSettingsFactory> */
+    use BelongsToStore, HasFactory;
+
+    public $incrementing = false;
+
+    public $timestamps = false;
+
+    protected $primaryKey = 'store_id';
+
+    /**
+     * @var list<string>
+     */
+    protected $fillable = [
+        'store_id',
+        'mode',
+        'provider',
+        'prices_include_tax',
+        'config_json',
+    ];
+
+    /**
+     * @var array<string, mixed>
+     */
+    protected $attributes = [
+        'mode' => 'manual',
+        'provider' => 'none',
+        'prices_include_tax' => false,
+        'config_json' => '{}',
+    ];
+
+    /**
+     * @return BelongsTo<Store, $this>
+     */
+    public function store(): BelongsTo
+    {
+        return $this->belongsTo(Store::class);
+    }
+
+    /**
+     * @return array<string, string>
+     */
+    protected function casts(): array
+    {
+        return [
+            'mode' => TaxMode::class,
+            'prices_include_tax' => 'bool',
+            'config_json' => 'array',
+        ];
+    }
+}
*** Delete File: /Users/fabianwesner/Herd/shop/app/Models/Discount.php
*** Add File: /Users/fabianwesner/Herd/shop/app/Models/Discount.php
+<?php
+
+namespace App\Models;
+
+use App\Enums\DiscountStatus;
+use App\Enums\DiscountType;
+use App\Enums\DiscountValueType;
+use App\Models\Concerns\BelongsToStore;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+
+class Discount extends Model
+{
+    /** @use HasFactory<\Database\Factories\DiscountFactory> */
+    use BelongsToStore, HasFactory;
+
+    /**
+     * @var list<string>
+     */
+    protected $fillable = [
+        'store_id',
+        'type',
+        'code',
+        'value_type',
+        'value_amount',
+        'starts_at',
+        'ends_at',
+        'usage_limit',
+        'usage_count',
+        'rules_json',
+        'status',
+    ];
+
+    /**
+     * @var array<string, mixed>
+     */
+    protected $attributes = [
+        'type' => 'code',
+        'value_amount' => 0,
+        'usage_count' => 0,
+        'rules_json' => '{}',
+        'status' => 'active',
+    ];
+
+    /**
+     * @return BelongsTo<Store, $this>
+     */
+    public function store(): BelongsTo
+    {
+        return $this->belongsTo(Store::class);
+    }
+
+    /**
+     * @return array<string, string>
+     */
+    protected function casts(): array
+    {
+        return [
+            'type' => DiscountType::class,
+            'value_type' => DiscountValueType::class,
+            'value_amount' => 'integer',
+            'starts_at' => 'datetime',
+            'ends_at' => 'datetime',
+            'usage_limit' => 'integer',
+            'usage_count' => 'integer',
+            'rules_json' => 'array',
+            'status' => DiscountStatus::class,
+        ];
+    }
+}
*** End Patch


Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/app/Models/Cart.php
A /Users/fabianwesner/Herd/shop/app/Models/CartLine.php
A /Users/fabianwesner/Herd/shop/app/Models/Checkout.php
A /Users/fabianwesner/Herd/shop/app/Models/ShippingZone.php
A /Users/fabianwesner/Herd/shop/app/Models/ShippingRate.php
A /Users/fabianwesner/Herd/shop/app/Models/TaxSettings.php
A /Users/fabianwesner/Herd/shop/app/Models/Discount.php
D /Users/fabianwesner/Herd/shop/app/Models/Cart.php
D /Users/fabianwesner/Herd/shop/app/Models/CartLine.php
D /Users/fabianwesner/Herd/shop/app/Models/Checkout.php
D /Users/fabianwesner/Herd/shop/app/Models/ShippingZone.php
D /Users/fabianwesner/Herd/shop/app/Models/ShippingRate.php
D /Users/fabianwesner/Herd/shop/app/Models/TaxSettings.php
D /Users/fabianwesner/Herd/shop/app/Models/Discount.php

<?php

namespace App\Models;

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

class ShippingZone extends Model
{
    /** @use HasFactory<\Database\Factories\ShippingZoneFactory> */
    use BelongsToStore, HasFactory;

    public $timestamps = false;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'name',
        'countries_json',
        'regions_json',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'countries_json' => '[]',
        'regions_json' => '[]',
    ];

    /**
     * @return BelongsTo<Store, $this>
     */
    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }

    /**
     * @return HasMany<ShippingRate, $this>
     */
    public function rates(): HasMany
    {
        return $this->hasMany(ShippingRate::class, 'zone_id');
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'countries_json' => 'array',
            'regions_json' => 'array',
        ];
    }
}

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class CartLine extends Model
{
    /** @use HasFactory<\Database\Factories\CartLineFactory> */
    use HasFactory;

    public $timestamps = false;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'cart_id',
        'variant_id',
        'quantity',
        'unit_price_amount',
        'line_subtotal_amount',
        'line_discount_amount',
        'line_total_amount',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'quantity' => 1,
        'unit_price_amount' => 0,
        'line_subtotal_amount' => 0,
        'line_discount_amount' => 0,
        'line_total_amount' => 0,
    ];

    protected static function booted(): void
    {
        static::addGlobalScope('current_store', function (Builder $builder): void {
            if (! app()->bound('current_store')) {
                return;
            }

            $store = app('current_store');

            if (! $store instanceof Store) {
                return;
            }

            $builder->whereHas('cart', function (Builder $query) use ($store): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $store->getKey());
            });
        });
    }

    /**
     * @return BelongsTo<Cart, $this>
     */
    public function cart(): BelongsTo
    {
        return $this->belongsTo(Cart::class);
    }

    /**
     * @return BelongsTo<ProductVariant, $this>
     */
    public function variant(): BelongsTo
    {
        return $this->belongsTo(ProductVariant::class, 'variant_id');
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'quantity' => 'integer',
            'unit_price_amount' => 'integer',
            'line_subtotal_amount' => 'integer',
            'line_discount_amount' => 'integer',
            'line_total_amount' => 'integer',
        ];
    }
}

<?php

namespace App\Models;

use App\Enums\CheckoutStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Checkout extends Model
{
    /** @use HasFactory<\Database\Factories\CheckoutFactory> */
    use BelongsToStore, HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'cart_id',
        'customer_id',
        'status',
        'payment_method',
        'email',
        'shipping_address_json',
        'billing_address_json',
        'shipping_method_id',
        'discount_code',
        'tax_provider_snapshot_json',
        'totals_json',
        'expires_at',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'status' => 'started',
    ];

    /**
     * @return BelongsTo<Store, $this>
     */
    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }

    /**
     * @return BelongsTo<Cart, $this>
     */
    public function cart(): BelongsTo
    {
        return $this->belongsTo(Cart::class);
    }

    /**
     * @return BelongsTo<Customer, $this>
     */
    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }

    /**
     * @return BelongsTo<ShippingRate, $this>
     */
    public function shippingRate(): BelongsTo
    {
        return $this->belongsTo(ShippingRate::class, 'shipping_method_id');
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'status' => CheckoutStatus::class,
            'shipping_address_json' => 'array',
            'billing_address_json' => 'array',
            'shipping_method_id' => 'integer',
            'tax_provider_snapshot_json' => 'array',
            'totals_json' => 'array',
            'expires_at' => 'datetime',
        ];
    }
}

<?php

namespace App\Models;

use App\Enums\ShippingRateType;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class ShippingRate extends Model
{
    /** @use HasFactory<\Database\Factories\ShippingRateFactory> */
    use HasFactory;

    public $timestamps = false;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'zone_id',
        'name',
        'type',
        'config_json',
        'is_active',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'type' => 'flat',
        'config_json' => '{}',
        'is_active' => true,
    ];

    protected static function booted(): void
    {
        static::addGlobalScope('current_store', function (Builder $builder): void {
            if (! app()->bound('current_store')) {
                return;
            }

            $store = app('current_store');

            if (! $store instanceof Store) {
                return;
            }

            $builder->whereHas('zone', function (Builder $query) use ($store): void {
                $query
                    ->withoutGlobalScopes()
                    ->where('store_id', $store->getKey());
            });
        });
    }

    /**
     * @return BelongsTo<ShippingZone, $this>
     */
    public function zone(): BelongsTo
    {
        return $this->belongsTo(ShippingZone::class, 'zone_id');
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'type' => ShippingRateType::class,
            'config_json' => 'array',
            'is_active' => 'bool',
        ];
    }
}

<?php

namespace App\Models;

use App\Enums\TaxMode;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class TaxSettings extends Model
{
    /** @use HasFactory<\Database\Factories\TaxSettingsFactory> */
    use BelongsToStore, HasFactory;

    public $incrementing = false;

    public $timestamps = false;

    protected $primaryKey = 'store_id';

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'mode',
        'provider',
        'prices_include_tax',
        'config_json',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'mode' => 'manual',
        'provider' => 'none',
        'prices_include_tax' => false,
        'config_json' => '{}',
    ];

    /**
     * @return BelongsTo<Store, $this>
     */
    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'mode' => TaxMode::class,
            'prices_include_tax' => 'bool',
            'config_json' => 'array',
        ];
    }
}

<?php

namespace App\Models;

use App\Enums\CartStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Cart extends Model
{
    /** @use HasFactory<\Database\Factories\CartFactory> */
    use BelongsToStore, HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'customer_id',
        'currency',
        'cart_version',
        'status',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'currency' => 'USD',
        'cart_version' => 1,
        'status' => 'active',
    ];

    /**
     * @return BelongsTo<Store, $this>
     */
    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }

    /**
     * @return BelongsTo<Customer, $this>
     */
    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }

    /**
     * @return HasMany<CartLine, $this>
     */
    public function lines(): HasMany
    {
        return $this->hasMany(CartLine::class);
    }

    /**
     * @return HasMany<Checkout, $this>
     */
    public function checkouts(): HasMany
    {
        return $this->hasMany(Checkout::class);
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'cart_version' => 'integer',
            'status' => CartStatus::class,
        ];
    }
}

<?php

namespace App\Models;

use App\Enums\DiscountStatus;
use App\Enums\DiscountType;
use App\Enums\DiscountValueType;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Discount extends Model
{
    /** @use HasFactory<\Database\Factories\DiscountFactory> */
    use BelongsToStore, HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'type',
        'code',
        'value_type',
        'value_amount',
        'starts_at',
        'ends_at',
        'usage_limit',
        'usage_count',
        'rules_json',
        'status',
    ];

    /**
     * @var array<string, mixed>
     */
    protected $attributes = [
        'type' => 'code',
        'value_amount' => 0,
        'usage_count' => 0,
        'rules_json' => '{}',
        'status' => 'active',
    ];

    /**
     * @return BelongsTo<Store, $this>
     */
    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'type' => DiscountType::class,
            'value_type' => DiscountValueType::class,
            'value_amount' => 'integer',
            'starts_at' => 'datetime',
            'ends_at' => 'datetime',
            'usage_limit' => 'integer',
            'usage_count' => 'integer',
            'rules_json' => 'array',
            'status' => DiscountStatus::class,
        ];
    }
}


Output: {"output":"Success. Updated the following files:\nA /Users/fabianwesner/Herd/shop/app/Models/Cart.php\nA /Users/fabianwesner/Herd/shop/app/Models/CartLine.php\nA /Users/fabianwesner/Herd/shop/app/Models/Checkout.php\nA /Users/fabianwesner/Herd/shop/app/Models/ShippingZone.php\nA /Users/fabianwesner/Herd/shop/app/Models/ShippingRate.php\nA /Users/fabianwesner/Herd/shop/app/Models/TaxSettings.php\nA /Users/fabianwesner/Herd/shop/app/Models/Discount.php\nD /Users/fabianwesner/Herd/shop/app/Models/Cart.php\nD /Users/fabianwesner/Herd/shop/app/Models/CartLine.php\nD /Users/fabianwesner/Herd/shop/app/Models/Checkout.php\nD /Users/fabianwesner/Herd/shop/app/Models/ShippingZone.php\nD /Users/fabianwesner/Herd/shop/app/Models/ShippingRate.php\nD /Users/fabianwesner/Herd/shop/app/Models/TaxSettings.php\nD /Users/fabianwesner/Herd/shop/app/Models/Discount.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Models/Store.php
@@
     public function navigationMenus(): HasMany
     {
         return $this->hasMany(NavigationMenu::class);
     }
+
+    /**
+     * @return HasMany<Cart, $this>
+     */
+    public function carts(): HasMany
+    {
+        return $this->hasMany(Cart::class);
+    }
+
+    /**
+     * @return HasMany<Checkout, $this>
+     */
+    public function checkouts(): HasMany
+    {
+        return $this->hasMany(Checkout::class);
+    }
+
+    /**
+     * @return HasMany<ShippingZone, $this>
+     */
+    public function shippingZones(): HasMany
+    {
+        return $this->hasMany(ShippingZone::class);
+    }
+
+    /**
+     * @return HasOne<TaxSettings, $this>
+     */
+    public function taxSettings(): HasOne
+    {
+        return $this->hasOne(TaxSettings::class);
+    }
+
+    /**
+     * @return HasMany<Discount, $this>
+     */
+    public function discounts(): HasMany
+    {
+        return $this->hasMany(Discount::class);
+    }
*** Update File: /Users/fabianwesner/Herd/shop/app/Models/Customer.php
@@
 use App\Models\Concerns\BelongsToStore;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Foundation\Auth\User as Authenticatable;
@@
     public function getPasswordAttribute(): ?string
     {
         return $this->password_hash;
     }
+
+    /**
+     * @return HasMany<Cart, $this>
+     */
+    public function carts(): HasMany
+    {
+        return $this->hasMany(Cart::class);
+    }
+
+    /**
+     * @return HasMany<Checkout, $this>
+     */
+    public function checkouts(): HasMany
+    {
+        return $this->hasMany(Checkout::class);
+    }
*** End Patch


Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/app/Models/Store.php
M /Users/fabianwesner/Herd/shop/app/Models/Customer.php

@@ -88,2 +88,42 @@

+    /**
+     * @return HasMany<Cart, $this>
+     */
+    public function carts(): HasMany
+    {
+        return $this->hasMany(Cart::class);
+    }
+
+    /**
+     * @return HasMany<Checkout, $this>
+     */
+    public function checkouts(): HasMany
+    {
+        return $this->hasMany(Checkout::class);
+    }
+
+    /**
+     * @return HasMany<ShippingZone, $this>
+     */
+    public function shippingZones(): HasMany
+    {
+        return $this->hasMany(ShippingZone::class);
+    }
+
+    /**
+     * @return HasOne<TaxSettings, $this>
+     */
+    public function taxSettings(): HasOne
+    {
+        return $this->hasOne(TaxSettings::class);
+    }
+
+    /**
+     * @return HasMany<Discount, $this>
+     */
+    public function discounts(): HasMany
+    {
+        return $this->hasMany(Discount::class);
+    }
+
     public function isSuspended(): bool

@@ -6,2 +6,3 @@
 use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Foundation\Auth\User as Authenticatable;
@@ -50,2 +51,18 @@
     /**
+     * @return HasMany<Cart, $this>
+     */
+    public function carts(): HasMany
+    {
+        return $this->hasMany(Cart::class);
+    }
+
+    /**
+     * @return HasMany<Checkout, $this>
+     */
+    public function checkouts(): HasMany
+    {
+        return $this->hasMany(Checkout::class);
+    }
+
+    /**
      * @return array<string, string>


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/app/Models/Store.php\nM /Users/fabianwesner/Herd/shop/app/Models/Customer.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Delete File: /Users/fabianwesner/Herd/shop/database/factories/CartFactory.php
*** Add File: /Users/fabianwesner/Herd/shop/database/factories/CartFactory.php
+<?php
+
+namespace Database\Factories;
+
+use App\Enums\CartStatus;
+use App\Models\Customer;
+use App\Models\Store;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Cart>
+ */
+class CartFactory extends Factory
+{
+    /**
+     * Define the model's default state.
+     *
+     * @return array<string, mixed>
+     */
+    public function definition(): array
+    {
+        return [
+            'store_id' => Store::factory(),
+            'customer_id' => null,
+            'currency' => 'EUR',
+            'cart_version' => 1,
+            'status' => CartStatus::Active,
+        ];
+    }
+
+    public function forCustomer(?Customer $customer = null): static
+    {
+        return $this->state(fn (array $attributes): array => [
+            'customer_id' => ($customer ?? Customer::factory()->create())->getKey(),
+        ]);
+    }
+
+    public function abandoned(): static
+    {
+        return $this->state(fn (array $attributes): array => [
+            'status' => CartStatus::Abandoned,
+        ]);
+    }
+}
*** Delete File: /Users/fabianwesner/Herd/shop/database/factories/CartLineFactory.php
*** Add File: /Users/fabianwesner/Herd/shop/database/factories/CartLineFactory.php
+<?php
+
+namespace Database\Factories;
+
+use App\Models\Cart;
+use App\Models\ProductVariant;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\CartLine>
+ */
+class CartLineFactory extends Factory
+{
+    /**
+     * Define the model's default state.
+     *
+     * @return array<string, mixed>
+     */
+    public function definition(): array
+    {
+        $quantity = fake()->numberBetween(1, 3);
+        $unitPrice = fake()->numberBetween(1000, 10000);
+
+        return [
+            'cart_id' => Cart::factory(),
+            'variant_id' => ProductVariant::factory(),
+            'quantity' => $quantity,
+            'unit_price_amount' => $unitPrice,
+            'line_subtotal_amount' => $unitPrice * $quantity,
+            'line_discount_amount' => 0,
+            'line_total_amount' => $unitPrice * $quantity,
+        ];
+    }
+}
*** Delete File: /Users/fabianwesner/Herd/shop/database/factories/CheckoutFactory.php
*** Add File: /Users/fabianwesner/Herd/shop/database/factories/CheckoutFactory.php
+<?php
+
+namespace Database\Factories;
+
+use App\Enums\CheckoutStatus;
+use App\Models\Cart;
+use App\Models\Customer;
+use App\Models\Store;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Checkout>
+ */
+class CheckoutFactory extends Factory
+{
+    /**
+     * Define the model's default state.
+     *
+     * @return array<string, mixed>
+     */
+    public function definition(): array
+    {
+        return [
+            'store_id' => Store::factory(),
+            'cart_id' => Cart::factory(),
+            'customer_id' => null,
+            'status' => CheckoutStatus::Started,
+            'payment_method' => null,
+            'email' => null,
+            'shipping_address_json' => null,
+            'billing_address_json' => null,
+            'shipping_method_id' => null,
+            'discount_code' => null,
+            'tax_provider_snapshot_json' => null,
+            'totals_json' => null,
+            'expires_at' => null,
+        ];
+    }
+
+    public function forCustomer(?Customer $customer = null): static
+    {
+        return $this->state(fn (array $attributes): array => [
+            'customer_id' => ($customer ?? Customer::factory()->create())->getKey(),
+        ]);
+    }
+}
*** Delete File: /Users/fabianwesner/Herd/shop/database/factories/ShippingZoneFactory.php
*** Add File: /Users/fabianwesner/Herd/shop/database/factories/ShippingZoneFactory.php
+<?php
+
+namespace Database\Factories;
+
+use App\Models\Store;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ShippingZone>
+ */
+class ShippingZoneFactory extends Factory
+{
+    /**
+     * Define the model's default state.
+     *
+     * @return array<string, mixed>
+     */
+    public function definition(): array
+    {
+        return [
+            'store_id' => Store::factory(),
+            'name' => fake()->country().' Shipping',
+            'countries_json' => ['DE'],
+            'regions_json' => [],
+        ];
+    }
+}
*** Delete File: /Users/fabianwesner/Herd/shop/database/factories/ShippingRateFactory.php
*** Add File: /Users/fabianwesner/Herd/shop/database/factories/ShippingRateFactory.php
+<?php
+
+namespace Database\Factories;
+
+use App\Enums\ShippingRateType;
+use App\Models\ShippingZone;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ShippingRate>
+ */
+class ShippingRateFactory extends Factory
+{
+    /**
+     * Define the model's default state.
+     *
+     * @return array<string, mixed>
+     */
+    public function definition(): array
+    {
+        return [
+            'zone_id' => ShippingZone::factory(),
+            'name' => 'Standard Shipping',
+            'type' => ShippingRateType::Flat,
+            'config_json' => ['amount' => 799],
+            'is_active' => true,
+        ];
+    }
+
+    public function weight(): static
+    {
+        return $this->state(fn (array $attributes): array => [
+            'type' => ShippingRateType::Weight,
+            'config_json' => [
+                'ranges' => [
+                    ['min_g' => 0, 'max_g' => 500, 'amount' => 499],
+                    ['min_g' => 501, 'max_g' => 2000, 'amount' => 899],
+                ],
+            ],
+        ]);
+    }
+
+    public function price(): static
+    {
+        return $this->state(fn (array $attributes): array => [
+            'type' => ShippingRateType::Price,
+            'config_json' => [
+                'ranges' => [
+                    ['min_amount' => 0, 'max_amount' => 7500, 'amount' => 799],
+                    ['min_amount' => 7501, 'amount' => 0],
+                ],
+            ],
+        ]);
+    }
+}
*** Delete File: /Users/fabianwesner/Herd/shop/database/factories/TaxSettingsFactory.php
*** Add File: /Users/fabianwesner/Herd/shop/database/factories/TaxSettingsFactory.php
+<?php
+
+namespace Database\Factories;
+
+use App\Enums\TaxMode;
+use App\Models\Store;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\TaxSettings>
+ */
+class TaxSettingsFactory extends Factory
+{
+    /**
+     * Define the model's default state.
+     *
+     * @return array<string, mixed>
+     */
+    public function definition(): array
+    {
+        return [
+            'store_id' => Store::factory(),
+            'mode' => TaxMode::Manual,
+            'provider' => 'none',
+            'prices_include_tax' => false,
+            'config_json' => [
+                'name' => 'VAT',
+                'default_rate_bps' => 1900,
+                'shipping_taxable' => true,
+                'rates' => [
+                    ['country' => 'DE', 'rate_bps' => 1900, 'name' => 'VAT'],
+                ],
+            ],
+        ];
+    }
+
+    public function inclusive(): static
+    {
+        return $this->state(fn (array $attributes): array => [
+            'prices_include_tax' => true,
+        ]);
+    }
+}
*** Delete File: /Users/fabianwesner/Herd/shop/database/factories/DiscountFactory.php
*** Add File: /Users/fabianwesner/Herd/shop/database/factories/DiscountFactory.php
+<?php
+
+namespace Database\Factories;
+
+use App\Enums\DiscountStatus;
+use App\Enums\DiscountType;
+use App\Enums\DiscountValueType;
+use App\Models\Store;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Discount>
+ */
+class DiscountFactory extends Factory
+{
+    /**
+     * Define the model's default state.
+     *
+     * @return array<string, mixed>
+     */
+    public function definition(): array
+    {
+        return [
+            'store_id' => Store::factory(),
+            'type' => DiscountType::Code,
+            'code' => fake()->unique()->bothify('SAVE##'),
+            'value_type' => DiscountValueType::Percent,
+            'value_amount' => 10,
+            'starts_at' => now()->subDay(),
+            'ends_at' => now()->addMonth(),
+            'usage_limit' => null,
+            'usage_count' => 0,
+            'rules_json' => ['customer_eligibility' => 'all'],
+            'status' => DiscountStatus::Active,
+        ];
+    }
+
+    public function fixed(int $amount = 500): static
+    {
+        return $this->state(fn (array $attributes): array => [
+            'value_type' => DiscountValueType::Fixed,
+            'value_amount' => $amount,
+        ]);
+    }
+
+    public function freeShipping(): static
+    {
+        return $this->state(fn (array $attributes): array => [
+            'value_type' => DiscountValueType::FreeShipping,
+            'value_amount' => 0,
+        ]);
+    }
+}
*** End Patch


Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/database/factories/CartFactory.php
A /Users/fabianwesner/Herd/shop/database/factories/CartLineFactory.php
A /Users/fabianwesner/Herd/shop/database/factories/CheckoutFactory.php
A /Users/fabianwesner/Herd/shop/database/factories/ShippingZoneFactory.php
A /Users/fabianwesner/Herd/shop/database/factories/ShippingRateFactory.php
A /Users/fabianwesner/Herd/shop/database/factories/TaxSettingsFactory.php
A /Users/fabianwesner/Herd/shop/database/factories/DiscountFactory.php
D /Users/fabianwesner/Herd/shop/database/factories/CartFactory.php
D /Users/fabianwesner/Herd/shop/database/factories/CartLineFactory.php
D /Users/fabianwesner/Herd/shop/database/factories/CheckoutFactory.php
D /Users/fabianwesner/Herd/shop/database/factories/ShippingZoneFactory.php
D /Users/fabianwesner/Herd/shop/database/factories/ShippingRateFactory.php
D /Users/fabianwesner/Herd/shop/database/factories/TaxSettingsFactory.php
D /Users/fabianwesner/Herd/shop/database/factories/DiscountFactory.php

<?php

namespace Database\Factories;

use App\Enums\TaxMode;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\TaxSettings>
 */
class TaxSettingsFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'mode' => TaxMode::Manual,
            'provider' => 'none',
            'prices_include_tax' => false,
            'config_json' => [
                'name' => 'VAT',
                'default_rate_bps' => 1900,
                'shipping_taxable' => true,
                'rates' => [
                    ['country' => 'DE', 'rate_bps' => 1900, 'name' => 'VAT'],
                ],
            ],
        ];
    }

    public function inclusive(): static
    {
        return $this->state(fn (array $attributes): array => [
            'prices_include_tax' => true,
        ]);
    }
}

<?php

namespace Database\Factories;

use App\Enums\DiscountStatus;
use App\Enums\DiscountType;
use App\Enums\DiscountValueType;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Discount>
 */
class DiscountFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'type' => DiscountType::Code,
            'code' => fake()->unique()->bothify('SAVE##'),
            'value_type' => DiscountValueType::Percent,
            'value_amount' => 10,
            'starts_at' => now()->subDay(),
            'ends_at' => now()->addMonth(),
            'usage_limit' => null,
            'usage_count' => 0,
            'rules_json' => ['customer_eligibility' => 'all'],
            'status' => DiscountStatus::Active,
        ];
    }

    public function fixed(int $amount = 500): static
    {
        return $this->state(fn (array $attributes): array => [
            'value_type' => DiscountValueType::Fixed,
            'value_amount' => $amount,
        ]);
    }

    public function freeShipping(): static
    {
        return $this->state(fn (array $attributes): array => [
            'value_type' => DiscountValueType::FreeShipping,
            'value_amount' => 0,
        ]);
    }
}

<?php

namespace Database\Factories;

use App\Enums\CheckoutStatus;
use App\Models\Cart;
use App\Models\Customer;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Checkout>
 */
class CheckoutFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'cart_id' => Cart::factory(),
            'customer_id' => null,
            'status' => CheckoutStatus::Started,
            'payment_method' => null,
            'email' => null,
            'shipping_address_json' => null,
            'billing_address_json' => null,
            'shipping_method_id' => null,
            'discount_code' => null,
            'tax_provider_snapshot_json' => null,
            'totals_json' => null,
            'expires_at' => null,
        ];
    }

    public function forCustomer(?Customer $customer = null): static
    {
        return $this->state(fn (array $attributes): array => [
            'customer_id' => ($customer ?? Customer::factory()->create())->getKey(),
        ]);
    }
}

<?php

namespace Database\Factories;

use App\Enums\CartStatus;
use App\Models\Customer;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Cart>
 */
class CartFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'customer_id' => null,
            'currency' => 'EUR',
            'cart_version' => 1,
            'status' => CartStatus::Active,
        ];
    }

    public function forCustomer(?Customer $customer = null): static
    {
        return $this->state(fn (array $attributes): array => [
            'customer_id' => ($customer ?? Customer::factory()->create())->getKey(),
        ]);
    }

    public function abandoned(): static
    {
        return $this->state(fn (array $attributes): array => [
            'status' => CartStatus::Abandoned,
        ]);
    }
}

<?php

namespace Database\Factories;

use App\Enums\ShippingRateType;
use App\Models\ShippingZone;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ShippingRate>
 */
class ShippingRateFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'zone_id' => ShippingZone::factory(),
            'name' => 'Standard Shipping',
            'type' => ShippingRateType::Flat,
            'config_json' => ['amount' => 799],
            'is_active' => true,
        ];
    }

    public function weight(): static
    {
        return $this->state(fn (array $attributes): array => [
            'type' => ShippingRateType::Weight,
            'config_json' => [
                'ranges' => [
                    ['min_g' => 0, 'max_g' => 500, 'amount' => 499],
                    ['min_g' => 501, 'max_g' => 2000, 'amount' => 899],
                ],
            ],
        ]);
    }

    public function price(): static
    {
        return $this->state(fn (array $attributes): array => [
            'type' => ShippingRateType::Price,
            'config_json' => [
                'ranges' => [
                    ['min_amount' => 0, 'max_amount' => 7500, 'amount' => 799],
                    ['min_amount' => 7501, 'amount' => 0],
                ],
            ],
        ]);
    }
}

<?php

namespace Database\Factories;

use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ShippingZone>
 */
class ShippingZoneFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'name' => fake()->country().' Shipping',
            'countries_json' => ['DE'],
            'regions_json' => [],
        ];
    }
}

<?php

namespace Database\Factories;

use App\Models\Cart;
use App\Models\ProductVariant;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\CartLine>
 */
class CartLineFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        $quantity = fake()->numberBetween(1, 3);
        $unitPrice = fake()->numberBetween(1000, 10000);

        return [
            'cart_id' => Cart::factory(),
            'variant_id' => ProductVariant::factory(),
            'quantity' => $quantity,
            'unit_price_amount' => $unitPrice,
            'line_subtotal_amount' => $unitPrice * $quantity,
            'line_discount_amount' => 0,
            'line_total_amount' => $unitPrice * $quantity,
        ];
    }
}


Models and factories are now filled in. I’m adding the service-layer exceptions and value objects before the main services so the service APIs have stable return types and reason codes.


Input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/app/Exceptions/CartVersionMismatchException.php
+<?php
+
+namespace App\Exceptions;
+
+use RuntimeException;
+
+class CartVersionMismatchException extends RuntimeException
+{
+    public function __construct(
+        public readonly int $expectedVersion,
+        public readonly int $currentVersion,
+    ) {
+        parent::__construct("Cart version mismatch. Expected {$expectedVersion}, current {$currentVersion}.");
+    }
+}
*** Add File: /Users/fabianwesner/Herd/shop/app/Exceptions/InvalidCartOperationException.php
+<?php
+
+namespace App\Exceptions;
+
+use RuntimeException;
+
+class InvalidCartOperationException extends RuntimeException
+{
+    public static function because(string $reason): self
+    {
+        return new self($reason);
+    }
+}
*** Add File: /Users/fabianwesner/Herd/shop/app/Exceptions/InvalidDiscountException.php
+<?php
+
+namespace App\Exceptions;
+
+use RuntimeException;
+
+class InvalidDiscountException extends RuntimeException
+{
+    public function __construct(
+        public readonly string $reasonCode,
+        string $message,
+    ) {
+        parent::__construct($message);
+    }
+
+    public static function because(string $reasonCode, string $message): self
+    {
+        return new self($reasonCode, $message);
+    }
+}
*** Add File: /Users/fabianwesner/Herd/shop/app/Exceptions/InvalidCheckoutTransitionException.php
+<?php
+
+namespace App\Exceptions;
+
+use RuntimeException;
+
+class InvalidCheckoutTransitionException extends RuntimeException
+{
+    public static function because(string $reason): self
+    {
+        return new self($reason);
+    }
+}
*** Add File: /Users/fabianwesner/Herd/shop/app/Exceptions/UnserviceableShippingAddressException.php
+<?php
+
+namespace App\Exceptions;
+
+use RuntimeException;
+
+class UnserviceableShippingAddressException extends RuntimeException
+{
+    public static function forAddress(): self
+    {
+        return new self('Cannot ship to this address.');
+    }
+}
*** Add File: /Users/fabianwesner/Herd/shop/app/ValueObjects/TaxLine.php
+<?php
+
+namespace App\ValueObjects;
+
+final readonly class TaxLine
+{
+    public function __construct(
+        public string $name,
+        public int $rate,
+        public int $amount,
+    ) {}
+
+    /**
+     * @return array{name: string, rate: int, amount: int}
+     */
+    public function toArray(): array
+    {
+        return [
+            'name' => $this->name,
+            'rate' => $this->rate,
+            'amount' => $this->amount,
+        ];
+    }
+}
*** Add File: /Users/fabianwesner/Herd/shop/app/ValueObjects/TaxResult.php
+<?php
+
+namespace App\ValueObjects;
+
+final readonly class TaxResult
+{
+    /**
+     * @param  array<int, TaxLine>  $taxLines
+     */
+    public function __construct(
+        public array $taxLines,
+        public int $totalAmount,
+        public int $rate,
+    ) {}
+
+    /**
+     * @return array{tax_lines: array<int, array{name: string, rate: int, amount: int}>, tax_total: int, rate: int}
+     */
+    public function toArray(): array
+    {
+        return [
+            'tax_lines' => array_map(fn (TaxLine $line): array => $line->toArray(), $this->taxLines),
+            'tax_total' => $this->totalAmount,
+            'rate' => $this->rate,
+        ];
+    }
+}
*** Add File: /Users/fabianwesner/Herd/shop/app/ValueObjects/DiscountResult.php
+<?php
+
+namespace App\ValueObjects;
+
+final readonly class DiscountResult
+{
+    /**
+     * @param  array<int, int>  $allocations
+     */
+    public function __construct(
+        public int $amount,
+        public array $allocations,
+        public bool $freeShipping = false,
+    ) {}
+}
*** Add File: /Users/fabianwesner/Herd/shop/app/ValueObjects/PricingResult.php
+<?php
+
+namespace App\ValueObjects;
+
+final readonly class PricingResult
+{
+    /**
+     * @param  array<int, 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,
+    ) {}
+
+    /**
+     * @return array{subtotal: int, discount: int, shipping: int, tax_lines: array<int, array{name: string, rate: int, amount: int}>, tax: int, total: int, currency: string}
+     */
+    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,
+        ];
+    }
+}
*** Add File: /Users/fabianwesner/Herd/shop/app/Contracts/TaxProvider.php
+<?php
+
+namespace App\Contracts;
+
+use App\Models\TaxSettings;
+use App\ValueObjects\TaxResult;
+
+interface TaxProvider
+{
+    /**
+     * @param  array<int, int>  $amounts
+     * @param  array<string, mixed>  $address
+     */
+    public function calculate(array $amounts, int $shippingAmount, TaxSettings $settings, array $address): TaxResult;
+}
*** End Patch


Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/app/Exceptions/CartVersionMismatchException.php
A /Users/fabianwesner/Herd/shop/app/Exceptions/InvalidCartOperationException.php
A /Users/fabianwesner/Herd/shop/app/Exceptions/InvalidDiscountException.php
A /Users/fabianwesner/Herd/shop/app/Exceptions/InvalidCheckoutTransitionException.php
A /Users/fabianwesner/Herd/shop/app/Exceptions/UnserviceableShippingAddressException.php
A /Users/fabianwesner/Herd/shop/app/ValueObjects/TaxLine.php
A /Users/fabianwesner/Herd/shop/app/ValueObjects/TaxResult.php
A /Users/fabianwesner/Herd/shop/app/ValueObjects/DiscountResult.php
A /Users/fabianwesner/Herd/shop/app/ValueObjects/PricingResult.php
A /Users/fabianwesner/Herd/shop/app/Contracts/TaxProvider.php

<?php

namespace App\Exceptions;

use RuntimeException;

class InvalidDiscountException extends RuntimeException
{
    public function __construct(
        public readonly string $reasonCode,
        string $message,
    ) {
        parent::__construct($message);
    }

    public static function because(string $reasonCode, string $message): self
    {
        return new self($reasonCode, $message);
    }
}

<?php

namespace App\ValueObjects;

final readonly class DiscountResult
{
    /**
     * @param  array<int, int>  $allocations
     */
    public function __construct(
        public int $amount,
        public array $allocations,
        public bool $freeShipping = false,
    ) {}
}

<?php

namespace App\Exceptions;

use RuntimeException;

class UnserviceableShippingAddressException extends RuntimeException
{
    public static function forAddress(): self
    {
        return new self('Cannot ship to this address.');
    }
}

<?php

namespace App\Contracts;

use App\Models\TaxSettings;
use App\ValueObjects\TaxResult;

interface TaxProvider
{
    /**
     * @param  array<int, int>  $amounts
     * @param  array<string, mixed>  $address
     */
    public function calculate(array $amounts, int $shippingAmount, TaxSettings $settings, array $address): TaxResult;
}

<?php

namespace App\Exceptions;

use RuntimeException;

class InvalidCartOperationException extends RuntimeException
{
    public static function because(string $reason): self
    {
        return new self($reason);
    }
}

<?php

namespace App\Exceptions;

use RuntimeException;

class InvalidCheckoutTransitionException extends RuntimeException
{
    public static function because(string $reason): self
    {
        return new self($reason);
    }
}

<?php

namespace App\ValueObjects;

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

    /**
     * @return array{name: string, rate: int, amount: int}
     */
    public function toArray(): array
    {
        return [
            'name' => $this->name,
            'rate' => $this->rate,
            'amount' => $this->amount,
        ];
    }
}

<?php

namespace App\Exceptions;

use RuntimeException;

class CartVersionMismatchException extends RuntimeException
{
    public function __construct(
        public readonly int $expectedVersion,
        public readonly int $currentVersion,
    ) {
        parent::__construct("Cart version mismatch. Expected {$expectedVersion}, current {$currentVersion}.");
    }
}

<?php

namespace App\ValueObjects;

final readonly class PricingResult
{
    /**
     * @param  array<int, 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,
    ) {}

    /**
     * @return array{subtotal: int, discount: int, shipping: int, tax_lines: array<int, array{name: string, rate: int, amount: int}>, tax: int, total: int, currency: string}
     */
    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;

final readonly class TaxResult
{
    /**
     * @param  array<int, TaxLine>  $taxLines
     */
    public function __construct(
        public array $taxLines,
        public int $totalAmount,
        public int $rate,
    ) {}

    /**
     * @return array{tax_lines: array<int, array{name: string, rate: int, amount: int}>, tax_total: int, rate: int}
     */
    public function toArray(): array
    {
        return [
            'tax_lines' => array_map(fn (TaxLine $line): array => $line->toArray(), $this->taxLines),
            'tax_total' => $this->totalAmount,
            'rate' => $this->rate,
        ];
    }
}