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

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

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

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


status: completed

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


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

type: add

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

status: completed


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


status: completed

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


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

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


status: completed

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


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

type: update

unified_diff:
@@ -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();

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

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


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

phase: commentary


type: message

role: assistant

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


status: completed

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


stdout:
Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/database/seeders/TaxSettingsSeeder.php
A /Users/fabianwesner/Herd/shop/database/seeders/ShippingZoneSeeder.php
A /Users/fabianwesner/Herd/shop/database/seeders/ShippingRateSeeder.php
A /Users/fabianwesner/Herd/shop/database/seeders/DiscountSeeder.php
A /Users/fabianwesner/Herd/shop/database/seeders/CartSeeder.php
A /Users/fabianwesner/Herd/shop/database/seeders/CartLineSeeder.php
A /Users/fabianwesner/Herd/shop/database/seeders/CheckoutSeeder.php
M /Users/fabianwesner/Herd/shop/database/seeders/DatabaseSeeder.php
D /Users/fabianwesner/Herd/shop/database/seeders/TaxSettingsSeeder.php
D /Users/fabianwesner/Herd/shop/database/seeders/ShippingZoneSeeder.php
D /Users/fabianwesner/Herd/shop/database/seeders/ShippingRateSeeder.php
D /Users/fabianwesner/Herd/shop/database/seeders/DiscountSeeder.php
D /Users/fabianwesner/Herd/shop/database/seeders/CartSeeder.php
D /Users/fabianwesner/Herd/shop/database/seeders/CartLineSeeder.php
D /Users/fabianwesner/Herd/shop/database/seeders/CheckoutSeeder.php

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

content:
<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class CartLineSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        //
    }
}

unified_diff:
@@ -28,2 +28,6 @@
             NavigationItemSeeder::class,
+            TaxSettingsSeeder::class,
+            ShippingZoneSeeder::class,
+            ShippingRateSeeder::class,
+            DiscountSeeder::class,
             CustomerSeeder::class,

content:
<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class CheckoutSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        //
    }
}

content:
<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class CartSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        //
    }
}

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

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

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


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/database/factories/CartFactory.php
@@
     public function forCustomer(?Customer $customer = null): static
     {
         return $this->state(fn (array $attributes): array => [
-            'customer_id' => ($customer ?? Customer::factory()->create())->getKey(),
+            'customer_id' => $customer?->getKey() ?? Customer::factory(),
         ]);
     }
*** Update File: /Users/fabianwesner/Herd/shop/database/factories/CheckoutFactory.php
@@
     public function forCustomer(?Customer $customer = null): static
     {
         return $this->state(fn (array $attributes): array => [
-            'customer_id' => ($customer ?? Customer::factory()->create())->getKey(),
+            'customer_id' => $customer?->getKey() ?? Customer::factory(),
         ]);
     }
*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/database/factories/CartFactory.php
M /Users/fabianwesner/Herd/shop/database/factories/CheckoutFactory.php

unified_diff:
@@ -42,3 +42,3 @@
         return $this->state(fn (array $attributes): array => [
-            'customer_id' => ($customer ?? Customer::factory()->create())->getKey(),
+            'customer_id' => $customer?->getKey() ?? Customer::factory(),
         ]);

unified_diff:
@@ -33,3 +33,3 @@
         return $this->state(fn (array $attributes): array => [
-            'customer_id' => ($customer ?? Customer::factory()->create())->getKey(),
+            'customer_id' => $customer?->getKey() ?? Customer::factory(),
         ]);


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/database/factories/CartFactory.php\nM /Users/fabianwesner/Herd/shop/database/factories/CheckoutFactory.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


cmd: sed -n '1,180p' database/factories/ProductFactory.php


cmd: sed -n '1,180p' database/factories/ProductVariantFactory.php


cmd: sed -n '1,120p' database/factories/InventoryItemFactory.php


command: sed -n '1,180p' database/factories/ProductVariantFactory.php

ProductVariantFactory.php

type: read

aggregated_output:
<?php

namespace Database\Factories;

use App\Enums\VariantStatus;
use App\Models\Product;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ProductVariant>
 */
class ProductVariantFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'product_id' => Product::factory(),
            'sku' => 'SKU-'.fake()->unique()->numerify('####').'-'.fake()->lexify('???'),
            'barcode' => fake()->ean13(),
            'price_amount' => fake()->numberBetween(999, 19999),
            'compare_at_amount' => null,
            'currency' => 'EUR',
            'weight_g' => fake()->numberBetween(100, 5000),
            'requires_shipping' => true,
            'is_default' => false,
            'position' => 0,
            'status' => VariantStatus::Active,
        ];
    }

    public function onSale(): static
    {
        return $this->state(fn (array $attributes): array => [
            'compare_at_amount' => fake()->numberBetween(20000, 39999),
            'price_amount' => fake()->numberBetween(9999, 19999),
        ]);
    }

    public function digital(): static
    {
        return $this->state(fn (array $attributes): array => [
            'requires_shipping' => false,
            'weight_g' => 0,
        ]);
    }

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

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


command: sed -n '1,180p' database/factories/ProductFactory.php

aggregated_output:
<?php

namespace Database\Factories;

use App\Enums\ProductStatus;
use App\Models\InventoryItem;
use App\Models\ProductVariant;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Product>
 */
class ProductFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        $title = Str::title(fake()->words(3, true));

        return [
            'store_id' => Store::factory(),
            'title' => $title,
            'handle' => Str::slug($title),
            'status' => ProductStatus::Active,
            'description_html' => '<p>'.fake()->paragraph().'</p>',
            'vendor' => fake()->company(),
            'product_type' => fake()->randomElement(['Shirts', 'Pants', 'Shoes', 'Accessories', 'Electronics', 'Books']),
            'tags' => fake()->randomElements(['new', 'sale', 'trending', 'popular', 'limited'], fake()->numberBetween(1, 3)),
            'published_at' => now(),
        ];
    }

    public function draft(): static
    {
        return $this->state(fn (array $attributes): array => [
            'status' => ProductStatus::Draft,
            'published_at' => null,
        ]);
    }

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

    public function withVariants(int $count): static
    {
        return $this->afterCreating(function ($product) use ($count): void {
            ProductVariant::factory()
                ->count($count)
                ->for($product)
                ->create();
        });
    }

    public function withDefaultVariant(int $price = 2499): static
    {
        return $this->afterCreating(function ($product) use ($price): void {
            $variant = ProductVariant::factory()
                ->default()
                ->for($product)
                ->create([
                    'price_amount' => $price,
                    'currency' => $product->store->default_currency,
                ]);

            InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->update([
                'quantity_on_hand' => 50,
            ]);
        });
    }
}


command: sed -n '1,120p' database/factories/InventoryItemFactory.php

aggregated_output:
<?php

namespace Database\Factories;

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

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\InventoryItem>
 */
class InventoryItemFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'variant_id' => fn (): int => ProductVariant::withoutEvents(
                fn (): int => ProductVariant::factory()->create()->getKey(),
            ),
            'store_id' => fn (array $attributes): mixed => ProductVariant::query()
                ->with('product')
                ->find($attributes['variant_id'])
                ?->product
                ?->store_id ?? Store::factory(),
            'quantity_on_hand' => fake()->numberBetween(0, 100),
            'quantity_reserved' => 0,
            'policy' => InventoryPolicy::Deny,
        ];
    }

    public function outOfStock(): static
    {
        return $this->state(fn (array $attributes): array => [
            'quantity_on_hand' => 0,
            'quantity_reserved' => 0,
        ]);
    }

    public function continuePolicy(): static
    {
        return $this->state(fn (array $attributes): array => [
            'policy' => InventoryPolicy::Continue,
        ]);
    }

    public function lowStock(): static
    {
        return $this->state(fn (array $attributes): array => [
            'quantity_on_hand' => fake()->numberBetween(1, 3),
            'quantity_reserved' => 0,
        ]);
    }
}


output:
Chunk ID: ff1fc4
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 606
Output:
<?php

namespace Database\Factories;

use App\Enums\ProductStatus;
use App\Models\InventoryItem;
use App\Models\ProductVariant;
use App\Models\Store;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Product>
 */
class ProductFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        $title = Str::title(fake()->words(3, true));

        return [
            'store_id' => Store::factory(),
            'title' => $title,
            'handle' => Str::slug($title),
            'status' => ProductStatus::Active,
            'description_html' => '<p>'.fake()->paragraph().'</p>',
            'vendor' => fake()->company(),
            'product_type' => fake()->randomElement(['Shirts', 'Pants', 'Shoes', 'Accessories', 'Electronics', 'Books']),
            'tags' => fake()->randomElements(['new', 'sale', 'trending', 'popular', 'limited'], fake()->numberBetween(1, 3)),
            'published_at' => now(),
        ];
    }

    public function draft(): static
    {
        return $this->state(fn (array $attributes): array => [
            'status' => ProductStatus::Draft,
            'published_at' => null,
        ]);
    }

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

    public function withVariants(int $count): static
    {
        return $this->afterCreating(function ($product) use ($count): void {
            ProductVariant::factory()
                ->count($count)
                ->for($product)
                ->create();
        });
    }

    public function withDefaultVariant(int $price = 2499): static
    {
        return $this->afterCreating(function ($product) use ($price): void {
            $variant = ProductVariant::factory()
                ->default()
                ->for($product)
                ->create([
                    'price_amount' => $price,
                    'currency' => $product->store->default_currency,
                ]);

            InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->update([
                'quantity_on_hand' => 50,
            ]);
        });
    }
}


output:
Chunk ID: f67fd6
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 449
Output:
<?php

namespace Database\Factories;

use App\Enums\VariantStatus;
use App\Models\Product;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ProductVariant>
 */
class ProductVariantFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'product_id' => Product::factory(),
            'sku' => 'SKU-'.fake()->unique()->numerify('####').'-'.fake()->lexify('???'),
            'barcode' => fake()->ean13(),
            'price_amount' => fake()->numberBetween(999, 19999),
            'compare_at_amount' => null,
            'currency' => 'EUR',
            'weight_g' => fake()->numberBetween(100, 5000),
            'requires_shipping' => true,
            'is_default' => false,
            'position' => 0,
            'status' => VariantStatus::Active,
        ];
    }

    public function onSale(): static
    {
        return $this->state(fn (array $attributes): array => [
            'compare_at_amount' => fake()->numberBetween(20000, 39999),
            'price_amount' => fake()->numberBetween(9999, 19999),
        ]);
    }

    public function digital(): static
    {
        return $this->state(fn (array $attributes): array => [
            'requires_shipping' => false,
            'weight_g' => 0,
        ]);
    }

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

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


output:
Chunk ID: 3b9c5d
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 418
Output:
<?php

namespace Database\Factories;

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

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\InventoryItem>
 */
class InventoryItemFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'variant_id' => fn (): int => ProductVariant::withoutEvents(
                fn (): int => ProductVariant::factory()->create()->getKey(),
            ),
            'store_id' => fn (array $attributes): mixed => ProductVariant::query()
                ->with('product')
                ->find($attributes['variant_id'])
                ?->product
                ?->store_id ?? Store::factory(),
            'quantity_on_hand' => fake()->numberBetween(0, 100),
            'quantity_reserved' => 0,
            'policy' => InventoryPolicy::Deny,
        ];
    }

    public function outOfStock(): static
    {
        return $this->state(fn (array $attributes): array => [
            'quantity_on_hand' => 0,
            'quantity_reserved' => 0,
        ]);
    }

    public function continuePolicy(): static
    {
        return $this->state(fn (array $attributes): array => [
            'policy' => InventoryPolicy::Continue,
        ]);
    }

    public function lowStock(): static
    {
        return $this->state(fn (array $attributes): array => [
            'quantity_on_hand' => fake()->numberBetween(1, 3),
            'quantity_reserved' => 0,
        ]);
    }
}


message: The backend services are in place. I’m writing focused Pest coverage for cart mutations, pricing/discount/shipping/tax calculation, and checkout state transitions before running the new migrations.


text: The backend services are in place. I’m writing focused Pest coverage for cart mutations, pricing/discount/shipping/tax calculation, and checkout state transitions before running the new migrations.


status: completed

input:
*** Begin Patch
*** Delete File: /Users/fabianwesner/Herd/shop/tests/Feature/Cart/CartServiceTest.php
*** Add File: /Users/fabianwesner/Herd/shop/tests/Feature/Cart/CartServiceTest.php
+<?php
+
+use App\Enums\CartStatus;
+use App\Enums\InventoryPolicy;
+use App\Exceptions\CartVersionMismatchException;
+use App\Exceptions\InsufficientInventoryException;
+use App\Exceptions\InvalidCartOperationException;
+use App\Models\Cart;
+use App\Models\Customer;
+use App\Models\InventoryItem;
+use App\Models\Product;
+use App\Models\ProductVariant;
+use App\Models\Store;
+use App\Services\CartService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+
+uses(RefreshDatabase::class);
+
+function cartServiceStore(): Store
+{
+    $store = Store::factory()->create();
+    app()->instance('current_store', $store);
+
+    return $store;
+}
+
+function cartServiceVariant(Store $store, int $price = 2500, int $stock = 20, InventoryPolicy $policy = InventoryPolicy::Deny): ProductVariant
+{
+    $product = Product::factory()
+        ->withDefaultVariant($price)
+        ->create(['store_id' => $store->getKey()]);
+    $variant = ProductVariant::withoutGlobalScopes()
+        ->where('product_id', $product->getKey())
+        ->firstOrFail();
+
+    InventoryItem::withoutGlobalScopes()
+        ->where('variant_id', $variant->getKey())
+        ->update([
+            'quantity_on_hand' => $stock,
+            'quantity_reserved' => 0,
+            'policy' => $policy,
+        ]);
+
+    return $variant->refresh();
+}
+
+test('cart service creates carts and mutates lines with version increments and price snapshots', function () {
+    $store = cartServiceStore();
+    $variant = cartServiceVariant($store, price: 2500, stock: 20);
+    $service = app(CartService::class);
+    $cart = $service->create($store);
+
+    expect($cart->store_id)->toBe($store->getKey())
+        ->and($cart->currency)->toBe('EUR')
+        ->and($cart->cart_version)->toBe(1);
+
+    $line = $service->addLine($cart, $variant->getKey(), 2, expectedVersion: 1);
+
+    expect($line->quantity)->toBe(2)
+        ->and($line->unit_price_amount)->toBe(2500)
+        ->and($line->line_subtotal_amount)->toBe(5000)
+        ->and($cart->refresh()->cart_version)->toBe(2);
+
+    $service->addLine($cart, $variant->getKey(), 1, expectedVersion: 2);
+
+    expect(Cart::withoutGlobalScopes()->find($cart->getKey())?->lines()->withoutGlobalScopes()->count())->toBe(1)
+        ->and($line->refresh()->quantity)->toBe(3)
+        ->and($cart->refresh()->cart_version)->toBe(3);
+
+    $variant->forceFill(['price_amount' => 9999])->save();
+    $service->updateLineQuantity($cart, $line->getKey(), 4, expectedVersion: 3);
+
+    expect($line->refresh()->unit_price_amount)->toBe(2500)
+        ->and($line->line_subtotal_amount)->toBe(10000)
+        ->and($cart->refresh()->cart_version)->toBe(4);
+
+    $service->updateLineQuantity($cart, $line->getKey(), 0, expectedVersion: 4);
+
+    expect($cart->refresh()->cart_version)->toBe(5)
+        ->and($cart->lines()->withoutGlobalScopes()->count())->toBe(0);
+});
+
+test('cart service rejects stale versions inactive products and insufficient stock', function () {
+    $store = cartServiceStore();
+    $variant = cartServiceVariant($store, stock: 2);
+    $service = app(CartService::class);
+    $cart = $service->create($store);
+
+    expect(fn () => $service->addLine($cart, $variant->getKey(), 1, expectedVersion: 99))
+        ->toThrow(CartVersionMismatchException::class);
+
+    $draftProduct = Product::factory()
+        ->draft()
+        ->withDefaultVariant()
+        ->create(['store_id' => $store->getKey()]);
+    $draftVariant = ProductVariant::withoutGlobalScopes()
+        ->where('product_id', $draftProduct->getKey())
+        ->firstOrFail();
+
+    expect(fn () => $service->addLine($cart, $draftVariant->getKey(), 1))
+        ->toThrow(InvalidCartOperationException::class);
+
+    expect(fn () => $service->addLine($cart, $variant->getKey(), 5))
+        ->toThrow(InsufficientInventoryException::class);
+});
+
+test('cart service allows oversell for continue policy and merges guest carts into customer carts', function () {
+    $store = cartServiceStore();
+    $firstVariant = cartServiceVariant($store, stock: 0, policy: InventoryPolicy::Continue);
+    $secondVariant = cartServiceVariant($store, stock: 10);
+    $service = app(CartService::class);
+    $guestCart = $service->create($store);
+    $customer = Customer::factory()->create(['store_id' => $store->getKey()]);
+    $customerCart = $service->create($store, $customer);
+
+    $service->addLine($guestCart, $firstVariant->getKey(), 5);
+    $service->addLine($customerCart, $firstVariant->getKey(), 2);
+    $service->addLine($customerCart, $secondVariant->getKey(), 3);
+
+    $merged = $service->mergeOnLogin($guestCart, $customerCart);
+
+    expect($merged->lines()->withoutGlobalScopes()->where('variant_id', $firstVariant->getKey())->first()?->quantity)->toBe(5)
+        ->and($merged->lines()->withoutGlobalScopes()->where('variant_id', $secondVariant->getKey())->first()?->quantity)->toBe(3)
+        ->and($guestCart->refresh()->status)->toBe(CartStatus::Abandoned);
+});
*** End Patch


stdout:
Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/tests/Feature/Cart/CartServiceTest.php
D /Users/fabianwesner/Herd/shop/tests/Feature/Cart/CartServiceTest.php

content:
<?php

use App\Enums\CartStatus;
use App\Enums\InventoryPolicy;
use App\Exceptions\CartVersionMismatchException;
use App\Exceptions\InsufficientInventoryException;
use App\Exceptions\InvalidCartOperationException;
use App\Models\Cart;
use App\Models\Customer;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Services\CartService;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

function cartServiceStore(): Store
{
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    return $store;
}

function cartServiceVariant(Store $store, int $price = 2500, int $stock = 20, InventoryPolicy $policy = InventoryPolicy::Deny): ProductVariant
{
    $product = Product::factory()
        ->withDefaultVariant($price)
        ->create(['store_id' => $store->getKey()]);
    $variant = ProductVariant::withoutGlobalScopes()
        ->where('product_id', $product->getKey())
        ->firstOrFail();

    InventoryItem::withoutGlobalScopes()
        ->where('variant_id', $variant->getKey())
        ->update([
            'quantity_on_hand' => $stock,
            'quantity_reserved' => 0,
            'policy' => $policy,
        ]);

    return $variant->refresh();
}

test('cart service creates carts and mutates lines with version increments and price snapshots', function () {
    $store = cartServiceStore();
    $variant = cartServiceVariant($store, price: 2500, stock: 20);
    $service = app(CartService::class);
    $cart = $service->create($store);

    expect($cart->store_id)->toBe($store->getKey())
        ->and($cart->currency)->toBe('EUR')
        ->and($cart->cart_version)->toBe(1);

    $line = $service->addLine($cart, $variant->getKey(), 2, expectedVersion: 1);

    expect($line->quantity)->toBe(2)
        ->and($line->unit_price_amount)->toBe(2500)
        ->and($line->line_subtotal_amount)->toBe(5000)
        ->and($cart->refresh()->cart_version)->toBe(2);

    $service->addLine($cart, $variant->getKey(), 1, expectedVersion: 2);

    expect(Cart::withoutGlobalScopes()->find($cart->getKey())?->lines()->withoutGlobalScopes()->count())->toBe(1)
        ->and($line->refresh()->quantity)->toBe(3)
        ->and($cart->refresh()->cart_version)->toBe(3);

    $variant->forceFill(['price_amount' => 9999])->save();
    $service->updateLineQuantity($cart, $line->getKey(), 4, expectedVersion: 3);

    expect($line->refresh()->unit_price_amount)->toBe(2500)
        ->and($line->line_subtotal_amount)->toBe(10000)
        ->and($cart->refresh()->cart_version)->toBe(4);

    $service->updateLineQuantity($cart, $line->getKey(), 0, expectedVersion: 4);

    expect($cart->refresh()->cart_version)->toBe(5)
        ->and($cart->lines()->withoutGlobalScopes()->count())->toBe(0);
});

test('cart service rejects stale versions inactive products and insufficient stock', function () {
    $store = cartServiceStore();
    $variant = cartServiceVariant($store, stock: 2);
    $service = app(CartService::class);
    $cart = $service->create($store);

    expect(fn () => $service->addLine($cart, $variant->getKey(), 1, expectedVersion: 99))
        ->toThrow(CartVersionMismatchException::class);

    $draftProduct = Product::factory()
        ->draft()
        ->withDefaultVariant()
        ->create(['store_id' => $store->getKey()]);
    $draftVariant = ProductVariant::withoutGlobalScopes()
        ->where('product_id', $draftProduct->getKey())
        ->firstOrFail();

    expect(fn () => $service->addLine($cart, $draftVariant->getKey(), 1))
        ->toThrow(InvalidCartOperationException::class);

    expect(fn () => $service->addLine($cart, $variant->getKey(), 5))
        ->toThrow(InsufficientInventoryException::class);
});

test('cart service allows oversell for continue policy and merges guest carts into customer carts', function () {
    $store = cartServiceStore();
    $firstVariant = cartServiceVariant($store, stock: 0, policy: InventoryPolicy::Continue);
    $secondVariant = cartServiceVariant($store, stock: 10);
    $service = app(CartService::class);
    $guestCart = $service->create($store);
    $customer = Customer::factory()->create(['store_id' => $store->getKey()]);
    $customerCart = $service->create($store, $customer);

    $service->addLine($guestCart, $firstVariant->getKey(), 5);
    $service->addLine($customerCart, $firstVariant->getKey(), 2);
    $service->addLine($customerCart, $secondVariant->getKey(), 3);

    $merged = $service->mergeOnLogin($guestCart, $customerCart);

    expect($merged->lines()->withoutGlobalScopes()->where('variant_id', $firstVariant->getKey())->first()?->quantity)->toBe(5)
        ->and($merged->lines()->withoutGlobalScopes()->where('variant_id', $secondVariant->getKey())->first()?->quantity)->toBe(3)
        ->and($guestCart->refresh()->status)->toBe(CartStatus::Abandoned);
});


output: {"output":"Success. Updated the following files:\nA /Users/fabianwesner/Herd/shop/tests/Feature/Cart/CartServiceTest.php\nD /Users/fabianwesner/Herd/shop/tests/Feature/Cart/CartServiceTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Delete File: /Users/fabianwesner/Herd/shop/tests/Feature/Checkout/PricingServicesTest.php
*** Add File: /Users/fabianwesner/Herd/shop/tests/Feature/Checkout/PricingServicesTest.php
+<?php
+
+use App\Models\Cart;
+use App\Models\Checkout;
+use App\Models\Discount;
+use App\Models\InventoryItem;
+use App\Models\Product;
+use App\Models\ProductVariant;
+use App\Models\ShippingRate;
+use App\Models\ShippingZone;
+use App\Models\Store;
+use App\Models\TaxSettings;
+use App\Services\CartService;
+use App\Services\PricingEngine;
+use App\Services\ShippingCalculator;
+use App\Services\TaxCalculator;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+
+uses(RefreshDatabase::class);
+
+function pricingStore(): Store
+{
+    $store = Store::factory()->create();
+    app()->instance('current_store', $store);
+
+    TaxSettings::withoutGlobalScopes()->create([
+        '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'],
+            ],
+        ],
+    ]);
+
+    return $store;
+}
+
+function pricingVariant(Store $store, int $price = 2500, bool $requiresShipping = true): ProductVariant
+{
+    $product = Product::factory()
+        ->withDefaultVariant($price)
+        ->create(['store_id' => $store->getKey()]);
+    $variant = ProductVariant::withoutGlobalScopes()
+        ->where('product_id', $product->getKey())
+        ->firstOrFail();
+
+    $variant->forceFill([
+        'requires_shipping' => $requiresShipping,
+        'weight_g' => $requiresShipping ? 500 : 0,
+    ])->save();
+    InventoryItem::withoutGlobalScopes()
+        ->where('variant_id', $variant->getKey())
+        ->update(['quantity_on_hand' => 20]);
+
+    return $variant->refresh();
+}
+
+function pricingCheckout(Store $store, ProductVariant $variant, int $quantity = 2): Checkout
+{
+    $cart = app(CartService::class)->create($store);
+    app(CartService::class)->addLine($cart, $variant->getKey(), $quantity);
+
+    return Checkout::withoutGlobalScopes()->create([
+        'store_id' => $store->getKey(),
+        'cart_id' => $cart->getKey(),
+        'status' => 'shipping_selected',
+        'email' => 'buyer@example.test',
+        'shipping_address_json' => [
+            'first_name' => 'Test',
+            'last_name' => 'Buyer',
+            'address1' => 'Main Street 1',
+            'city' => 'Berlin',
+            'country' => 'DE',
+            'postal_code' => '10115',
+        ],
+    ]);
+}
+
+test('pricing engine calculates deterministic exclusive totals with shipping tax', function () {
+    $store = pricingStore();
+    $variant = pricingVariant($store);
+    $zone = ShippingZone::withoutGlobalScopes()->create([
+        'store_id' => $store->getKey(),
+        'name' => 'Germany',
+        'countries_json' => ['DE'],
+        'regions_json' => [],
+    ]);
+    $rate = ShippingRate::withoutGlobalScopes()->create([
+        'zone_id' => $zone->getKey(),
+        'name' => 'Standard',
+        'type' => 'flat',
+        'config_json' => ['amount' => 499],
+        'is_active' => true,
+    ]);
+    $checkout = pricingCheckout($store, $variant);
+    $checkout->forceFill(['shipping_method_id' => $rate->getKey()])->save();
+
+    $result = app(PricingEngine::class)->calculate($checkout);
+
+    expect($result->subtotal)->toBe(5000)
+        ->and($result->shipping)->toBe(499)
+        ->and($result->taxTotal)->toBe(1045)
+        ->and($result->total)->toBe(6544)
+        ->and($checkout->refresh()->totals_json['total'])->toBe(6544);
+});
+
+test('pricing engine applies percent and free shipping discounts', function () {
+    $store = pricingStore();
+    $variant = pricingVariant($store);
+    $zone = ShippingZone::withoutGlobalScopes()->create([
+        'store_id' => $store->getKey(),
+        'name' => 'Germany',
+        'countries_json' => ['DE'],
+        'regions_json' => [],
+    ]);
+    $rate = ShippingRate::withoutGlobalScopes()->create([
+        'zone_id' => $zone->getKey(),
+        'name' => 'Standard',
+        'type' => 'flat',
+        'config_json' => ['amount' => 499],
+        'is_active' => true,
+    ]);
+    Discount::factory()->create(['store_id' => $store->getKey(), 'code' => 'SAVE10']);
+    Discount::factory()->freeShipping()->create(['store_id' => $store->getKey(), 'code' => 'FREESHIP']);
+    $checkout = pricingCheckout($store, $variant);
+    $checkout->forceFill([
+        'shipping_method_id' => $rate->getKey(),
+        'discount_code' => 'save10',
+    ])->save();
+
+    $discounted = app(PricingEngine::class)->calculate($checkout);
+
+    expect($discounted->discount)->toBe(500)
+        ->and($discounted->shipping)->toBe(499)
+        ->and($discounted->total)->toBe(5949);
+
+    $checkout->forceFill(['discount_code' => 'FREESHIP'])->save();
+    $freeShipping = app(PricingEngine::class)->calculate($checkout);
+
+    expect($freeShipping->discount)->toBe(0)
+        ->and($freeShipping->shipping)->toBe(0)
+        ->and($freeShipping->total)->toBe(5950);
+});
+
+test('shipping and tax calculators handle matching ranges and inclusive extraction', function () {
+    $store = pricingStore();
+    $physicalVariant = pricingVariant($store, requiresShipping: true);
+    $digitalVariant = pricingVariant($store, requiresShipping: false);
+    $cart = app(CartService::class)->create($store);
+    app(CartService::class)->addLine($cart, $physicalVariant->getKey(), 2);
+    app(CartService::class)->addLine($cart, $digitalVariant->getKey(), 1);
+    $zone = ShippingZone::withoutGlobalScopes()->create([
+        'store_id' => $store->getKey(),
+        'name' => 'Berlin',
+        'countries_json' => ['DE'],
+        'regions_json' => ['DE-BE'],
+    ]);
+    $rate = ShippingRate::withoutGlobalScopes()->create([
+        'zone_id' => $zone->getKey(),
+        'name' => 'Weight',
+        'type' => 'weight',
+        'config_json' => [
+            'ranges' => [
+                ['min_g' => 0, 'max_g' => 500, 'amount' => 499],
+                ['min_g' => 501, 'max_g' => 2000, 'amount' => 899],
+            ],
+        ],
+        'is_active' => true,
+    ]);
+
+    $rates = app(ShippingCalculator::class)->getAvailableRates($store, ['country' => 'DE', 'province_code' => 'DE-BE']);
+
+    expect($rates->first()?->getKey())->toBe($rate->getKey())
+        ->and(app(ShippingCalculator::class)->calculate($rate, $cart))->toBe(899)
+        ->and(app(TaxCalculator::class)->extractInclusive(11900, 1900))->toBe(1900)
+        ->and(app(TaxCalculator::class)->addExclusive(10000, 1900))->toBe(1900);
+});
*** End Patch


stdout:
Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/tests/Feature/Checkout/PricingServicesTest.php
D /Users/fabianwesner/Herd/shop/tests/Feature/Checkout/PricingServicesTest.php

content:
<?php

use App\Models\Cart;
use App\Models\Checkout;
use App\Models\Discount;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\ShippingRate;
use App\Models\ShippingZone;
use App\Models\Store;
use App\Models\TaxSettings;
use App\Services\CartService;
use App\Services\PricingEngine;
use App\Services\ShippingCalculator;
use App\Services\TaxCalculator;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

function pricingStore(): Store
{
    $store = Store::factory()->create();
    app()->instance('current_store', $store);

    TaxSettings::withoutGlobalScopes()->create([
        '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'],
            ],
        ],
    ]);

    return $store;
}

function pricingVariant(Store $store, int $price = 2500, bool $requiresShipping = true): ProductVariant
{
    $product = Product::factory()
        ->withDefaultVariant($price)
        ->create(['store_id' => $store->getKey()]);
    $variant = ProductVariant::withoutGlobalScopes()
        ->where('product_id', $product->getKey())
        ->firstOrFail();

    $variant->forceFill([
        'requires_shipping' => $requiresShipping,
        'weight_g' => $requiresShipping ? 500 : 0,
    ])->save();
    InventoryItem::withoutGlobalScopes()
        ->where('variant_id', $variant->getKey())
        ->update(['quantity_on_hand' => 20]);

    return $variant->refresh();
}

function pricingCheckout(Store $store, ProductVariant $variant, int $quantity = 2): Checkout
{
    $cart = app(CartService::class)->create($store);
    app(CartService::class)->addLine($cart, $variant->getKey(), $quantity);

    return Checkout::withoutGlobalScopes()->create([
        'store_id' => $store->getKey(),
        'cart_id' => $cart->getKey(),
        'status' => 'shipping_selected',
        'email' => 'buyer@example.test',
        'shipping_address_json' => [
            'first_name' => 'Test',
            'last_name' => 'Buyer',
            'address1' => 'Main Street 1',
            'city' => 'Berlin',
            'country' => 'DE',
            'postal_code' => '10115',
        ],
    ]);
}

test('pricing engine calculates deterministic exclusive totals with shipping tax', function () {
    $store = pricingStore();
    $variant = pricingVariant($store);
    $zone = ShippingZone::withoutGlobalScopes()->create([
        'store_id' => $store->getKey(),
        'name' => 'Germany',
        'countries_json' => ['DE'],
        'regions_json' => [],
    ]);
    $rate = ShippingRate::withoutGlobalScopes()->create([
        'zone_id' => $zone->getKey(),
        'name' => 'Standard',
        'type' => 'flat',
        'config_json' => ['amount' => 499],
        'is_active' => true,
    ]);
    $checkout = pricingCheckout($store, $variant);
    $checkout->forceFill(['shipping_method_id' => $rate->getKey()])->save();

    $result = app(PricingEngine::class)->calculate($checkout);

    expect($result->subtotal)->toBe(5000)
        ->and($result->shipping)->toBe(499)
        ->and($result->taxTotal)->toBe(1045)
        ->and($result->total)->toBe(6544)
        ->and($checkout->refresh()->totals_json['total'])->toBe(6544);
});

test('pricing engine applies percent and free shipping discounts', function () {
    $store = pricingStore();
    $variant = pricingVariant($store);
    $zone = ShippingZone::withoutGlobalScopes()->create([
        'store_id' => $store->getKey(),
        'name' => 'Germany',
        'countries_json' => ['DE'],
        'regions_json' => [],
    ]);
    $rate = ShippingRate::withoutGlobalScopes()->create([
        'zone_id' => $zone->getKey(),
        'name' => 'Standard',
        'type' => 'flat',
        'config_json' => ['amount' => 499],
        'is_active' => true,
    ]);
    Discount::factory()->create(['store_id' => $store->getKey(), 'code' => 'SAVE10']);
    Discount::factory()->freeShipping()->create(['store_id' => $store->getKey(), 'code' => 'FREESHIP']);
    $checkout = pricingCheckout($store, $variant);
    $checkout->forceFill([
        'shipping_method_id' => $rate->getKey(),
        'discount_code' => 'save10',
    ])->save();

    $discounted = app(PricingEngine::class)->calculate($checkout);

    expect($discounted->discount)->toBe(500)
        ->and($discounted->shipping)->toBe(499)
        ->and($discounted->total)->toBe(5949);

    $checkout->forceFill(['discount_code' => 'FREESHIP'])->save();
    $freeShipping = app(PricingEngine::class)->calculate($checkout);

    expect($freeShipping->discount)->toBe(0)
        ->and($freeShipping->shipping)->toBe(0)
        ->and($freeShipping->total)->toBe(5950);
});

test('shipping and tax calculators handle matching ranges and inclusive extraction', function () {
    $store = pricingStore();
    $physicalVariant = pricingVariant($store, requiresShipping: true);
    $digitalVariant = pricingVariant($store, requiresShipping: false);
    $cart = app(CartService::class)->create($store);
    app(CartService::class)->addLine($cart, $physicalVariant->getKey(), 2);
    app(CartService::class)->addLine($cart, $digitalVariant->getKey(), 1);
    $zone = ShippingZone::withoutGlobalScopes()->create([
        'store_id' => $store->getKey(),
        'name' => 'Berlin',
        'countries_json' => ['DE'],
        'regions_json' => ['DE-BE'],
    ]);
    $rate = ShippingRate::withoutGlobalScopes()->create([
        'zone_id' => $zone->getKey(),
        'name' => 'Weight',
        'type' => 'weight',
        'config_json' => [
            'ranges' => [
                ['min_g' => 0, 'max_g' => 500, 'amount' => 499],
                ['min_g' => 501, 'max_g' => 2000, 'amount' => 899],
            ],
        ],
        'is_active' => true,
    ]);

    $rates = app(ShippingCalculator::class)->getAvailableRates($store, ['country' => 'DE', 'province_code' => 'DE-BE']);

    expect($rates->first()?->getKey())->toBe($rate->getKey())
        ->and(app(ShippingCalculator::class)->calculate($rate, $cart))->toBe(899)
        ->and(app(TaxCalculator::class)->extractInclusive(11900, 1900))->toBe(1900)
        ->and(app(TaxCalculator::class)->addExclusive(10000, 1900))->toBe(1900);
});


output: {"output":"Success. Updated the following files:\nA /Users/fabianwesner/Herd/shop/tests/Feature/Checkout/PricingServicesTest.php\nD /Users/fabianwesner/Herd/shop/tests/Feature/Checkout/PricingServicesTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}