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


Success. Updated the following files:
A /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.');
        }
    }
}


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


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


Success. Updated the following files:
A /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();
        });
    }
}


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


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


Success. Updated the following files:
A /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();
    }
}


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


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


Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/app/Services/Tax/ManualTaxProvider.php
A /Users/fabianwesner/Herd/shop/app/Services/Tax/StripeTaxProvider.php
A /Users/fabianwesner/Herd/shop/app/Services/TaxCalculator.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);
    }
}

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

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


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


Input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/app/Services/PricingEngine.php
+<?php
+
+namespace App\Services;
+
+use App\Exceptions\InvalidCheckoutTransitionException;
+use App\Models\CartLine;
+use App\Models\Checkout;
+use App\Models\Discount;
+use App\Models\ShippingRate;
+use App\Models\TaxSettings;
+use App\ValueObjects\DiscountResult;
+use App\ValueObjects\PricingResult;
+use Illuminate\Support\Facades\DB;
+
+class PricingEngine
+{
+    public function __construct(
+        private readonly DiscountService $discounts,
+        private readonly ShippingCalculator $shipping,
+        private readonly TaxCalculator $taxes,
+    ) {}
+
+    public function calculate(Checkout $checkout): PricingResult
+    {
+        return DB::transaction(function () use ($checkout): PricingResult {
+            $checkout = Checkout::withoutGlobalScopes()
+                ->with(['cart.lines'])
+                ->whereKey($checkout->getKey())
+                ->firstOrFail();
+            $cart = $checkout->cart;
+
+            $this->resetLineAmounts($checkout);
+
+            $lines = CartLine::withoutGlobalScopes()
+                ->where('cart_id', $cart->getKey())
+                ->orderBy('id')
+                ->get();
+            $subtotal = $lines->sum('line_subtotal_amount');
+            $discountResult = new DiscountResult(0, []);
+
+            if ($checkout->discount_code) {
+                $discount = $this->discounts->validate($checkout->discount_code, $checkout->store, $cart);
+                $discountResult = $this->discounts->applyToCart($cart, $discount);
+                $lines = CartLine::withoutGlobalScopes()
+                    ->where('cart_id', $cart->getKey())
+                    ->orderBy('id')
+                    ->get();
+            }
+
+            $shippingAmount = $this->shippingAmount($checkout, $discountResult);
+            $taxSettings = $this->taxSettings($checkout);
+            $taxResult = $this->taxes->calculateForAmounts(
+                $lines->pluck('line_total_amount')->map(fn (int $amount): int => $amount)->all(),
+                $shippingAmount,
+                $taxSettings,
+                $checkout->shipping_address_json ?? [],
+            );
+            $discountedSubtotal = $subtotal - $discountResult->amount;
+            $total = $taxSettings->prices_include_tax
+                ? $discountedSubtotal + $shippingAmount
+                : $discountedSubtotal + $shippingAmount + $taxResult->totalAmount;
+
+            $result = new PricingResult(
+                subtotal: $subtotal,
+                discount: $discountResult->amount,
+                shipping: $shippingAmount,
+                taxLines: $taxResult->taxLines,
+                taxTotal: $taxResult->totalAmount,
+                total: $total,
+                currency: $cart->currency,
+            );
+
+            $checkout->forceFill([
+                'tax_provider_snapshot_json' => $taxResult->toArray(),
+                'totals_json' => $result->toArray(),
+            ])->save();
+
+            return $result;
+        });
+    }
+
+    private function resetLineAmounts(Checkout $checkout): void
+    {
+        CartLine::withoutGlobalScopes()
+            ->where('cart_id', $checkout->cart_id)
+            ->get()
+            ->each(function (CartLine $line): void {
+                $subtotal = $line->unit_price_amount * $line->quantity;
+
+                $line->forceFill([
+                    'line_subtotal_amount' => $subtotal,
+                    'line_discount_amount' => 0,
+                    'line_total_amount' => $subtotal,
+                ])->save();
+            });
+    }
+
+    private function shippingAmount(Checkout $checkout, DiscountResult $discountResult): int
+    {
+        if (! $this->shipping->requiresShipping($checkout->cart)) {
+            return 0;
+        }
+
+        if ($checkout->shipping_method_id === null) {
+            return 0;
+        }
+
+        $rate = ShippingRate::withoutGlobalScopes()->find($checkout->shipping_method_id);
+
+        if (! $rate instanceof ShippingRate) {
+            throw InvalidCheckoutTransitionException::because('Shipping rate is not available.');
+        }
+
+        $amount = $this->shipping->calculate($rate, $checkout->cart);
+
+        if ($amount === null) {
+            throw InvalidCheckoutTransitionException::because('Shipping rate is not available for this cart.');
+        }
+
+        return $discountResult->freeShipping ? 0 : $amount;
+    }
+
+    private function taxSettings(Checkout $checkout): TaxSettings
+    {
+        return TaxSettings::withoutGlobalScopes()
+            ->where('store_id', $checkout->store_id)
+            ->first() ?? new TaxSettings([
+                'store_id' => $checkout->store_id,
+                'mode' => 'manual',
+                'provider' => 'none',
+                'prices_include_tax' => false,
+                'config_json' => [
+                    'default_rate_bps' => 0,
+                    'shipping_taxable' => false,
+                ],
+            ]);
+    }
+}
*** End Patch


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

<?php

namespace App\Services;

use App\Exceptions\InvalidCheckoutTransitionException;
use App\Models\CartLine;
use App\Models\Checkout;
use App\Models\Discount;
use App\Models\ShippingRate;
use App\Models\TaxSettings;
use App\ValueObjects\DiscountResult;
use App\ValueObjects\PricingResult;
use Illuminate\Support\Facades\DB;

class PricingEngine
{
    public function __construct(
        private readonly DiscountService $discounts,
        private readonly ShippingCalculator $shipping,
        private readonly TaxCalculator $taxes,
    ) {}

    public function calculate(Checkout $checkout): PricingResult
    {
        return DB::transaction(function () use ($checkout): PricingResult {
            $checkout = Checkout::withoutGlobalScopes()
                ->with(['cart.lines'])
                ->whereKey($checkout->getKey())
                ->firstOrFail();
            $cart = $checkout->cart;

            $this->resetLineAmounts($checkout);

            $lines = CartLine::withoutGlobalScopes()
                ->where('cart_id', $cart->getKey())
                ->orderBy('id')
                ->get();
            $subtotal = $lines->sum('line_subtotal_amount');
            $discountResult = new DiscountResult(0, []);

            if ($checkout->discount_code) {
                $discount = $this->discounts->validate($checkout->discount_code, $checkout->store, $cart);
                $discountResult = $this->discounts->applyToCart($cart, $discount);
                $lines = CartLine::withoutGlobalScopes()
                    ->where('cart_id', $cart->getKey())
                    ->orderBy('id')
                    ->get();
            }

            $shippingAmount = $this->shippingAmount($checkout, $discountResult);
            $taxSettings = $this->taxSettings($checkout);
            $taxResult = $this->taxes->calculateForAmounts(
                $lines->pluck('line_total_amount')->map(fn (int $amount): int => $amount)->all(),
                $shippingAmount,
                $taxSettings,
                $checkout->shipping_address_json ?? [],
            );
            $discountedSubtotal = $subtotal - $discountResult->amount;
            $total = $taxSettings->prices_include_tax
                ? $discountedSubtotal + $shippingAmount
                : $discountedSubtotal + $shippingAmount + $taxResult->totalAmount;

            $result = new PricingResult(
                subtotal: $subtotal,
                discount: $discountResult->amount,
                shipping: $shippingAmount,
                taxLines: $taxResult->taxLines,
                taxTotal: $taxResult->totalAmount,
                total: $total,
                currency: $cart->currency,
            );

            $checkout->forceFill([
                'tax_provider_snapshot_json' => $taxResult->toArray(),
                'totals_json' => $result->toArray(),
            ])->save();

            return $result;
        });
    }

    private function resetLineAmounts(Checkout $checkout): void
    {
        CartLine::withoutGlobalScopes()
            ->where('cart_id', $checkout->cart_id)
            ->get()
            ->each(function (CartLine $line): void {
                $subtotal = $line->unit_price_amount * $line->quantity;

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

    private function shippingAmount(Checkout $checkout, DiscountResult $discountResult): int
    {
        if (! $this->shipping->requiresShipping($checkout->cart)) {
            return 0;
        }

        if ($checkout->shipping_method_id === null) {
            return 0;
        }

        $rate = ShippingRate::withoutGlobalScopes()->find($checkout->shipping_method_id);

        if (! $rate instanceof ShippingRate) {
            throw InvalidCheckoutTransitionException::because('Shipping rate is not available.');
        }

        $amount = $this->shipping->calculate($rate, $checkout->cart);

        if ($amount === null) {
            throw InvalidCheckoutTransitionException::because('Shipping rate is not available for this cart.');
        }

        return $discountResult->freeShipping ? 0 : $amount;
    }

    private function taxSettings(Checkout $checkout): TaxSettings
    {
        return TaxSettings::withoutGlobalScopes()
            ->where('store_id', $checkout->store_id)
            ->first() ?? new TaxSettings([
                'store_id' => $checkout->store_id,
                'mode' => 'manual',
                'provider' => 'none',
                'prices_include_tax' => false,
                'config_json' => [
                    'default_rate_bps' => 0,
                    'shipping_taxable' => false,
                ],
            ]);
    }
}


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


Input:
*** Begin Patch
*** Add File: /Users/fabianwesner/Herd/shop/app/Services/CheckoutService.php
+<?php
+
+namespace App\Services;
+
+use App\Enums\CartStatus;
+use App\Enums\CheckoutStatus;
+use App\Exceptions\InvalidCheckoutTransitionException;
+use App\Exceptions\UnserviceableShippingAddressException;
+use App\Models\Cart;
+use App\Models\CartLine;
+use App\Models\Checkout;
+use App\Models\Customer;
+use App\Models\InventoryItem;
+use App\Models\ProductVariant;
+use App\Models\ShippingRate;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+use RuntimeException;
+
+class CheckoutService
+{
+    public function __construct(
+        private readonly InventoryService $inventory,
+        private readonly ShippingCalculator $shipping,
+        private readonly PricingEngine $pricing,
+    ) {}
+
+    public function createFromCart(Cart $cart, ?Customer $customer = null): Checkout
+    {
+        $cart = Cart::withoutGlobalScopes()->findOrFail($cart->getKey());
+
+        if ($cart->status !== CartStatus::Active) {
+            throw InvalidCheckoutTransitionException::because('Checkout can only start from an active cart.');
+        }
+
+        if (! CartLine::withoutGlobalScopes()->where('cart_id', $cart->getKey())->exists()) {
+            throw InvalidCheckoutTransitionException::because('Checkout cannot start from an empty cart.');
+        }
+
+        return Checkout::withoutGlobalScopes()->create([
+            'store_id' => $cart->store_id,
+            'cart_id' => $cart->getKey(),
+            'customer_id' => $customer?->getKey() ?? $cart->customer_id,
+            'status' => CheckoutStatus::Started,
+        ]);
+    }
+
+    /**
+     * @param  array<string, mixed>  $addressData
+     */
+    public function setAddress(Checkout $checkout, array $addressData): Checkout
+    {
+        return DB::transaction(function () use ($checkout, $addressData): Checkout {
+            $checkout = $this->freshCheckout($checkout);
+            $this->assertStatus($checkout, [CheckoutStatus::Started, CheckoutStatus::Addressed]);
+
+            $email = (string) data_get($addressData, 'email');
+            $shippingAddress = data_get($addressData, 'shipping_address', $addressData);
+
+            Validator::make([
+                'email' => $email,
+                'shipping_address' => $shippingAddress,
+            ], [
+                'email' => ['required', 'email'],
+                'shipping_address.first_name' => ['required', 'string'],
+                'shipping_address.last_name' => ['required', 'string'],
+                'shipping_address.address1' => ['required', 'string'],
+                'shipping_address.city' => ['required', 'string'],
+                'shipping_address.country' => ['required', 'string', 'size:2'],
+                'shipping_address.postal_code' => ['required', 'string'],
+            ])->validate();
+
+            $checkout->forceFill([
+                'email' => $email,
+                'shipping_address_json' => $shippingAddress,
+                'billing_address_json' => data_get($addressData, 'billing_address', $shippingAddress),
+                'status' => CheckoutStatus::Addressed,
+            ])->save();
+
+            $this->pricing->calculate($checkout);
+
+            return $checkout->refresh();
+        });
+    }
+
+    public function setShippingMethod(Checkout $checkout, ?int $shippingRateId): Checkout
+    {
+        return DB::transaction(function () use ($checkout, $shippingRateId): Checkout {
+            $checkout = $this->freshCheckout($checkout);
+            $this->assertStatus($checkout, [CheckoutStatus::Addressed, CheckoutStatus::ShippingSelected]);
+
+            if (! $this->shipping->requiresShipping($checkout->cart)) {
+                $checkout->forceFill([
+                    'shipping_method_id' => null,
+                    'status' => CheckoutStatus::ShippingSelected,
+                ])->save();
+
+                $this->pricing->calculate($checkout);
+
+                return $checkout->refresh();
+            }
+
+            $availableRates = $this->shipping->getAvailableRates($checkout->store, $checkout->shipping_address_json ?? []);
+
+            if ($availableRates->isEmpty()) {
+                throw UnserviceableShippingAddressException::forAddress();
+            }
+
+            $rate = $availableRates->firstWhere('id', $shippingRateId);
+
+            if (! $rate instanceof ShippingRate) {
+                throw InvalidCheckoutTransitionException::because('Selected shipping rate is not available for this address.');
+            }
+
+            $checkout->forceFill([
+                'shipping_method_id' => $rate->getKey(),
+                'status' => CheckoutStatus::ShippingSelected,
+            ])->save();
+
+            $this->pricing->calculate($checkout);
+
+            return $checkout->refresh();
+        });
+    }
+
+    public function selectPaymentMethod(Checkout $checkout, string $paymentMethod): Checkout
+    {
+        return DB::transaction(function () use ($checkout, $paymentMethod): Checkout {
+            $checkout = $this->freshCheckout($checkout);
+
+            if ($checkout->status === CheckoutStatus::PaymentSelected) {
+                return $checkout;
+            }
+
+            $this->assertStatus($checkout, [CheckoutStatus::ShippingSelected]);
+
+            if (! in_array($paymentMethod, ['credit_card', 'paypal', 'bank_transfer'], true)) {
+                throw InvalidCheckoutTransitionException::because('Payment method is invalid.');
+            }
+
+            if ($checkout->totals_json === null) {
+                $this->pricing->calculate($checkout);
+            }
+
+            CartLine::withoutGlobalScopes()
+                ->where('cart_id', $checkout->cart_id)
+                ->get()
+                ->each(function (CartLine $line): void {
+                    $inventory = InventoryItem::withoutGlobalScopes()
+                        ->where('variant_id', $line->variant_id)
+                        ->firstOrFail();
+
+                    $this->inventory->reserve($inventory, $line->quantity);
+                });
+
+            $checkout->forceFill([
+                'payment_method' => $paymentMethod,
+                'status' => CheckoutStatus::PaymentSelected,
+                'expires_at' => now()->addDay(),
+            ])->save();
+
+            return $checkout->refresh();
+        });
+    }
+
+    public function expireCheckout(Checkout $checkout): Checkout
+    {
+        return DB::transaction(function () use ($checkout): Checkout {
+            $checkout = $this->freshCheckout($checkout);
+
+            if (in_array($checkout->status, [CheckoutStatus::Completed, CheckoutStatus::Expired], true)) {
+                return $checkout;
+            }
+
+            if ($checkout->status === CheckoutStatus::PaymentSelected) {
+                CartLine::withoutGlobalScopes()
+                    ->where('cart_id', $checkout->cart_id)
+                    ->get()
+                    ->each(function (CartLine $line): void {
+                        $inventory = InventoryItem::withoutGlobalScopes()
+                            ->where('variant_id', $line->variant_id)
+                            ->firstOrFail();
+
+                        $this->inventory->release($inventory, $line->quantity);
+                    });
+            }
+
+            $checkout->forceFill([
+                'status' => CheckoutStatus::Expired,
+            ])->save();
+
+            return $checkout->refresh();
+        });
+    }
+
+    public function completeCheckout(Checkout $checkout, array $paymentMethodData = []): never
+    {
+        throw new RuntimeException('Order creation is implemented in the payments and orders phase.');
+    }
+
+    private function freshCheckout(Checkout $checkout): Checkout
+    {
+        return Checkout::withoutGlobalScopes()
+            ->with(['cart', 'store'])
+            ->whereKey($checkout->getKey())
+            ->lockForUpdate()
+            ->firstOrFail();
+    }
+
+    /**
+     * @param  array<int, CheckoutStatus>  $allowed
+     */
+    private function assertStatus(Checkout $checkout, array $allowed): void
+    {
+        if (! in_array($checkout->status, $allowed, true)) {
+            throw InvalidCheckoutTransitionException::because("Checkout cannot transition from {$checkout->status->value}.");
+        }
+    }
+}
*** End Patch


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

<?php

namespace App\Services;

use App\Enums\CartStatus;
use App\Enums\CheckoutStatus;
use App\Exceptions\InvalidCheckoutTransitionException;
use App\Exceptions\UnserviceableShippingAddressException;
use App\Models\Cart;
use App\Models\CartLine;
use App\Models\Checkout;
use App\Models\Customer;
use App\Models\InventoryItem;
use App\Models\ProductVariant;
use App\Models\ShippingRate;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use RuntimeException;

class CheckoutService
{
    public function __construct(
        private readonly InventoryService $inventory,
        private readonly ShippingCalculator $shipping,
        private readonly PricingEngine $pricing,
    ) {}

    public function createFromCart(Cart $cart, ?Customer $customer = null): Checkout
    {
        $cart = Cart::withoutGlobalScopes()->findOrFail($cart->getKey());

        if ($cart->status !== CartStatus::Active) {
            throw InvalidCheckoutTransitionException::because('Checkout can only start from an active cart.');
        }

        if (! CartLine::withoutGlobalScopes()->where('cart_id', $cart->getKey())->exists()) {
            throw InvalidCheckoutTransitionException::because('Checkout cannot start from an empty cart.');
        }

        return Checkout::withoutGlobalScopes()->create([
            'store_id' => $cart->store_id,
            'cart_id' => $cart->getKey(),
            'customer_id' => $customer?->getKey() ?? $cart->customer_id,
            'status' => CheckoutStatus::Started,
        ]);
    }

    /**
     * @param  array<string, mixed>  $addressData
     */
    public function setAddress(Checkout $checkout, array $addressData): Checkout
    {
        return DB::transaction(function () use ($checkout, $addressData): Checkout {
            $checkout = $this->freshCheckout($checkout);
            $this->assertStatus($checkout, [CheckoutStatus::Started, CheckoutStatus::Addressed]);

            $email = (string) data_get($addressData, 'email');
            $shippingAddress = data_get($addressData, 'shipping_address', $addressData);

            Validator::make([
                'email' => $email,
                'shipping_address' => $shippingAddress,
            ], [
                'email' => ['required', 'email'],
                'shipping_address.first_name' => ['required', 'string'],
                'shipping_address.last_name' => ['required', 'string'],
                'shipping_address.address1' => ['required', 'string'],
                'shipping_address.city' => ['required', 'string'],
                'shipping_address.country' => ['required', 'string', 'size:2'],
                'shipping_address.postal_code' => ['required', 'string'],
            ])->validate();

            $checkout->forceFill([
                'email' => $email,
                'shipping_address_json' => $shippingAddress,
                'billing_address_json' => data_get($addressData, 'billing_address', $shippingAddress),
                'status' => CheckoutStatus::Addressed,
            ])->save();

            $this->pricing->calculate($checkout);

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

    public function setShippingMethod(Checkout $checkout, ?int $shippingRateId): Checkout
    {
        return DB::transaction(function () use ($checkout, $shippingRateId): Checkout {
            $checkout = $this->freshCheckout($checkout);
            $this->assertStatus($checkout, [CheckoutStatus::Addressed, CheckoutStatus::ShippingSelected]);

            if (! $this->shipping->requiresShipping($checkout->cart)) {
                $checkout->forceFill([
                    'shipping_method_id' => null,
                    'status' => CheckoutStatus::ShippingSelected,
                ])->save();

                $this->pricing->calculate($checkout);

                return $checkout->refresh();
            }

            $availableRates = $this->shipping->getAvailableRates($checkout->store, $checkout->shipping_address_json ?? []);

            if ($availableRates->isEmpty()) {
                throw UnserviceableShippingAddressException::forAddress();
            }

            $rate = $availableRates->firstWhere('id', $shippingRateId);

            if (! $rate instanceof ShippingRate) {
                throw InvalidCheckoutTransitionException::because('Selected shipping rate is not available for this address.');
            }

            $checkout->forceFill([
                'shipping_method_id' => $rate->getKey(),
                'status' => CheckoutStatus::ShippingSelected,
            ])->save();

            $this->pricing->calculate($checkout);

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

    public function selectPaymentMethod(Checkout $checkout, string $paymentMethod): Checkout
    {
        return DB::transaction(function () use ($checkout, $paymentMethod): Checkout {
            $checkout = $this->freshCheckout($checkout);

            if ($checkout->status === CheckoutStatus::PaymentSelected) {
                return $checkout;
            }

            $this->assertStatus($checkout, [CheckoutStatus::ShippingSelected]);

            if (! in_array($paymentMethod, ['credit_card', 'paypal', 'bank_transfer'], true)) {
                throw InvalidCheckoutTransitionException::because('Payment method is invalid.');
            }

            if ($checkout->totals_json === null) {
                $this->pricing->calculate($checkout);
            }

            CartLine::withoutGlobalScopes()
                ->where('cart_id', $checkout->cart_id)
                ->get()
                ->each(function (CartLine $line): void {
                    $inventory = InventoryItem::withoutGlobalScopes()
                        ->where('variant_id', $line->variant_id)
                        ->firstOrFail();

                    $this->inventory->reserve($inventory, $line->quantity);
                });

            $checkout->forceFill([
                'payment_method' => $paymentMethod,
                'status' => CheckoutStatus::PaymentSelected,
                'expires_at' => now()->addDay(),
            ])->save();

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

    public function expireCheckout(Checkout $checkout): Checkout
    {
        return DB::transaction(function () use ($checkout): Checkout {
            $checkout = $this->freshCheckout($checkout);

            if (in_array($checkout->status, [CheckoutStatus::Completed, CheckoutStatus::Expired], true)) {
                return $checkout;
            }

            if ($checkout->status === CheckoutStatus::PaymentSelected) {
                CartLine::withoutGlobalScopes()
                    ->where('cart_id', $checkout->cart_id)
                    ->get()
                    ->each(function (CartLine $line): void {
                        $inventory = InventoryItem::withoutGlobalScopes()
                            ->where('variant_id', $line->variant_id)
                            ->firstOrFail();

                        $this->inventory->release($inventory, $line->quantity);
                    });
            }

            $checkout->forceFill([
                'status' => CheckoutStatus::Expired,
            ])->save();

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

    public function completeCheckout(Checkout $checkout, array $paymentMethodData = []): never
    {
        throw new RuntimeException('Order creation is implemented in the payments and orders phase.');
    }

    private function freshCheckout(Checkout $checkout): Checkout
    {
        return Checkout::withoutGlobalScopes()
            ->with(['cart', 'store'])
            ->whereKey($checkout->getKey())
            ->lockForUpdate()
            ->firstOrFail();
    }

    /**
     * @param  array<int, CheckoutStatus>  $allowed
     */
    private function assertStatus(Checkout $checkout, array $allowed): void
    {
        if (! in_array($checkout->status, $allowed, true)) {
            throw InvalidCheckoutTransitionException::because("Checkout cannot transition from {$checkout->status->value}.");
        }
    }
}


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


Input:
*** Begin Patch
*** Delete File: /Users/fabianwesner/Herd/shop/app/Jobs/ExpireAbandonedCheckouts.php
*** Add File: /Users/fabianwesner/Herd/shop/app/Jobs/ExpireAbandonedCheckouts.php
+<?php
+
+namespace App\Jobs;
+
+use App\Enums\CheckoutStatus;
+use App\Models\Checkout;
+use App\Services\CheckoutService;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Queue\Queueable;
+
+class ExpireAbandonedCheckouts implements ShouldQueue
+{
+    use Queueable;
+
+    /**
+     * Execute the job.
+     */
+    public function handle(CheckoutService $checkouts): void
+    {
+        Checkout::withoutGlobalScopes()
+            ->whereNotIn('status', [CheckoutStatus::Completed->value, CheckoutStatus::Expired->value])
+            ->where(function ($query): void {
+                $query
+                    ->where('expires_at', '<', now())
+                    ->orWhere(function ($query): void {
+                        $query
+                            ->whereNull('expires_at')
+                            ->where('updated_at', '<', now()->subDay());
+                    });
+            })
+            ->orderBy('id')
+            ->get()
+            ->each(fn (Checkout $checkout): Checkout => $checkouts->expireCheckout($checkout));
+    }
+}
*** Delete File: /Users/fabianwesner/Herd/shop/app/Jobs/CleanupAbandonedCarts.php
*** Add File: /Users/fabianwesner/Herd/shop/app/Jobs/CleanupAbandonedCarts.php
+<?php
+
+namespace App\Jobs;
+
+use App\Enums\CartStatus;
+use App\Enums\CheckoutStatus;
+use App\Models\Cart;
+use App\Models\Checkout;
+use App\Services\CheckoutService;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Queue\Queueable;
+
+class CleanupAbandonedCarts implements ShouldQueue
+{
+    use Queueable;
+
+    /**
+     * Execute the job.
+     */
+    public function handle(CheckoutService $checkouts): void
+    {
+        Cart::withoutGlobalScopes()
+            ->where('status', CartStatus::Active)
+            ->where('updated_at', '<', now()->subDays(14))
+            ->orderBy('id')
+            ->get()
+            ->each(function (Cart $cart) use ($checkouts): void {
+                Checkout::withoutGlobalScopes()
+                    ->where('cart_id', $cart->getKey())
+                    ->whereNotIn('status', [CheckoutStatus::Completed->value, CheckoutStatus::Expired->value])
+                    ->get()
+                    ->each(fn (Checkout $checkout): Checkout => $checkouts->expireCheckout($checkout));
+
+                $cart->forceFill(['status' => CartStatus::Abandoned])->save();
+            });
+    }
+}
*** Update File: /Users/fabianwesner/Herd/shop/routes/console.php
@@
 <?php

+use App\Jobs\CleanupAbandonedCarts;
+use App\Jobs\ExpireAbandonedCheckouts;
 use Illuminate\Foundation\Inspiring;
 use Illuminate\Support\Facades\Artisan;
+use Illuminate\Support\Facades\Schedule;
@@
 Artisan::command('inspire', function () {
     $this->comment(Inspiring::quote());
 })->purpose('Display an inspiring quote');
+
+Schedule::job(new ExpireAbandonedCheckouts)->everyFifteenMinutes();
+Schedule::job(new CleanupAbandonedCarts)->daily();
*** End Patch


Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/app/Jobs/ExpireAbandonedCheckouts.php
A /Users/fabianwesner/Herd/shop/app/Jobs/CleanupAbandonedCarts.php
M /Users/fabianwesner/Herd/shop/routes/console.php
D /Users/fabianwesner/Herd/shop/app/Jobs/ExpireAbandonedCheckouts.php
D /Users/fabianwesner/Herd/shop/app/Jobs/CleanupAbandonedCarts.php

@@ -2,4 +2,7 @@

+use App\Jobs\CleanupAbandonedCarts;
+use App\Jobs\ExpireAbandonedCheckouts;
 use Illuminate\Foundation\Inspiring;
 use Illuminate\Support\Facades\Artisan;
+use Illuminate\Support\Facades\Schedule;

@@ -8 +11,4 @@
 })->purpose('Display an inspiring quote');
+
+Schedule::job(new ExpireAbandonedCheckouts)->everyFifteenMinutes();
+Schedule::job(new CleanupAbandonedCarts)->daily();

<?php

namespace App\Jobs;

use App\Enums\CheckoutStatus;
use App\Models\Checkout;
use App\Services\CheckoutService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class ExpireAbandonedCheckouts implements ShouldQueue
{
    use Queueable;

    /**
     * Execute the job.
     */
    public function handle(CheckoutService $checkouts): void
    {
        Checkout::withoutGlobalScopes()
            ->whereNotIn('status', [CheckoutStatus::Completed->value, CheckoutStatus::Expired->value])
            ->where(function ($query): void {
                $query
                    ->where('expires_at', '<', now())
                    ->orWhere(function ($query): void {
                        $query
                            ->whereNull('expires_at')
                            ->where('updated_at', '<', now()->subDay());
                    });
            })
            ->orderBy('id')
            ->get()
            ->each(fn (Checkout $checkout): Checkout => $checkouts->expireCheckout($checkout));
    }
}

<?php

namespace App\Jobs;

use App\Enums\CartStatus;
use App\Enums\CheckoutStatus;
use App\Models\Cart;
use App\Models\Checkout;
use App\Services\CheckoutService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class CleanupAbandonedCarts implements ShouldQueue
{
    use Queueable;

    /**
     * Execute the job.
     */
    public function handle(CheckoutService $checkouts): void
    {
        Cart::withoutGlobalScopes()
            ->where('status', CartStatus::Active)
            ->where('updated_at', '<', now()->subDays(14))
            ->orderBy('id')
            ->get()
            ->each(function (Cart $cart) use ($checkouts): void {
                Checkout::withoutGlobalScopes()
                    ->where('cart_id', $cart->getKey())
                    ->whereNotIn('status', [CheckoutStatus::Completed->value, CheckoutStatus::Expired->value])
                    ->get()
                    ->each(fn (Checkout $checkout): Checkout => $checkouts->expireCheckout($checkout));

                $cart->forceFill(['status' => CartStatus::Abandoned])->save();
            });
    }
}


Output: {"output":"Success. Updated the following files:\nA /Users/fabianwesner/Herd/shop/app/Jobs/ExpireAbandonedCheckouts.php\nA /Users/fabianwesner/Herd/shop/app/Jobs/CleanupAbandonedCarts.php\nM /Users/fabianwesner/Herd/shop/routes/console.php\nD /Users/fabianwesner/Herd/shop/app/Jobs/ExpireAbandonedCheckouts.php\nD /Users/fabianwesner/Herd/shop/app/Jobs/CleanupAbandonedCarts.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


The cart, discount, shipping, tax, pricing, checkout, and cleanup service classes are in. I’m adding deterministic Phase 4 seed data now for tax settings, shipping rates, and demo discounts.


Input:
*** Begin Patch
*** Delete File: /Users/fabianwesner/Herd/shop/database/seeders/TaxSettingsSeeder.php
*** Add File: /Users/fabianwesner/Herd/shop/database/seeders/TaxSettingsSeeder.php
+<?php
+
+namespace Database\Seeders;
+
+use App\Models\Store;
+use App\Models\TaxSettings;
+use Illuminate\Database\Seeder;
+
+class TaxSettingsSeeder extends Seeder
+{
+    /**
+     * Run the database seeds.
+     */
+    public function run(): void
+    {
+        Store::query()->get()->each(function (Store $store): void {
+            TaxSettings::withoutGlobalScopes()->updateOrCreate(
+                ['store_id' => $store->getKey()],
+                [
+                    'mode' => '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'],
+                            ['country' => 'AT', 'rate_bps' => 2000, 'name' => 'VAT'],
+                            ['country' => 'CH', 'rate_bps' => 770, 'name' => 'VAT'],
+                        ],
+                    ],
+                ],
+            );
+        });
+    }
+}
*** Delete File: /Users/fabianwesner/Herd/shop/database/seeders/ShippingZoneSeeder.php
*** Add File: /Users/fabianwesner/Herd/shop/database/seeders/ShippingZoneSeeder.php
+<?php
+
+namespace Database\Seeders;
+
+use App\Models\ShippingZone;
+use App\Models\Store;
+use Illuminate\Database\Seeder;
+
+class ShippingZoneSeeder extends Seeder
+{
+    /**
+     * Run the database seeds.
+     */
+    public function run(): void
+    {
+        Store::query()->get()->each(function (Store $store): void {
+            ShippingZone::withoutGlobalScopes()->updateOrCreate(
+                [
+                    'store_id' => $store->getKey(),
+                    'name' => 'DACH',
+                ],
+                [
+                    'countries_json' => ['DE', 'AT', 'CH'],
+                    'regions_json' => [],
+                ],
+            );
+        });
+    }
+}
*** Delete File: /Users/fabianwesner/Herd/shop/database/seeders/ShippingRateSeeder.php
*** Add File: /Users/fabianwesner/Herd/shop/database/seeders/ShippingRateSeeder.php
+<?php
+
+namespace Database\Seeders;
+
+use App\Models\ShippingRate;
+use App\Models\ShippingZone;
+use Illuminate\Database\Seeder;
+
+class ShippingRateSeeder extends Seeder
+{
+    /**
+     * Run the database seeds.
+     */
+    public function run(): void
+    {
+        ShippingZone::withoutGlobalScopes()->get()->each(function (ShippingZone $zone): void {
+            foreach ($this->rates() as $rate) {
+                ShippingRate::withoutGlobalScopes()->updateOrCreate(
+                    [
+                        'zone_id' => $zone->getKey(),
+                        'name' => $rate['name'],
+                    ],
+                    [
+                        'type' => $rate['type'],
+                        'config_json' => $rate['config_json'],
+                        'is_active' => true,
+                    ],
+                );
+            }
+        });
+    }
+
+    /**
+     * @return array<int, array{name: string, type: string, config_json: array<string, mixed>}>
+     */
+    private function rates(): array
+    {
+        return [
+            ['name' => 'Standard Shipping', 'type' => 'flat', 'config_json' => ['amount' => 799]],
+            ['name' => 'Express Shipping', 'type' => 'flat', 'config_json' => ['amount' => 1499]],
+            [
+                'name' => 'Free Shipping Over 75',
+                'type' => 'price',
+                'config_json' => [
+                    'ranges' => [
+                        ['min_amount' => 0, 'max_amount' => 7499, 'amount' => 799],
+                        ['min_amount' => 7500, 'amount' => 0],
+                    ],
+                ],
+            ],
+        ];
+    }
+}
*** Delete File: /Users/fabianwesner/Herd/shop/database/seeders/DiscountSeeder.php
*** Add File: /Users/fabianwesner/Herd/shop/database/seeders/DiscountSeeder.php
+<?php
+
+namespace Database\Seeders;
+
+use App\Models\Discount;
+use App\Models\Store;
+use Illuminate\Database\Seeder;
+
+class DiscountSeeder extends Seeder
+{
+    /**
+     * Run the database seeds.
+     */
+    public function run(): void
+    {
+        Store::query()->get()->each(function (Store $store): void {
+            foreach ($this->discounts() as $discount) {
+                Discount::withoutGlobalScopes()->updateOrCreate(
+                    [
+                        'store_id' => $store->getKey(),
+                        'code' => $discount['code'],
+                    ],
+                    [
+                        'type' => 'code',
+                        'value_type' => $discount['value_type'],
+                        'value_amount' => $discount['value_amount'],
+                        'starts_at' => now()->subDay(),
+                        'ends_at' => now()->addMonth(),
+                        'usage_limit' => null,
+                        'usage_count' => 0,
+                        'rules_json' => $discount['rules_json'],
+                        'status' => 'active',
+                    ],
+                );
+            }
+        });
+    }
+
+    /**
+     * @return array<int, array{code: string, value_type: string, value_amount: int, rules_json: array<string, mixed>}>
+     */
+    private function discounts(): array
+    {
+        return [
+            ['code' => 'SAVE10', 'value_type' => 'percent', 'value_amount' => 10, 'rules_json' => ['customer_eligibility' => 'all']],
+            ['code' => '5OFF', 'value_type' => 'fixed', 'value_amount' => 500, 'rules_json' => ['min_purchase_amount' => 2500, 'customer_eligibility' => 'all']],
+            ['code' => 'FREESHIP', 'value_type' => 'free_shipping', 'value_amount' => 0, 'rules_json' => ['customer_eligibility' => 'all']],
+        ];
+    }
+}
*** Delete File: /Users/fabianwesner/Herd/shop/database/seeders/CartSeeder.php
*** Add File: /Users/fabianwesner/Herd/shop/database/seeders/CartSeeder.php
+<?php
+
+namespace Database\Seeders;
+
+use Illuminate\Database\Seeder;
+
+class CartSeeder extends Seeder
+{
+    /**
+     * Run the database seeds.
+     */
+    public function run(): void
+    {
+        //
+    }
+}
*** Delete File: /Users/fabianwesner/Herd/shop/database/seeders/CartLineSeeder.php
*** Add File: /Users/fabianwesner/Herd/shop/database/seeders/CartLineSeeder.php
+<?php
+
+namespace Database\Seeders;
+
+use Illuminate\Database\Seeder;
+
+class CartLineSeeder extends Seeder
+{
+    /**
+     * Run the database seeds.
+     */
+    public function run(): void
+    {
+        //
+    }
+}
*** Delete File: /Users/fabianwesner/Herd/shop/database/seeders/CheckoutSeeder.php
*** Add File: /Users/fabianwesner/Herd/shop/database/seeders/CheckoutSeeder.php
+<?php
+
+namespace Database\Seeders;
+
+use Illuminate\Database\Seeder;
+
+class CheckoutSeeder extends Seeder
+{
+    /**
+     * Run the database seeds.
+     */
+    public function run(): void
+    {
+        //
+    }
+}
*** Update File: /Users/fabianwesner/Herd/shop/database/seeders/DatabaseSeeder.php
@@
             NavigationMenuSeeder::class,
             NavigationItemSeeder::class,
+            TaxSettingsSeeder::class,
+            ShippingZoneSeeder::class,
+            ShippingRateSeeder::class,
+            DiscountSeeder::class,
             CustomerSeeder::class,
         ]);
     }
 }
*** End Patch