stdout:
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

content:
<?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',
        ];
    }
}

content:
<?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',
        ];
    }
}

content:
<?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',
        ];
    }
}

content:
<?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',
        ];
    }
}

content:
<?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',
        ];
    }
}

content:
<?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,
        ];
    }
}

content:
<?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}}


status: completed

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


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

type: update

unified_diff:
@@ -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

unified_diff:
@@ -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>

status: completed


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}}


status: completed

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


stdout:
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

type: add

content:
<?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,
        ]);
    }
}

content:
<?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,
        ]);
    }
}

content:
<?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(),
        ]);
    }
}

content:
<?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,
        ]);
    }
}

content:
<?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],
                ],
            ],
        ]);
    }
}

content:
<?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' => [],
        ];
    }
}

content:
<?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,
        ];
    }
}


message: 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.

phase: commentary


type: message

role: assistant

text: 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.


status: completed

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


stdout:
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

content:
<?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);
    }
}

content:
<?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,
    ) {}
}

content:
<?php

namespace App\Exceptions;

use RuntimeException;

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

content:
<?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;
}

content:
<?php

namespace App\Exceptions;

use RuntimeException;

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

content:
<?php

namespace App\Exceptions;

use RuntimeException;

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

content:
<?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,
        ];
    }
}

content:
<?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}.");
    }
}

content:
<?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,
        ];
    }
}

content:
<?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,
        ];
    }
}


status: completed

input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/app/Services/CartService.php
+<?php
+
+namespace App\Services;
+
+use App\Enums\CartStatus;
+use App\Enums\ProductStatus;
+use App\Enums\VariantStatus;
+use App\Exceptions\CartVersionMismatchException;
+use App\Exceptions\InvalidCartOperationException;
+use App\Models\Cart;
+use App\Models\CartLine;
+use App\Models\Customer;
+use App\Models\InventoryItem;
+use App\Models\ProductVariant;
+use App\Models\Store;
+use Illuminate\Support\Facades\DB;
+
+class CartService
+{
+    public function __construct(
+        private readonly InventoryService $inventory,
+    ) {}
+
+    public function create(Store $store, ?Customer $customer = null): Cart
+    {
+        return Cart::withoutGlobalScopes()->create([
+            'store_id' => $store->getKey(),
+            'customer_id' => $customer?->getKey(),
+            'currency' => $store->default_currency,
+            'cart_version' => 1,
+            'status' => CartStatus::Active,
+        ]);
+    }
+
+    public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart
+    {
+        $sessionCartId = session('cart_id');
+
+        if ($customer instanceof Customer) {
+            $customerCart = Cart::withoutGlobalScopes()
+                ->where('store_id', $store->getKey())
+                ->where('customer_id', $customer->getKey())
+                ->where('status', CartStatus::Active)
+                ->latest('id')
+                ->first() ?? $this->create($store, $customer);
+
+            if ($sessionCartId) {
+                $guestCart = Cart::withoutGlobalScopes()
+                    ->where('store_id', $store->getKey())
+                    ->whereNull('customer_id')
+                    ->where('status', CartStatus::Active)
+                    ->find($sessionCartId);
+
+                if ($guestCart instanceof Cart && $guestCart->isNot($customerCart)) {
+                    $customerCart = $this->mergeOnLogin($guestCart, $customerCart);
+                }
+
+                session()->forget('cart_id');
+            }
+
+            return $customerCart->refresh();
+        }
+
+        if ($sessionCartId) {
+            $cart = Cart::withoutGlobalScopes()
+                ->where('store_id', $store->getKey())
+                ->whereNull('customer_id')
+                ->where('status', CartStatus::Active)
+                ->find($sessionCartId);
+
+            if ($cart instanceof Cart) {
+                return $cart;
+            }
+        }
+
+        $cart = $this->create($store);
+        session(['cart_id' => $cart->getKey()]);
+
+        return $cart;
+    }
+
+    public function addLine(Cart $cart, int $variantId, int $quantity, ?int $expectedVersion = null): CartLine
+    {
+        $this->assertPositiveQuantity($quantity);
+
+        return DB::transaction(function () use ($cart, $variantId, $quantity, $expectedVersion): CartLine {
+            $lockedCart = $this->freshCart($cart);
+            $this->assertExpectedVersion($lockedCart, $expectedVersion);
+            $this->assertCartIsActive($lockedCart);
+
+            $variant = $this->purchasableVariant($lockedCart, $variantId);
+            $line = CartLine::withoutGlobalScopes()
+                ->where('cart_id', $lockedCart->getKey())
+                ->where('variant_id', $variant->getKey())
+                ->first();
+            $newQuantity = ($line?->quantity ?? 0) + $quantity;
+
+            $this->assertAvailable($variant->inventoryItem, $newQuantity);
+
+            if (! $line instanceof CartLine) {
+                $line = new CartLine([
+                    'cart_id' => $lockedCart->getKey(),
+                    'variant_id' => $variant->getKey(),
+                    'unit_price_amount' => $variant->price_amount,
+                ]);
+            }
+
+            $this->fillLineAmounts($line, $newQuantity, $line->unit_price_amount ?: $variant->price_amount);
+            $line->save();
+            $this->incrementVersion($lockedCart);
+
+            return $line->refresh();
+        });
+    }
+
+    public function updateLineQuantity(Cart $cart, int $lineId, int $quantity, ?int $expectedVersion = null): ?CartLine
+    {
+        if ($quantity <= 0) {
+            $this->removeLine($cart, $lineId, $expectedVersion);
+
+            return null;
+        }
+
+        return DB::transaction(function () use ($cart, $lineId, $quantity, $expectedVersion): CartLine {
+            $lockedCart = $this->freshCart($cart);
+            $this->assertExpectedVersion($lockedCart, $expectedVersion);
+            $this->assertCartIsActive($lockedCart);
+
+            $line = CartLine::withoutGlobalScopes()
+                ->where('cart_id', $lockedCart->getKey())
+                ->findOrFail($lineId);
+            $variant = $this->purchasableVariant($lockedCart, $line->variant_id);
+
+            $this->assertAvailable($variant->inventoryItem, $quantity);
+            $this->fillLineAmounts($line, $quantity, $line->unit_price_amount);
+            $line->save();
+            $this->incrementVersion($lockedCart);
+
+            return $line->refresh();
+        });
+    }
+
+    public function removeLine(Cart $cart, int $lineId, ?int $expectedVersion = null): void
+    {
+        DB::transaction(function () use ($cart, $lineId, $expectedVersion): void {
+            $lockedCart = $this->freshCart($cart);
+            $this->assertExpectedVersion($lockedCart, $expectedVersion);
+            $this->assertCartIsActive($lockedCart);
+
+            CartLine::withoutGlobalScopes()
+                ->where('cart_id', $lockedCart->getKey())
+                ->whereKey($lineId)
+                ->delete();
+
+            $this->incrementVersion($lockedCart);
+        });
+    }
+
+    public function mergeOnLogin(Cart $guest, Cart $customer): Cart
+    {
+        return DB::transaction(function () use ($guest, $customer): Cart {
+            $guestCart = $this->freshCart($guest);
+            $customerCart = $this->freshCart($customer);
+
+            CartLine::withoutGlobalScopes()
+                ->where('cart_id', $guestCart->getKey())
+                ->get()
+                ->each(function (CartLine $guestLine) use ($customerCart): void {
+                    $existingLine = CartLine::withoutGlobalScopes()
+                        ->where('cart_id', $customerCart->getKey())
+                        ->where('variant_id', $guestLine->variant_id)
+                        ->first();
+
+                    if ($existingLine instanceof CartLine) {
+                        $quantity = max($existingLine->quantity, $guestLine->quantity);
+                        $this->fillLineAmounts($existingLine, $quantity, $existingLine->unit_price_amount);
+                        $existingLine->save();
+                        $guestLine->delete();
+
+                        return;
+                    }
+
+                    $guestLine->forceFill(['cart_id' => $customerCart->getKey()])->save();
+                });
+
+            $guestCart->forceFill(['status' => CartStatus::Abandoned])->save();
+            $this->incrementVersion($customerCart);
+
+            return $customerCart->refresh();
+        });
+    }
+
+    private function freshCart(Cart $cart): Cart
+    {
+        return Cart::withoutGlobalScopes()
+            ->whereKey($cart->getKey())
+            ->lockForUpdate()
+            ->firstOrFail();
+    }
+
+    private function purchasableVariant(Cart $cart, int $variantId): ProductVariant
+    {
+        $variant = ProductVariant::withoutGlobalScopes()
+            ->with([
+                'product' => fn ($query) => $query->withoutGlobalScopes(),
+                'inventoryItem' => fn ($query) => $query->withoutGlobalScopes(),
+            ])
+            ->findOrFail($variantId);
+
+        if ((int) $variant->product->store_id !== (int) $cart->store_id) {
+            throw InvalidCartOperationException::because('Variant does not belong to this store.');
+        }
+
+        if ($variant->product->status !== ProductStatus::Active || $variant->product->published_at === null) {
+            throw InvalidCartOperationException::because('Product is not active.');
+        }
+
+        if ($variant->status !== VariantStatus::Active) {
+            throw InvalidCartOperationException::because('Variant is not active.');
+        }
+
+        if (! $variant->inventoryItem instanceof InventoryItem) {
+            throw InvalidCartOperationException::because('Variant inventory is missing.');
+        }
+
+        return $variant;
+    }
+
+    private function assertAvailable(InventoryItem $item, int $quantity): void
+    {
+        if (! $this->inventory->checkAvailability($item, $quantity)) {
+            throw \App\Exceptions\InsufficientInventoryException::forQuantity($item->availableQuantity(), $quantity);
+        }
+    }
+
+    private function fillLineAmounts(CartLine $line, int $quantity, int $unitPrice): void
+    {
+        $subtotal = $unitPrice * $quantity;
+
+        $line->forceFill([
+            'quantity' => $quantity,
+            'unit_price_amount' => $unitPrice,
+            'line_subtotal_amount' => $subtotal,
+            'line_discount_amount' => 0,
+            'line_total_amount' => $subtotal,
+        ]);
+    }
+
+    private function incrementVersion(Cart $cart): void
+    {
+        $cart->forceFill([
+            'cart_version' => $cart->cart_version + 1,
+        ])->save();
+    }
+
+    private function assertExpectedVersion(Cart $cart, ?int $expectedVersion): void
+    {
+        if ($expectedVersion !== null && $expectedVersion !== $cart->cart_version) {
+            throw new CartVersionMismatchException($expectedVersion, $cart->cart_version);
+        }
+    }
+
+    private function assertCartIsActive(Cart $cart): void
+    {
+        if ($cart->status !== CartStatus::Active) {
+            throw InvalidCartOperationException::because('Cart is not active.');
+        }
+    }
+
+    private function assertPositiveQuantity(int $quantity): void
+    {
+        if ($quantity <= 0) {
+            throw InvalidCartOperationException::because('Cart line quantity must be greater than zero.');
+        }
+    }
+}
*** End Patch


stdout:
Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/app/Services/CartService.php

content:
<?php

namespace App\Services;

use App\Enums\CartStatus;
use App\Enums\ProductStatus;
use App\Enums\VariantStatus;
use App\Exceptions\CartVersionMismatchException;
use App\Exceptions\InvalidCartOperationException;
use App\Models\Cart;
use App\Models\CartLine;
use App\Models\Customer;
use App\Models\InventoryItem;
use App\Models\ProductVariant;
use App\Models\Store;
use Illuminate\Support\Facades\DB;

class CartService
{
    public function __construct(
        private readonly InventoryService $inventory,
    ) {}

    public function create(Store $store, ?Customer $customer = null): Cart
    {
        return Cart::withoutGlobalScopes()->create([
            'store_id' => $store->getKey(),
            'customer_id' => $customer?->getKey(),
            'currency' => $store->default_currency,
            'cart_version' => 1,
            'status' => CartStatus::Active,
        ]);
    }

    public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart
    {
        $sessionCartId = session('cart_id');

        if ($customer instanceof Customer) {
            $customerCart = Cart::withoutGlobalScopes()
                ->where('store_id', $store->getKey())
                ->where('customer_id', $customer->getKey())
                ->where('status', CartStatus::Active)
                ->latest('id')
                ->first() ?? $this->create($store, $customer);

            if ($sessionCartId) {
                $guestCart = Cart::withoutGlobalScopes()
                    ->where('store_id', $store->getKey())
                    ->whereNull('customer_id')
                    ->where('status', CartStatus::Active)
                    ->find($sessionCartId);

                if ($guestCart instanceof Cart && $guestCart->isNot($customerCart)) {
                    $customerCart = $this->mergeOnLogin($guestCart, $customerCart);
                }

                session()->forget('cart_id');
            }

            return $customerCart->refresh();
        }

        if ($sessionCartId) {
            $cart = Cart::withoutGlobalScopes()
                ->where('store_id', $store->getKey())
                ->whereNull('customer_id')
                ->where('status', CartStatus::Active)
                ->find($sessionCartId);

            if ($cart instanceof Cart) {
                return $cart;
            }
        }

        $cart = $this->create($store);
        session(['cart_id' => $cart->getKey()]);

        return $cart;
    }

    public function addLine(Cart $cart, int $variantId, int $quantity, ?int $expectedVersion = null): CartLine
    {
        $this->assertPositiveQuantity($quantity);

        return DB::transaction(function () use ($cart, $variantId, $quantity, $expectedVersion): CartLine {
            $lockedCart = $this->freshCart($cart);
            $this->assertExpectedVersion($lockedCart, $expectedVersion);
            $this->assertCartIsActive($lockedCart);

            $variant = $this->purchasableVariant($lockedCart, $variantId);
            $line = CartLine::withoutGlobalScopes()
                ->where('cart_id', $lockedCart->getKey())
                ->where('variant_id', $variant->getKey())
                ->first();
            $newQuantity = ($line?->quantity ?? 0) + $quantity;

            $this->assertAvailable($variant->inventoryItem, $newQuantity);

            if (! $line instanceof CartLine) {
                $line = new CartLine([
                    'cart_id' => $lockedCart->getKey(),
                    'variant_id' => $variant->getKey(),
                    'unit_price_amount' => $variant->price_amount,
                ]);
            }

            $this->fillLineAmounts($line, $newQuantity, $line->unit_price_amount ?: $variant->price_amount);
            $line->save();
            $this->incrementVersion($lockedCart);

            return $line->refresh();
        });
    }

    public function updateLineQuantity(Cart $cart, int $lineId, int $quantity, ?int $expectedVersion = null): ?CartLine
    {
        if ($quantity <= 0) {
            $this->removeLine($cart, $lineId, $expectedVersion);

            return null;
        }

        return DB::transaction(function () use ($cart, $lineId, $quantity, $expectedVersion): CartLine {
            $lockedCart = $this->freshCart($cart);
            $this->assertExpectedVersion($lockedCart, $expectedVersion);
            $this->assertCartIsActive($lockedCart);

            $line = CartLine::withoutGlobalScopes()
                ->where('cart_id', $lockedCart->getKey())
                ->findOrFail($lineId);
            $variant = $this->purchasableVariant($lockedCart, $line->variant_id);

            $this->assertAvailable($variant->inventoryItem, $quantity);
            $this->fillLineAmounts($line, $quantity, $line->unit_price_amount);
            $line->save();
            $this->incrementVersion($lockedCart);

            return $line->refresh();
        });
    }

    public function removeLine(Cart $cart, int $lineId, ?int $expectedVersion = null): void
    {
        DB::transaction(function () use ($cart, $lineId, $expectedVersion): void {
            $lockedCart = $this->freshCart($cart);
            $this->assertExpectedVersion($lockedCart, $expectedVersion);
            $this->assertCartIsActive($lockedCart);

            CartLine::withoutGlobalScopes()
                ->where('cart_id', $lockedCart->getKey())
                ->whereKey($lineId)
                ->delete();

            $this->incrementVersion($lockedCart);
        });
    }

    public function mergeOnLogin(Cart $guest, Cart $customer): Cart
    {
        return DB::transaction(function () use ($guest, $customer): Cart {
            $guestCart = $this->freshCart($guest);
            $customerCart = $this->freshCart($customer);

            CartLine::withoutGlobalScopes()
                ->where('cart_id', $guestCart->getKey())
                ->get()
                ->each(function (CartLine $guestLine) use ($customerCart): void {
                    $existingLine = CartLine::withoutGlobalScopes()
                        ->where('cart_id', $customerCart->getKey())
                        ->where('variant_id', $guestLine->variant_id)
                        ->first();

                    if ($existingLine instanceof CartLine) {
                        $quantity = max($existingLine->quantity, $guestLine->quantity);
                        $this->fillLineAmounts($existingLine, $quantity, $existingLine->unit_price_amount);
                        $existingLine->save();
                        $guestLine->delete();

                        return;
                    }

                    $guestLine->forceFill(['cart_id' => $customerCart->getKey()])->save();
                });

            $guestCart->forceFill(['status' => CartStatus::Abandoned])->save();
            $this->incrementVersion($customerCart);

            return $customerCart->refresh();
        });
    }

    private function freshCart(Cart $cart): Cart
    {
        return Cart::withoutGlobalScopes()
            ->whereKey($cart->getKey())
            ->lockForUpdate()
            ->firstOrFail();
    }

    private function purchasableVariant(Cart $cart, int $variantId): ProductVariant
    {
        $variant = ProductVariant::withoutGlobalScopes()
            ->with([
                'product' => fn ($query) => $query->withoutGlobalScopes(),
                'inventoryItem' => fn ($query) => $query->withoutGlobalScopes(),
            ])
            ->findOrFail($variantId);

        if ((int) $variant->product->store_id !== (int) $cart->store_id) {
            throw InvalidCartOperationException::because('Variant does not belong to this store.');
        }

        if ($variant->product->status !== ProductStatus::Active || $variant->product->published_at === null) {
            throw InvalidCartOperationException::because('Product is not active.');
        }

        if ($variant->status !== VariantStatus::Active) {
            throw InvalidCartOperationException::because('Variant is not active.');
        }

        if (! $variant->inventoryItem instanceof InventoryItem) {
            throw InvalidCartOperationException::because('Variant inventory is missing.');
        }

        return $variant;
    }

    private function assertAvailable(InventoryItem $item, int $quantity): void
    {
        if (! $this->inventory->checkAvailability($item, $quantity)) {
            throw \App\Exceptions\InsufficientInventoryException::forQuantity($item->availableQuantity(), $quantity);
        }
    }

    private function fillLineAmounts(CartLine $line, int $quantity, int $unitPrice): void
    {
        $subtotal = $unitPrice * $quantity;

        $line->forceFill([
            'quantity' => $quantity,
            'unit_price_amount' => $unitPrice,
            'line_subtotal_amount' => $subtotal,
            'line_discount_amount' => 0,
            'line_total_amount' => $subtotal,
        ]);
    }

    private function incrementVersion(Cart $cart): void
    {
        $cart->forceFill([
            'cart_version' => $cart->cart_version + 1,
        ])->save();
    }

    private function assertExpectedVersion(Cart $cart, ?int $expectedVersion): void
    {
        if ($expectedVersion !== null && $expectedVersion !== $cart->cart_version) {
            throw new CartVersionMismatchException($expectedVersion, $cart->cart_version);
        }
    }

    private function assertCartIsActive(Cart $cart): void
    {
        if ($cart->status !== CartStatus::Active) {
            throw InvalidCartOperationException::because('Cart is not active.');
        }
    }

    private function assertPositiveQuantity(int $quantity): void
    {
        if ($quantity <= 0) {
            throw InvalidCartOperationException::because('Cart line quantity must be greater than zero.');
        }
    }
}


output: {"output":"Success. Updated the following files:\nA /Users/fabianwesner/Herd/shop/app/Services/CartService.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/app/Services/DiscountService.php
+<?php
+
+namespace App\Services;
+
+use App\Enums\DiscountStatus;
+use App\Enums\DiscountType;
+use App\Enums\DiscountValueType;
+use App\Exceptions\InvalidDiscountException;
+use App\Models\Cart;
+use App\Models\CartLine;
+use App\Models\Discount;
+use App\Models\ProductVariant;
+use App\Models\Store;
+use App\ValueObjects\DiscountResult;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+
+class DiscountService
+{
+    public function validate(string $code, Store $store, Cart $cart): Discount
+    {
+        $normalizedCode = mb_strtolower(trim($code));
+
+        $discount = Discount::withoutGlobalScopes()
+            ->where('store_id', $store->getKey())
+            ->where('type', DiscountType::Code)
+            ->whereRaw('lower(code) = ?', [$normalizedCode])
+            ->first();
+
+        if (! $discount instanceof Discount) {
+            throw InvalidDiscountException::because('discount_not_found', 'Discount code was not found.');
+        }
+
+        if ($discount->status !== DiscountStatus::Active) {
+            throw InvalidDiscountException::because('discount_expired', 'Discount is not active.');
+        }
+
+        if ($discount->starts_at->isFuture()) {
+            throw InvalidDiscountException::because('discount_not_yet_active', 'Discount is not active yet.');
+        }
+
+        if ($discount->ends_at !== null && $discount->ends_at->isPast()) {
+            throw InvalidDiscountException::because('discount_expired', 'Discount has expired.');
+        }
+
+        if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) {
+            throw InvalidDiscountException::because('discount_usage_limit_reached', 'Discount usage limit has been reached.');
+        }
+
+        $lines = $this->cartLines($cart);
+        $subtotal = $lines->sum('line_subtotal_amount');
+        $minimum = (int) data_get($discount->rules_json, 'min_purchase_amount', data_get($discount->rules_json, 'minimum_purchase', 0));
+
+        if ($minimum > 0 && $subtotal < $minimum) {
+            throw InvalidDiscountException::because('discount_min_purchase_not_met', 'Cart does not meet the minimum purchase amount.');
+        }
+
+        if ($this->qualifyingLines($discount, $lines)->isEmpty()) {
+            throw InvalidDiscountException::because('discount_not_applicable', 'Discount does not apply to these cart lines.');
+        }
+
+        return $discount;
+    }
+
+    /**
+     * @param  array<int, CartLine>  $lines
+     */
+    public function calculate(Discount $discount, int $subtotal, array $lines): DiscountResult
+    {
+        if ($discount->value_type === DiscountValueType::FreeShipping) {
+            return new DiscountResult(0, [], true);
+        }
+
+        $qualifyingLines = $this->qualifyingLines($discount, collect($lines));
+        $qualifyingSubtotal = $qualifyingLines->sum('line_subtotal_amount');
+
+        if ($qualifyingSubtotal <= 0) {
+            return new DiscountResult(0, []);
+        }
+
+        $discountAmount = match ($discount->value_type) {
+            DiscountValueType::Percent => (int) round($qualifyingSubtotal * $discount->value_amount / 100),
+            DiscountValueType::Fixed => min($discount->value_amount, $qualifyingSubtotal),
+            DiscountValueType::FreeShipping => 0,
+        };
+
+        $remaining = $discountAmount;
+        $allocations = [];
+        $lastIndex = $qualifyingLines->keys()->last();
+
+        foreach ($qualifyingLines as $index => $line) {
+            if ($index === $lastIndex) {
+                $allocations[$line->getKey()] = $remaining;
+
+                continue;
+            }
+
+            $allocation = (int) round($discountAmount * $line->line_subtotal_amount / $qualifyingSubtotal);
+            $allocations[$line->getKey()] = $allocation;
+            $remaining -= $allocation;
+        }
+
+        return new DiscountResult($discountAmount, $allocations);
+    }
+
+    public function applyToCart(Cart $cart, Discount $discount): DiscountResult
+    {
+        return DB::transaction(function () use ($cart, $discount): DiscountResult {
+            $lines = $this->cartLines($cart);
+            $result = $this->calculate($discount, $lines->sum('line_subtotal_amount'), $lines->all());
+
+            $lines->each(function (CartLine $line) use ($result): void {
+                $discountAmount = $result->allocations[$line->getKey()] ?? 0;
+
+                $line->forceFill([
+                    'line_discount_amount' => $discountAmount,
+                    'line_total_amount' => $line->line_subtotal_amount - $discountAmount,
+                ])->save();
+            });
+
+            return $result;
+        });
+    }
+
+    /**
+     * @return Collection<int, CartLine>
+     */
+    private function cartLines(Cart $cart): Collection
+    {
+        return CartLine::withoutGlobalScopes()
+            ->where('cart_id', $cart->getKey())
+            ->orderBy('id')
+            ->get();
+    }
+
+    /**
+     * @param  Collection<int, CartLine>  $lines
+     * @return Collection<int, CartLine>
+     */
+    private function qualifyingLines(Discount $discount, Collection $lines): Collection
+    {
+        $productIds = collect(data_get($discount->rules_json, 'applicable_product_ids', []))
+            ->filter()
+            ->map(fn (mixed $id): int => (int) $id)
+            ->values();
+        $collectionIds = collect(data_get($discount->rules_json, 'applicable_collection_ids', []))
+            ->filter()
+            ->map(fn (mixed $id): int => (int) $id)
+            ->values();
+
+        if ($productIds->isEmpty() && $collectionIds->isEmpty()) {
+            return $lines;
+        }
+
+        return $lines->filter(function (CartLine $line) use ($productIds, $collectionIds): bool {
+            $variant = ProductVariant::withoutGlobalScopes()
+                ->with(['product' => fn ($query) => $query->withoutGlobalScopes()->with('collections')])
+                ->find($line->variant_id);
+
+            if (! $variant instanceof ProductVariant || $variant->product === null) {
+                return false;
+            }
+
+            if ($productIds->contains((int) $variant->product_id)) {
+                return true;
+            }
+
+            return $variant->product->collections
+                ->pluck('id')
+                ->map(fn (int $id): int => $id)
+                ->intersect($collectionIds)
+                ->isNotEmpty();
+        });
+    }
+}
*** End Patch


stdout:
Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/app/Services/DiscountService.php

content:
<?php

namespace App\Services;

use App\Enums\DiscountStatus;
use App\Enums\DiscountType;
use App\Enums\DiscountValueType;
use App\Exceptions\InvalidDiscountException;
use App\Models\Cart;
use App\Models\CartLine;
use App\Models\Discount;
use App\Models\ProductVariant;
use App\Models\Store;
use App\ValueObjects\DiscountResult;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

class DiscountService
{
    public function validate(string $code, Store $store, Cart $cart): Discount
    {
        $normalizedCode = mb_strtolower(trim($code));

        $discount = Discount::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->where('type', DiscountType::Code)
            ->whereRaw('lower(code) = ?', [$normalizedCode])
            ->first();

        if (! $discount instanceof Discount) {
            throw InvalidDiscountException::because('discount_not_found', 'Discount code was not found.');
        }

        if ($discount->status !== DiscountStatus::Active) {
            throw InvalidDiscountException::because('discount_expired', 'Discount is not active.');
        }

        if ($discount->starts_at->isFuture()) {
            throw InvalidDiscountException::because('discount_not_yet_active', 'Discount is not active yet.');
        }

        if ($discount->ends_at !== null && $discount->ends_at->isPast()) {
            throw InvalidDiscountException::because('discount_expired', 'Discount has expired.');
        }

        if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) {
            throw InvalidDiscountException::because('discount_usage_limit_reached', 'Discount usage limit has been reached.');
        }

        $lines = $this->cartLines($cart);
        $subtotal = $lines->sum('line_subtotal_amount');
        $minimum = (int) data_get($discount->rules_json, 'min_purchase_amount', data_get($discount->rules_json, 'minimum_purchase', 0));

        if ($minimum > 0 && $subtotal < $minimum) {
            throw InvalidDiscountException::because('discount_min_purchase_not_met', 'Cart does not meet the minimum purchase amount.');
        }

        if ($this->qualifyingLines($discount, $lines)->isEmpty()) {
            throw InvalidDiscountException::because('discount_not_applicable', 'Discount does not apply to these cart lines.');
        }

        return $discount;
    }

    /**
     * @param  array<int, CartLine>  $lines
     */
    public function calculate(Discount $discount, int $subtotal, array $lines): DiscountResult
    {
        if ($discount->value_type === DiscountValueType::FreeShipping) {
            return new DiscountResult(0, [], true);
        }

        $qualifyingLines = $this->qualifyingLines($discount, collect($lines));
        $qualifyingSubtotal = $qualifyingLines->sum('line_subtotal_amount');

        if ($qualifyingSubtotal <= 0) {
            return new DiscountResult(0, []);
        }

        $discountAmount = match ($discount->value_type) {
            DiscountValueType::Percent => (int) round($qualifyingSubtotal * $discount->value_amount / 100),
            DiscountValueType::Fixed => min($discount->value_amount, $qualifyingSubtotal),
            DiscountValueType::FreeShipping => 0,
        };

        $remaining = $discountAmount;
        $allocations = [];
        $lastIndex = $qualifyingLines->keys()->last();

        foreach ($qualifyingLines as $index => $line) {
            if ($index === $lastIndex) {
                $allocations[$line->getKey()] = $remaining;

                continue;
            }

            $allocation = (int) round($discountAmount * $line->line_subtotal_amount / $qualifyingSubtotal);
            $allocations[$line->getKey()] = $allocation;
            $remaining -= $allocation;
        }

        return new DiscountResult($discountAmount, $allocations);
    }

    public function applyToCart(Cart $cart, Discount $discount): DiscountResult
    {
        return DB::transaction(function () use ($cart, $discount): DiscountResult {
            $lines = $this->cartLines($cart);
            $result = $this->calculate($discount, $lines->sum('line_subtotal_amount'), $lines->all());

            $lines->each(function (CartLine $line) use ($result): void {
                $discountAmount = $result->allocations[$line->getKey()] ?? 0;

                $line->forceFill([
                    'line_discount_amount' => $discountAmount,
                    'line_total_amount' => $line->line_subtotal_amount - $discountAmount,
                ])->save();
            });

            return $result;
        });
    }

    /**
     * @return Collection<int, CartLine>
     */
    private function cartLines(Cart $cart): Collection
    {
        return CartLine::withoutGlobalScopes()
            ->where('cart_id', $cart->getKey())
            ->orderBy('id')
            ->get();
    }

    /**
     * @param  Collection<int, CartLine>  $lines
     * @return Collection<int, CartLine>
     */
    private function qualifyingLines(Discount $discount, Collection $lines): Collection
    {
        $productIds = collect(data_get($discount->rules_json, 'applicable_product_ids', []))
            ->filter()
            ->map(fn (mixed $id): int => (int) $id)
            ->values();
        $collectionIds = collect(data_get($discount->rules_json, 'applicable_collection_ids', []))
            ->filter()
            ->map(fn (mixed $id): int => (int) $id)
            ->values();

        if ($productIds->isEmpty() && $collectionIds->isEmpty()) {
            return $lines;
        }

        return $lines->filter(function (CartLine $line) use ($productIds, $collectionIds): bool {
            $variant = ProductVariant::withoutGlobalScopes()
                ->with(['product' => fn ($query) => $query->withoutGlobalScopes()->with('collections')])
                ->find($line->variant_id);

            if (! $variant instanceof ProductVariant || $variant->product === null) {
                return false;
            }

            if ($productIds->contains((int) $variant->product_id)) {
                return true;
            }

            return $variant->product->collections
                ->pluck('id')
                ->map(fn (int $id): int => $id)
                ->intersect($collectionIds)
                ->isNotEmpty();
        });
    }
}


output: {"output":"Success. Updated the following files:\nA /Users/fabianwesner/Herd/shop/app/Services/DiscountService.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/app/Services/ShippingCalculator.php
+<?php
+
+namespace App\Services;
+
+use App\Enums\ShippingRateType;
+use App\Models\Cart;
+use App\Models\CartLine;
+use App\Models\ProductVariant;
+use App\Models\ShippingRate;
+use App\Models\ShippingZone;
+use App\Models\Store;
+use Illuminate\Support\Collection;
+
+class ShippingCalculator
+{
+    /**
+     * @param  array<string, mixed>  $address
+     * @return Collection<int, ShippingRate>
+     */
+    public function getAvailableRates(Store $store, array $address): Collection
+    {
+        $zone = $this->matchingZone($store, $address);
+
+        if (! $zone instanceof ShippingZone) {
+            return collect();
+        }
+
+        return ShippingRate::withoutGlobalScopes()
+            ->where('zone_id', $zone->getKey())
+            ->where('is_active', true)
+            ->orderBy('id')
+            ->get();
+    }
+
+    /**
+     * @param  array<string, mixed>  $address
+     */
+    public function matchingZone(Store $store, array $address): ?ShippingZone
+    {
+        $country = strtoupper((string) (data_get($address, 'country_code') ?: data_get($address, 'country')));
+        $region = strtoupper((string) data_get($address, 'province_code'));
+        $bestZone = null;
+        $bestSpecificity = -1;
+
+        ShippingZone::withoutGlobalScopes()
+            ->where('store_id', $store->getKey())
+            ->orderBy('id')
+            ->get()
+            ->each(function (ShippingZone $zone) use ($country, $region, &$bestZone, &$bestSpecificity): void {
+                $countries = collect($zone->countries_json)->map(fn (string $code): string => strtoupper($code));
+                $regions = collect($zone->regions_json)->map(fn (string $code): string => strtoupper($code));
+
+                if (! $countries->contains($country)) {
+                    return;
+                }
+
+                $specificity = $regions->contains($region) ? 2 : 1;
+
+                if ($specificity > $bestSpecificity) {
+                    $bestZone = $zone;
+                    $bestSpecificity = $specificity;
+                }
+            });
+
+        return $bestZone;
+    }
+
+    public function calculate(ShippingRate $rate, Cart $cart): ?int
+    {
+        if (! $this->requiresShipping($cart)) {
+            return 0;
+        }
+
+        return match ($rate->type) {
+            ShippingRateType::Flat => (int) data_get($rate->config_json, 'amount', 0),
+            ShippingRateType::Weight => $this->weightRate($rate, $cart),
+            ShippingRateType::Price => $this->priceRate($rate, $cart),
+            ShippingRateType::Carrier => (int) data_get($rate->config_json, 'amount', 1299),
+        };
+    }
+
+    public function requiresShipping(Cart $cart): bool
+    {
+        return $this->physicalLines($cart)->isNotEmpty();
+    }
+
+    private function weightRate(ShippingRate $rate, Cart $cart): ?int
+    {
+        $totalWeight = $this->physicalLines($cart)
+            ->sum(function (CartLine $line): int {
+                $variant = $this->variant($line);
+
+                return ($variant?->weight_g ?? 0) * $line->quantity;
+            });
+
+        foreach (data_get($rate->config_json, 'ranges', []) as $range) {
+            $minimum = (int) data_get($range, 'min_g', 0);
+            $maximum = data_get($range, 'max_g');
+
+            if ($totalWeight >= $minimum && ($maximum === null || $totalWeight <= (int) $maximum)) {
+                return (int) data_get($range, 'amount', 0);
+            }
+        }
+
+        return null;
+    }
+
+    private function priceRate(ShippingRate $rate, Cart $cart): ?int
+    {
+        $subtotal = CartLine::withoutGlobalScopes()
+            ->where('cart_id', $cart->getKey())
+            ->sum('line_subtotal_amount');
+
+        foreach (data_get($rate->config_json, 'ranges', []) as $range) {
+            $minimum = (int) data_get($range, 'min_amount', 0);
+            $maximum = data_get($range, 'max_amount');
+
+            if ($subtotal >= $minimum && ($maximum === null || $subtotal <= (int) $maximum)) {
+                return (int) data_get($range, 'amount', 0);
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * @return Collection<int, CartLine>
+     */
+    private function physicalLines(Cart $cart): Collection
+    {
+        return CartLine::withoutGlobalScopes()
+            ->where('cart_id', $cart->getKey())
+            ->get()
+            ->filter(function (CartLine $line): bool {
+                return (bool) $this->variant($line)?->requires_shipping;
+            });
+    }
+
+    private function variant(CartLine $line): ?ProductVariant
+    {
+        return ProductVariant::withoutGlobalScopes()
+            ->whereKey($line->variant_id)
+            ->first();
+    }
+}
*** End Patch


stdout:
Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/app/Services/ShippingCalculator.php

content:
<?php

namespace App\Services;

use App\Enums\ShippingRateType;
use App\Models\Cart;
use App\Models\CartLine;
use App\Models\ProductVariant;
use App\Models\ShippingRate;
use App\Models\ShippingZone;
use App\Models\Store;
use Illuminate\Support\Collection;

class ShippingCalculator
{
    /**
     * @param  array<string, mixed>  $address
     * @return Collection<int, ShippingRate>
     */
    public function getAvailableRates(Store $store, array $address): Collection
    {
        $zone = $this->matchingZone($store, $address);

        if (! $zone instanceof ShippingZone) {
            return collect();
        }

        return ShippingRate::withoutGlobalScopes()
            ->where('zone_id', $zone->getKey())
            ->where('is_active', true)
            ->orderBy('id')
            ->get();
    }

    /**
     * @param  array<string, mixed>  $address
     */
    public function matchingZone(Store $store, array $address): ?ShippingZone
    {
        $country = strtoupper((string) (data_get($address, 'country_code') ?: data_get($address, 'country')));
        $region = strtoupper((string) data_get($address, 'province_code'));
        $bestZone = null;
        $bestSpecificity = -1;

        ShippingZone::withoutGlobalScopes()
            ->where('store_id', $store->getKey())
            ->orderBy('id')
            ->get()
            ->each(function (ShippingZone $zone) use ($country, $region, &$bestZone, &$bestSpecificity): void {
                $countries = collect($zone->countries_json)->map(fn (string $code): string => strtoupper($code));
                $regions = collect($zone->regions_json)->map(fn (string $code): string => strtoupper($code));

                if (! $countries->contains($country)) {
                    return;
                }

                $specificity = $regions->contains($region) ? 2 : 1;

                if ($specificity > $bestSpecificity) {
                    $bestZone = $zone;
                    $bestSpecificity = $specificity;
                }
            });

        return $bestZone;
    }

    public function calculate(ShippingRate $rate, Cart $cart): ?int
    {
        if (! $this->requiresShipping($cart)) {
            return 0;
        }

        return match ($rate->type) {
            ShippingRateType::Flat => (int) data_get($rate->config_json, 'amount', 0),
            ShippingRateType::Weight => $this->weightRate($rate, $cart),
            ShippingRateType::Price => $this->priceRate($rate, $cart),
            ShippingRateType::Carrier => (int) data_get($rate->config_json, 'amount', 1299),
        };
    }

    public function requiresShipping(Cart $cart): bool
    {
        return $this->physicalLines($cart)->isNotEmpty();
    }

    private function weightRate(ShippingRate $rate, Cart $cart): ?int
    {
        $totalWeight = $this->physicalLines($cart)
            ->sum(function (CartLine $line): int {
                $variant = $this->variant($line);

                return ($variant?->weight_g ?? 0) * $line->quantity;
            });

        foreach (data_get($rate->config_json, 'ranges', []) as $range) {
            $minimum = (int) data_get($range, 'min_g', 0);
            $maximum = data_get($range, 'max_g');

            if ($totalWeight >= $minimum && ($maximum === null || $totalWeight <= (int) $maximum)) {
                return (int) data_get($range, 'amount', 0);
            }
        }

        return null;
    }

    private function priceRate(ShippingRate $rate, Cart $cart): ?int
    {
        $subtotal = CartLine::withoutGlobalScopes()
            ->where('cart_id', $cart->getKey())
            ->sum('line_subtotal_amount');

        foreach (data_get($rate->config_json, 'ranges', []) as $range) {
            $minimum = (int) data_get($range, 'min_amount', 0);
            $maximum = data_get($range, 'max_amount');

            if ($subtotal >= $minimum && ($maximum === null || $subtotal <= (int) $maximum)) {
                return (int) data_get($range, 'amount', 0);
            }
        }

        return null;
    }

    /**
     * @return Collection<int, CartLine>
     */
    private function physicalLines(Cart $cart): Collection
    {
        return CartLine::withoutGlobalScopes()
            ->where('cart_id', $cart->getKey())
            ->get()
            ->filter(function (CartLine $line): bool {
                return (bool) $this->variant($line)?->requires_shipping;
            });
    }

    private function variant(CartLine $line): ?ProductVariant
    {
        return ProductVariant::withoutGlobalScopes()
            ->whereKey($line->variant_id)
            ->first();
    }
}


output: {"output":"Success. Updated the following files:\nA /Users/fabianwesner/Herd/shop/app/Services/ShippingCalculator.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/app/Services/Tax/ManualTaxProvider.php
+<?php
+
+namespace App\Services\Tax;
+
+use App\Contracts\TaxProvider;
+use App\Models\TaxSettings;
+use App\ValueObjects\TaxLine;
+use App\ValueObjects\TaxResult;
+
+class ManualTaxProvider implements TaxProvider
+{
+    /**
+     * @param  array<int, int>  $amounts
+     * @param  array<string, mixed>  $address
+     */
+    public function calculate(array $amounts, int $shippingAmount, TaxSettings $settings, array $address): TaxResult
+    {
+        $rate = $this->rateFor($settings, $address);
+
+        if ($rate <= 0) {
+            return new TaxResult([], 0, 0);
+        }
+
+        if ((bool) data_get($settings->config_json, 'shipping_taxable', true) && $shippingAmount > 0) {
+            $amounts[] = $shippingAmount;
+        }
+
+        $taxAmount = collect($amounts)->sum(fn (int $amount): int => $settings->prices_include_tax
+            ? $this->extractInclusive($amount, $rate)
+            : $this->addExclusive($amount, $rate)
+        );
+
+        return new TaxResult([
+            new TaxLine($this->nameFor($settings, $address), $rate, $taxAmount),
+        ], $taxAmount, $rate);
+    }
+
+    /**
+     * @param  array<string, mixed>  $address
+     */
+    public function rateFor(TaxSettings $settings, array $address): int
+    {
+        $country = strtoupper((string) (data_get($address, 'country_code') ?: data_get($address, 'country')));
+        $province = strtoupper((string) data_get($address, 'province_code'));
+
+        foreach (data_get($settings->config_json, 'rates', []) as $rate) {
+            $rateCountry = strtoupper((string) data_get($rate, 'country'));
+            $rateProvince = strtoupper((string) data_get($rate, 'province_code'));
+
+            if ($rateCountry !== '' && $rateCountry !== $country) {
+                continue;
+            }
+
+            if ($rateProvince !== '' && $rateProvince !== $province) {
+                continue;
+            }
+
+            return (int) data_get($rate, 'rate_bps', 0);
+        }
+
+        return (int) data_get($settings->config_json, 'default_rate_bps', 0);
+    }
+
+    /**
+     * @param  array<string, mixed>  $address
+     */
+    public function nameFor(TaxSettings $settings, array $address): string
+    {
+        $country = strtoupper((string) (data_get($address, 'country_code') ?: data_get($address, 'country')));
+
+        foreach (data_get($settings->config_json, 'rates', []) as $rate) {
+            if (strtoupper((string) data_get($rate, 'country')) === $country && data_get($rate, 'name')) {
+                return (string) data_get($rate, 'name');
+            }
+        }
+
+        return (string) data_get($settings->config_json, 'name', 'Tax');
+    }
+
+    public function extractInclusive(int $grossAmount, int $rateBasisPoints): int
+    {
+        if ($grossAmount <= 0 || $rateBasisPoints <= 0) {
+            return 0;
+        }
+
+        return $grossAmount - intdiv($grossAmount * 10000, 10000 + $rateBasisPoints);
+    }
+
+    public function addExclusive(int $netAmount, int $rateBasisPoints): int
+    {
+        if ($netAmount <= 0 || $rateBasisPoints <= 0) {
+            return 0;
+        }
+
+        return (int) round($netAmount * $rateBasisPoints / 10000);
+    }
+}
*** Add File: /Users/fabianwesner/Herd/shop/app/Services/Tax/StripeTaxProvider.php
+<?php
+
+namespace App\Services\Tax;
+
+use App\Contracts\TaxProvider;
+use App\Models\TaxSettings;
+use App\ValueObjects\TaxResult;
+use RuntimeException;
+
+class StripeTaxProvider implements TaxProvider
+{
+    /**
+     * @param  array<int, int>  $amounts
+     * @param  array<string, mixed>  $address
+     */
+    public function calculate(array $amounts, int $shippingAmount, TaxSettings $settings, array $address): TaxResult
+    {
+        if (data_get($settings->config_json, 'fallback') === 'block') {
+            throw new RuntimeException('Stripe Tax provider is not configured.');
+        }
+
+        return new TaxResult([], 0, 0);
+    }
+}
*** Add File: /Users/fabianwesner/Herd/shop/app/Services/TaxCalculator.php
+<?php
+
+namespace App\Services;
+
+use App\Enums\TaxMode;
+use App\Models\TaxSettings;
+use App\Services\Tax\ManualTaxProvider;
+use App\Services\Tax\StripeTaxProvider;
+use App\ValueObjects\TaxResult;
+
+class TaxCalculator
+{
+    public function __construct(
+        private readonly ManualTaxProvider $manual,
+        private readonly StripeTaxProvider $stripe,
+    ) {}
+
+    /**
+     * @param  array<string, mixed>  $address
+     */
+    public function calculate(int $amount, TaxSettings $settings, array $address): TaxResult
+    {
+        return $this->calculateForAmounts([$amount], 0, $settings, $address);
+    }
+
+    /**
+     * @param  array<int, int>  $amounts
+     * @param  array<string, mixed>  $address
+     */
+    public function calculateForAmounts(array $amounts, int $shippingAmount, TaxSettings $settings, array $address): TaxResult
+    {
+        if ($settings->mode === TaxMode::Provider && $settings->provider === 'stripe_tax') {
+            return $this->stripe->calculate($amounts, $shippingAmount, $settings, $address);
+        }
+
+        return $this->manual->calculate($amounts, $shippingAmount, $settings, $address);
+    }
+
+    public function extractInclusive(int $grossAmount, int $rateBasisPoints): int
+    {
+        return $this->manual->extractInclusive($grossAmount, $rateBasisPoints);
+    }
+
+    public function addExclusive(int $netAmount, int $rateBasisPoints): int
+    {
+        return $this->manual->addExclusive($netAmount, $rateBasisPoints);
+    }
+}
*** End Patch