command: sed -n '1,130p' database/seeders/ShippingSeeder.php; sed -n '1,120p' database/factories/ShippingRateFactory.php; sed -n '1,120p' database/factories/ShippingZoneFactory.php; sed -n '1,160p' app/Models/ShippingRate.php; sed -n '1,120p' app/Models/Cart.php; sed -n '1,130p' app/Models/StoreSetting.php 2>/dev/null || true; rg -n "class StoreSetting|settings_json" app/Models database/migrations/2026_08_20_000000_create_shop_schema.php | head -80

type: unknown

stdout:
<?php

namespace Database\Seeders;

use App\Models\ShippingRate;
use App\Models\ShippingZone;
use App\Models\Store;
use Illuminate\Database\Seeder;

class ShippingSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $stores = Store::query()->whereIn('handle', ['acme-fashion', 'acme-electronics'])->get()->keyBy('handle');

        $fashionZones = [
            ['name' => 'Domestic', 'countries_json' => ['DE'], 'rates' => [['name' => 'Standard Shipping', 'amount' => 499], ['name' => 'Express Shipping', 'amount' => 999]]],
            ['name' => 'EU', 'countries_json' => ['AT', 'FR', 'IT', 'ES', 'NL', 'BE', 'PL'], 'rates' => [['name' => 'EU Standard', 'amount' => 899]]],
            ['name' => 'Rest of World', 'countries_json' => ['US', 'GB', 'CA', 'AU'], 'rates' => [['name' => 'International', 'amount' => 1499]]],
        ];

        foreach ($fashionZones as $zoneData) {
            $zone = ShippingZone::withoutGlobalScopes()->updateOrCreate(
                ['store_id' => $stores['acme-fashion']->getKey(), 'name' => $zoneData['name']],
                ['countries_json' => $zoneData['countries_json'], 'regions_json' => []],
            );

            $this->seedRates($zone, $zoneData['rates']);
        }

        $electronicsZone = ShippingZone::withoutGlobalScopes()->updateOrCreate(
            ['store_id' => $stores['acme-electronics']->getKey(), 'name' => 'Germany'],
            ['countries_json' => ['DE'], 'regions_json' => []],
        );

        $this->seedRates($electronicsZone, [['name' => 'Standard', 'amount' => 0]]);
    }

    private function seedRates(ShippingZone $zone, array $rates): void
    {
        foreach ($rates as $rate) {
            ShippingRate::query()->updateOrCreate(
                ['shipping_zone_id' => $zone->getKey(), 'name' => $rate['name']],
                ['zone_id' => $zone->getKey(), 'type' => 'flat', 'price_amount' => $rate['amount'], 'currency' => 'EUR', 'config_json' => ['amount' => $rate['amount']], 'is_active' => true, 'estimated_days_min' => $rate['amount'] === 0 ? 0 : ($rate['amount'] >= 999 ? 1 : 3), 'estimated_days_max' => $rate['amount'] === 0 ? 0 : ($rate['amount'] >= 999 ? 2 : 5)],
            );
        }
    }
}
<?php

namespace Database\Factories;

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

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ShippingRate>
 */
class ShippingRateFactory extends Factory
{
    protected $model = ShippingRate::class;

    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'shipping_zone_id' => ShippingZoneFactory::new(),
            'name' => fake()->randomElement(['Standard', 'Express', 'Economy']),
            'type' => 'flat',
            'price_amount' => 499,
            'currency' => 'EUR',
            'config_json' => ['amount' => 499],
            'is_active' => true,
            'estimated_days_min' => 3,
            'estimated_days_max' => 5,
        ];
    }

    public function inactive(): static
    {
        return $this->state(['is_active' => false]);
    }

    public function weightBased(): static
    {
        return $this->state(['type' => 'weight', 'config_json' => ['ranges' => [['min_g' => 0, 'max_g' => 500, 'amount' => 399], ['min_g' => 501, 'max_g' => 2000, 'amount' => 699], ['min_g' => 2001, 'max_g' => null, 'amount' => 1299]]]]);
    }
}
<?php

namespace Database\Factories;

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

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ShippingZone>
 */
class ShippingZoneFactory extends Factory
{
    protected $model = ShippingZone::class;

    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'name' => fake()->country(),
            'countries_json' => ['DE'],
            'regions_json' => [],
        ];
    }
}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class ShippingRate extends Model
{
    protected $fillable = ['shipping_zone_id', 'zone_id', 'name', 'type', 'price_amount', 'currency', 'config_json', 'is_active', 'estimated_days_min', 'estimated_days_max'];

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

    public function zone(): BelongsTo
    {
        return $this->belongsTo(ShippingZone::class, 'shipping_zone_id');
    }
}
<?php

namespace App\Models;

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

class Cart extends Model
{
    use BelongsToStore;

    protected $fillable = ['store_id', 'customer_id', 'currency', 'cart_version', 'status', 'discount_code'];

    protected function casts(): array
    {
        return ['status' => CartStatus::class];
    }

    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }

    public function lines(): HasMany
    {
        return $this->hasMany(CartLine::class);
    }

    public function subtotalAmount(): int
    {
        return (int) $this->lines->sum('line_total_amount');
    }

    public function itemCount(): int
    {
        return (int) $this->lines->sum('quantity');
    }
}
app/Models/StoreSettings.php:10:class StoreSettings extends Model
app/Models/StoreSettings.php:21:    protected $fillable = ['store_id', 'settings_json', 'general_json', 'checkout_json', 'notification_json', 'social_json'];
app/Models/StoreSettings.php:24:        'settings_json' => '{}',
app/Models/StoreSettings.php:29:        return ['settings_json' => 'array', 'general_json' => 'array', 'checkout_json' => 'array', 'notification_json' => 'array', 'social_json' => 'array'];
app/Models/ThemeSetting.php:22:    protected $attributes = ['settings_json' => '{}'];
app/Models/ThemeSetting.php:24:    protected $fillable = ['theme_id', 'settings_json'];
app/Models/ThemeSetting.php:28:        return ['settings_json' => 'array'];


text:
Script completed
Wall time 0.1 seconds
Output:

text:
<?php

namespace Database\Seeders;

use App\Models\ShippingRate;
use App\Models\ShippingZone;
use App\Models\Store;
use Illuminate\Database\Seeder;

class ShippingSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $stores = Store::query()->whereIn('handle', ['acme-fashion', 'acme-electronics'])->get()->keyBy('handle');

        $fashionZones = [
            ['name' => 'Domestic', 'countries_json' => ['DE'], 'rates' => [['name' => 'Standard Shipping', 'amount' => 499], ['name' => 'Express Shipping', 'amount' => 999]]],
            ['name' => 'EU', 'countries_json' => ['AT', 'FR', 'IT', 'ES', 'NL', 'BE', 'PL'], 'rates' => [['name' => 'EU Standard', 'amount' => 899]]],
            ['name' => 'Rest of World', 'countries_json' => ['US', 'GB', 'CA', 'AU'], 'rates' => [['name' => 'International', 'amount' => 1499]]],
        ];

        foreach ($fashionZones as $zoneData) {
            $zone = ShippingZone::withoutGlobalScopes()->updateOrCreate(
                ['store_id' => $stores['acme-fashion']->getKey(), 'name' => $zoneData['name']],
                ['countries_json' => $zoneData['countries_json'], 'regions_json' => []],
            );

            $this->seedRates($zone, $zoneData['rates']);
        }

        $electronicsZone = ShippingZone::withoutGlobalScopes()->updateOrCreate(
            ['store_id' => $stores['acme-electronics']->getKey(), 'name' => 'Germany'],
            ['countries_json' => ['DE'], 'regions_json' => []],
        );

        $this->seedRates($electronicsZone, [['name' => 'Standard', 'amount' => 0]]);
    }

    private function seedRates(ShippingZone $zone, array $rates): void
    {
        foreach ($rates as $rate) {
            ShippingRate::query()->updateOrCreate(
                ['shipping_zone_id' => $zone->getKey(), 'name' => $rate['name']],
                ['zone_id' => $zone->getKey(), 'type' => 'flat', 'price_amount' => $rate['amount'], 'currency' => 'EUR', 'config_json' => ['amount' => $rate['amount']], 'is_active' => true, 'estimated_days_min' => $rate['amount'] === 0 ? 0 : ($rate['amount'] >= 999 ? 1 : 3), 'estimated_days_max' => $rate['amount'] === 0 ? 0 : ($rate['amount'] >= 999 ? 2 : 5)],
            );
        }
    }
}
<?php

namespace Database\Factories;

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

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ShippingRate>
 */
class ShippingRateFactory extends Factory
{
    protected $model = ShippingRate::class;

    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'shipping_zone_id' => ShippingZoneFactory::new(),
            'name' => fake()->randomElement(['Standard', 'Express', 'Economy']),
            'type' => 'flat',
            'price_amount' => 499,
            'currency' => 'EUR',
            'config_json' => ['amount' => 499],
            'is_active' => true,
            'estimated_days_min' => 3,
            'estimated_days_max' => 5,
        ];
    }

    public function inactive(): static
    {
        return $this->state(['is_active' => false]);
    }

    public function weightBased(): static
    {
        return $this->state(['type' => 'weight', 'config_json' => ['ranges' => [['min_g' => 0, 'max_g' => 500, 'amount' => 399], ['min_g' => 501, 'max_g' => 2000, 'amount' => 699], ['min_g' => 2001, 'max_g' => null, 'amount' => 1299]]]]);
    }
}
<?php

namespace Database\Factories;

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

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ShippingZone>
 */
class ShippingZoneFactory extends Factory
{
    protected $model = ShippingZone::class;

    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'name' => fake()->country(),
            'countries_json' => ['DE'],
            'regions_json' => [],
        ];
    }
}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class ShippingRate extends Model
{
    protected $fillable = ['shipping_zone_id', 'zone_id', 'name', 'type', 'price_amount', 'currency', 'config_json', 'is_active', 'estimated_days_min', 'estimated_days_max'];

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

    public function zone(): BelongsTo
    {
        return $this->belongsTo(ShippingZone::class, 'shipping_zone_id');
    }
}
<?php

namespace App\Models;

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

class Cart extends Model
{
    use BelongsToStore;

    protected $fillable = ['store_id', 'customer_id', 'currency', 'cart_version', 'status', 'discount_code'];

    protected function casts(): array
    {
        return ['status' => CartStatus::class];
    }

    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }

    public function lines(): HasMany
    {
        return $this->hasMany(CartLine::class);
    }

    public function subtotalAmount(): int
    {
        return (int) $this->lines->sum('line_total_amount');
    }

    public function itemCount(): int
    {
        return (int) $this->lines->sum('quantity');
    }
}
app/Models/StoreSettings.php:10:class StoreSettings extends Model
app/Models/StoreSettings.php:21:    protected $fillable = ['store_id', 'settings_json', 'general_json', 'checkout_json', 'notification_json', 'social_json'];
app/Models/StoreSettings.php:24:        'settings_json' => '{}',
app/Models/StoreSettings.php:29:        return ['settings_json' => 'array', 'general_json' => 'array', 'checkout_json' => 'array', 'notification_json' => 'array', 'social_json' => 'array'];
app/Models/ThemeSetting.php:22:    protected $attributes = ['settings_json' => '{}'];
app/Models/ThemeSetting.php:24:    protected $fillable = ['theme_id', 'settings_json'];
app/Models/ThemeSetting.php:28:        return ['settings_json' => 'array'];


exec

status: completed

input:
const r = await tools.exec_command({
  cmd: "sed -n '280,305p' database/migrations/2026_08_20_000000_create_shop_schema.php; sed -n '1,100p' app/Models/StoreSettings.php; sed -n '1,130p' app/Models/Checkout.php; sed -n '1,180p' app/Jobs/CancelUnpaidBankTransferOrders.php; rg -n \"store_settings|settings_json\" database/seeders/Store* database/seeders | head -80",
  workdir: "/Users/wesner/Herd/shop",
  yield_time_ms: 1000,
  max_output_tokens: 14000
});
text(r.output);


type: CommandExecution

command: sed -n '280,305p' database/migrations/2026_08_20_000000_create_shop_schema.php; sed -n '1,100p' app/Models/StoreSettings.php; sed -n '1,130p' app/Models/Checkout.php; sed -n '1,180p' app/Jobs/CancelUnpaidBankTransferOrders.php; rg -n "store_settings|settings_json" database/seeders/Store* database/seeders | head -80

type: read

Checkout.php

cmd: sed -n '1,130p' app/Models/Checkout.php

CancelUnpaidBankTransferOrders.php

type: search

cmd: rg -n 'store_settings|settings_json' 'database/seeders/Store*' database/seeders

path: Store*

status: completed

stdout:
            $table->unsignedInteger('estimated_days_min')->nullable();
            $table->unsignedInteger('estimated_days_max')->nullable();
            $table->timestamps();
            $table->index(['shipping_zone_id', 'is_active']);
        });

        Schema::create('tax_settings', function (Blueprint $table): void {
            $table->foreignId('store_id')->primary()->constrained()->cascadeOnDelete();
            $table->string('mode')->default('manual');
            $table->unsignedInteger('default_rate_basis_points')->default(0);
            $table->json('rates_json')->nullable();
            $table->json('provider_config_json')->nullable();
            $table->timestamps();
        });

        Schema::create('discounts', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('code')->nullable();
            $table->string('type')->default('code');
            $table->string('value_type')->default('percent');
            $table->unsignedInteger('value_amount')->default(0);
            $table->string('status')->default('active')->index();
            $table->unsignedInteger('usage_limit')->nullable();
            $table->unsignedInteger('usage_count')->default(0);
            $table->timestamp('starts_at')->nullable();
<?php

namespace App\Models;

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

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

    protected $primaryKey = 'store_id';

    public $incrementing = false;

    protected $keyType = 'int';

    protected $fillable = ['store_id', 'settings_json', 'general_json', 'checkout_json', 'notification_json', 'social_json'];

    protected $attributes = [
        'settings_json' => '{}',
    ];

    protected function casts(): array
    {
        return ['settings_json' => 'array', 'general_json' => 'array', 'checkout_json' => 'array', 'notification_json' => 'array', 'social_json' => 'array'];
    }

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

namespace App\Models;

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

class Checkout extends Model
{
    use BelongsToStore;

    protected $fillable = ['store_id', 'cart_id', 'customer_id', 'status', 'email', 'payment_method', 'shipping_address_json', 'billing_address_json', 'shipping_rate_id', 'shipping_method_id', 'discount_code', 'totals_json', 'tax_provider_snapshot_json', 'expires_at'];

    protected function casts(): array
    {
        return ['status' => CheckoutStatus::class, 'shipping_address_json' => 'array', 'billing_address_json' => 'array', 'totals_json' => 'array', 'tax_provider_snapshot_json' => 'array', 'expires_at' => 'datetime'];
    }

    public function cart(): BelongsTo
    {
        return $this->belongsTo(Cart::class);
    }

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

    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }

    public function shippingRate(): BelongsTo
    {
        return $this->belongsTo(ShippingRate::class);
    }

    public function isExpired(): bool
    {
        return $this->expires_at !== null && $this->expires_at->isPast();
    }
}
<?php

namespace App\Jobs;

use App\Enums\FinancialStatus;
use App\Models\Order;
use App\Services\OrderService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class CancelUnpaidBankTransferOrders implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, SerializesModels;

    public function handle(OrderService $orders): void
    {
        $days = (int) config('shop.bank_transfer_expiry_days', 7);
        Order::withoutGlobalScopes()->where('payment_method', 'bank_transfer')->where('financial_status', FinancialStatus::Pending)->where('placed_at', '<', now()->subDays($days))->with('lines.variant.inventory')->each(fn (Order $order): mixed => $orders->cancel($order, 'Bank transfer payment expired.'));
    }
}
database/seeders/StoreSettingsSeeder.php:25:                ['settings_json' => ['store_name' => $name, 'contact_email' => $email, 'order_number_prefix' => '#', 'order_number_start' => $start], 'general_json' => ['store_name' => $name], 'checkout_json' => ['guest_checkout' => true], 'notification_json' => [], 'social_json' => []],
database/seeders/StoreSettingsSeeder.php:25:                ['settings_json' => ['store_name' => $name, 'contact_email' => $email, 'order_number_prefix' => '#', 'order_number_start' => $start], 'general_json' => ['store_name' => $name], 'checkout_json' => ['guest_checkout' => true], 'notification_json' => [], 'social_json' => []],
database/seeders/ThemeSeeder.php:24:                ['settings_json' => ['brand' => ['primary' => '#18181b'], 'layout' => ['container' => 'wide']]],


text:
Script completed
Wall time 0.1 seconds
Output:

text:
            $table->unsignedInteger('estimated_days_min')->nullable();
            $table->unsignedInteger('estimated_days_max')->nullable();
            $table->timestamps();
            $table->index(['shipping_zone_id', 'is_active']);
        });

        Schema::create('tax_settings', function (Blueprint $table): void {
            $table->foreignId('store_id')->primary()->constrained()->cascadeOnDelete();
            $table->string('mode')->default('manual');
            $table->unsignedInteger('default_rate_basis_points')->default(0);
            $table->json('rates_json')->nullable();
            $table->json('provider_config_json')->nullable();
            $table->timestamps();
        });

        Schema::create('discounts', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('code')->nullable();
            $table->string('type')->default('code');
            $table->string('value_type')->default('percent');
            $table->unsignedInteger('value_amount')->default(0);
            $table->string('status')->default('active')->index();
            $table->unsignedInteger('usage_limit')->nullable();
            $table->unsignedInteger('usage_count')->default(0);
            $table->timestamp('starts_at')->nullable();
<?php

namespace App\Models;

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

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

    protected $primaryKey = 'store_id';

    public $incrementing = false;

    protected $keyType = 'int';

    protected $fillable = ['store_id', 'settings_json', 'general_json', 'checkout_json', 'notification_json', 'social_json'];

    protected $attributes = [
        'settings_json' => '{}',
    ];

    protected function casts(): array
    {
        return ['settings_json' => 'array', 'general_json' => 'array', 'checkout_json' => 'array', 'notification_json' => 'array', 'social_json' => 'array'];
    }

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

namespace App\Models;

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

class Checkout extends Model
{
    use BelongsToStore;

    protected $fillable = ['store_id', 'cart_id', 'customer_id', 'status', 'email', 'payment_method', 'shipping_address_json', 'billing_address_json', 'shipping_rate_id', 'shipping_method_id', 'discount_code', 'totals_json', 'tax_provider_snapshot_json', 'expires_at'];

    protected function casts(): array
    {
        return ['status' => CheckoutStatus::class, 'shipping_address_json' => 'array', 'billing_address_json' => 'array', 'totals_json' => 'array', 'tax_provider_snapshot_json' => 'array', 'expires_at' => 'datetime'];
    }

    public function cart(): BelongsTo
    {
        return $this->belongsTo(Cart::class);
    }

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

    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }

    public function shippingRate(): BelongsTo
    {
        return $this->belongsTo(ShippingRate::class);
    }

    public function isExpired(): bool
    {
        return $this->expires_at !== null && $this->expires_at->isPast();
    }
}
<?php

namespace App\Jobs;

use App\Enums\FinancialStatus;
use App\Models\Order;
use App\Services\OrderService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class CancelUnpaidBankTransferOrders implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, SerializesModels;

    public function handle(OrderService $orders): void
    {
        $days = (int) config('shop.bank_transfer_expiry_days', 7);
        Order::withoutGlobalScopes()->where('payment_method', 'bank_transfer')->where('financial_status', FinancialStatus::Pending)->where('placed_at', '<', now()->subDays($days))->with('lines.variant.inventory')->each(fn (Order $order): mixed => $orders->cancel($order, 'Bank transfer payment expired.'));
    }
}
database/seeders/StoreSettingsSeeder.php:25:                ['settings_json' => ['store_name' => $name, 'contact_email' => $email, 'order_number_prefix' => '#', 'order_number_start' => $start], 'general_json' => ['store_name' => $name], 'checkout_json' => ['guest_checkout' => true], 'notification_json' => [], 'social_json' => []],
database/seeders/StoreSettingsSeeder.php:25:                ['settings_json' => ['store_name' => $name, 'contact_email' => $email, 'order_number_prefix' => '#', 'order_number_start' => $start], 'general_json' => ['store_name' => $name], 'checkout_json' => ['guest_checkout' => true], 'notification_json' => [], 'social_json' => []],
database/seeders/ThemeSeeder.php:24:                ['settings_json' => ['brand' => ['primary' => '#18181b'], 'layout' => ['container' => 'wide']]],


exec

status: completed

input:
const r = await tools.exec_command({
  cmd: "rg -n \"tax_settings|provider|prices_include|settings_json\" database/migrations | sed -n '1,180p'; sed -n '1,100p' database/seeders/StoreSettingsSeeder.php",
  workdir: "/Users/wesner/Herd/shop",
  yield_time_ms: 1000,
  max_output_tokens: 12000
});
text(r.output);


command: rg -n "tax_settings|provider|prices_include|settings_json" database/migrations | sed -n '1,180p'; sed -n '1,100p' database/seeders/StoreSettingsSeeder.php

cmd: rg -n 'tax_settings|provider|prices_include|settings_json' database/migrations

query: tax_settings|provider|prices_include|settings_json

path: migrations

StoreSettingsSeeder.php

stdout:
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:26:        if (! Schema::hasTable('theme_settings') || Schema::hasColumn('theme_settings', 'settings_json')) {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:32:            $table->text('settings_json')->default('{}');
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:52:                'settings_json' => $this->encodeJsonObject($settings),
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:62:        if (! Schema::hasTable('theme_settings') || ! Schema::hasColumn('theme_settings', 'settings_json')) {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:76:            $settings = $this->decodeJson($row->settings_json);
database/migrations/2026_08_20_000000_create_shop_schema.php:286:        Schema::create('tax_settings', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:291:            $table->json('provider_config_json')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php:379:            $table->json('tax_provider_snapshot_json')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php:436:            $table->string('provider')->default('mock');
database/migrations/2026_08_20_000000_create_shop_schema.php:437:            $table->string('provider_payment_id')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php:443:            $table->index(['provider', 'provider_payment_id']);
database/migrations/2026_08_20_000000_create_shop_schema.php:581:            'checkouts', 'cart_lines', 'carts', 'discounts', 'tax_settings', 'shipping_rates',
database/migrations/2026_08_20_235245_remove_legacy_settings_from_themes_table.php:26:                ['settings_json' => json_encode(is_array($settings) ? $settings : new \stdClass), 'updated_at' => now()],
database/migrations/2026_08_20_235245_remove_legacy_settings_from_themes_table.php:49:            DB::table('themes')->where('id', $settings->theme_id)->update(['settings' => $settings->settings_json]);
database/migrations/2026_08_20_210057_add_settings_json_to_store_settings_table.php:15:            $table->text('settings_json')->default('{}')->after('store_id');
database/migrations/2026_08_20_210057_add_settings_json_to_store_settings_table.php:25:            $table->dropColumn('settings_json');
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:29:        Schema::table('tax_settings', function (Blueprint $table): void {
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:30:            $table->string('provider')->default('none');
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:31:            $table->boolean('prices_include_tax')->default(false);
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:42:            $table->string('provider_refund_id')->nullable();
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:49:            $table->dropColumn('provider_refund_id');
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:58:        Schema::table('tax_settings', function (Blueprint $table): void {
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:59:            $table->dropColumn(['provider', 'prices_include_tax', 'config_json']);
<?php

namespace Database\Seeders;

use App\Models\Store;
use App\Models\StoreSettings;
use Illuminate\Database\Seeder;

class StoreSettingsSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $stores = Store::query()->whereIn('handle', ['acme-fashion', 'acme-electronics'])->get();

        foreach ($stores as $store) {
            $name = $store->handle === 'acme-fashion' ? 'Acme Fashion' : 'Acme Electronics';
            $email = $store->handle === 'acme-fashion' ? 'hello@acme-fashion.test' : 'hello@acme-electronics.test';
            $start = $store->handle === 'acme-fashion' ? 1001 : 5001;

            StoreSettings::withoutGlobalScopes()->updateOrCreate(
                ['store_id' => $store->getKey()],
                ['settings_json' => ['store_name' => $name, 'contact_email' => $email, 'order_number_prefix' => '#', 'order_number_start' => $start], 'general_json' => ['store_name' => $name], 'checkout_json' => ['guest_checkout' => true], 'notification_json' => [], 'social_json' => []],
            );
        }
    }
}


text:
Script completed
Wall time 0.1 seconds
Output:

text:
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:26:        if (! Schema::hasTable('theme_settings') || Schema::hasColumn('theme_settings', 'settings_json')) {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:32:            $table->text('settings_json')->default('{}');
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:52:                'settings_json' => $this->encodeJsonObject($settings),
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:62:        if (! Schema::hasTable('theme_settings') || ! Schema::hasColumn('theme_settings', 'settings_json')) {
database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php:76:            $settings = $this->decodeJson($row->settings_json);
database/migrations/2026_08_20_000000_create_shop_schema.php:286:        Schema::create('tax_settings', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:291:            $table->json('provider_config_json')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php:379:            $table->json('tax_provider_snapshot_json')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php:436:            $table->string('provider')->default('mock');
database/migrations/2026_08_20_000000_create_shop_schema.php:437:            $table->string('provider_payment_id')->nullable();
database/migrations/2026_08_20_000000_create_shop_schema.php:443:            $table->index(['provider', 'provider_payment_id']);
database/migrations/2026_08_20_000000_create_shop_schema.php:581:            'checkouts', 'cart_lines', 'carts', 'discounts', 'tax_settings', 'shipping_rates',
database/migrations/2026_08_20_235245_remove_legacy_settings_from_themes_table.php:26:                ['settings_json' => json_encode(is_array($settings) ? $settings : new \stdClass), 'updated_at' => now()],
database/migrations/2026_08_20_235245_remove_legacy_settings_from_themes_table.php:49:            DB::table('themes')->where('id', $settings->theme_id)->update(['settings' => $settings->settings_json]);
database/migrations/2026_08_20_210057_add_settings_json_to_store_settings_table.php:15:            $table->text('settings_json')->default('{}')->after('store_id');
database/migrations/2026_08_20_210057_add_settings_json_to_store_settings_table.php:25:            $table->dropColumn('settings_json');
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:29:        Schema::table('tax_settings', function (Blueprint $table): void {
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:30:            $table->string('provider')->default('none');
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:31:            $table->boolean('prices_include_tax')->default(false);
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:42:            $table->string('provider_refund_id')->nullable();
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:49:            $table->dropColumn('provider_refund_id');
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:58:        Schema::table('tax_settings', function (Blueprint $table): void {
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:59:            $table->dropColumn(['provider', 'prices_include_tax', 'config_json']);
<?php

namespace Database\Seeders;

use App\Models\Store;
use App\Models\StoreSettings;
use Illuminate\Database\Seeder;

class StoreSettingsSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $stores = Store::query()->whereIn('handle', ['acme-fashion', 'acme-electronics'])->get();

        foreach ($stores as $store) {
            $name = $store->handle === 'acme-fashion' ? 'Acme Fashion' : 'Acme Electronics';
            $email = $store->handle === 'acme-fashion' ? 'hello@acme-fashion.test' : 'hello@acme-electronics.test';
            $start = $store->handle === 'acme-fashion' ? 1001 : 5001;

            StoreSettings::withoutGlobalScopes()->updateOrCreate(
                ['store_id' => $store->getKey()],
                ['settings_json' => ['store_name' => $name, 'contact_email' => $email, 'order_number_prefix' => '#', 'order_number_start' => $start], 'general_json' => ['store_name' => $name], 'checkout_json' => ['guest_checkout' => true], 'notification_json' => [], 'social_json' => []],
            );
        }
    }
}


exec

status: completed

input:
const patch = "*** Begin Patch\n*** Update File: /Users/wesner/Herd/shop/app/Http/Controllers/Api/StorefrontCheckoutController.php\n@@\n-        $cart = Cart::query()->with('lines')->findOrFail($data['cart_id']);\n+        $cart = Cart::query()->where('status', 'active')->with('lines')->findOrFail($data['cart_id']);\n*** Update File: /Users/wesner/Herd/shop/app/Services/PricingEngine.php\n@@\n namespace App\\Services;\n \n+use App\\Enums\\DiscountType;\n@@\n-        if ($checkout->discount_code !== null) {\n+        $discounts = collect();\n+\n+        if ($checkout->discount_code !== null) {\n             $discount = Discount::withoutGlobalScopes()->where('store_id', $checkout->store_id)->whereRaw('lower(code) = ?', [strtolower($checkout->discount_code)])->first();\n \n             if ($discount?->isAvailable()) {\n-                $result = $this->discounts->calculate($discount, $subtotal, $lines->map(fn ($line): array => [\n-                    'line_id' => $line->id,\n-                    'amount' => $line->unit_price_amount * $line->quantity,\n-                    'product_id' => $line->variant->product_id,\n-                    'collection_ids' => $line->variant->product->collections->modelKeys(),\n-                ])->all());\n-                $discountAmount = $result->amount;\n-                $freeShipping = $result->freeShipping;\n-\n-                foreach ($lines as $line) {\n-                    $lineDiscount = $result->allocations[$line->id] ?? 0;\n-                    $line->updateQuietly(['line_discount_amount' => $lineDiscount, 'line_total_amount' => max(0, $line->line_subtotal_amount - $lineDiscount)]);\n-                }\n+                $discounts->push($discount);\n             }\n         }\n+\n+        Discount::withoutGlobalScopes()\n+            ->where('store_id', $checkout->store_id)\n+            ->where('type', DiscountType::Automatic)\n+            ->get()\n+            ->filter(fn (Discount $discount): bool => $discount->isAvailable())\n+            ->each(fn (Discount $discount): mixed => $discounts->push($discount));\n+\n+        foreach ($discounts as $discount) {\n+            $currentSubtotal = (int) $lines->sum('line_total_amount');\n+            $minimum = (int) ($discount->rules_json['min_purchase_amount'] ?? $discount->rules_json['minimum_purchase_amount'] ?? 0);\n+\n+            if ($currentSubtotal < $minimum) {\n+                continue;\n+            }\n+\n+            $result = $this->discounts->calculate($discount, $currentSubtotal, $lines->map(fn ($line): array => [\n+                'line_id' => $line->id,\n+                'amount' => $line->line_total_amount,\n+                'product_id' => $line->variant->product_id,\n+                'collection_ids' => $line->variant->product->collections->modelKeys(),\n+            ])->all());\n+            $discountAmount += $result->amount;\n+            $freeShipping = $freeShipping || $result->freeShipping;\n+\n+            foreach ($lines as $line) {\n+                $lineDiscount = $result->allocations[$line->id] ?? 0;\n+                $line->updateQuietly([\n+                    'line_discount_amount' => $line->line_discount_amount + $lineDiscount,\n+                    'line_total_amount' => max(0, $line->line_total_amount - $lineDiscount),\n+                ]);\n+            }\n+        }\n@@\n-        $shippingAmount = $checkout->shippingRate !== null && ! $freeShipping ? $this->shipping->calculate($checkout->shippingRate, $cart) : 0;\n+        $shippingAmount = $checkout->shippingRate !== null && ! $freeShipping ? ($this->shipping->calculate($checkout->shippingRate, $cart) ?? 0) : 0;\n@@\n         $taxTotal = (int) collect($taxLines)->sum('amount');\n@@\n         $result = new PricingResult($subtotal, $discountAmount, $shippingAmount, $taxLines, $taxTotal, $total, $cart->currency);\n-        $checkout->update(['totals_json' => $result->toArray()]);\n+        $checkout->update([\n+            'totals_json' => $result->toArray(),\n+            'tax_provider_snapshot_json' => [\n+                'provider' => $taxSettings->provider,\n+                'mode' => $taxSettings->mode,\n+                'rates' => $taxSettings->rates_json ?? [],\n+                'captured_at' => now()->toIso8601String(),\n+                'tax_total' => $taxTotal,\n+            ],\n+        ]);\n*** Update File: /Users/wesner/Herd/shop/app/Services/TaxCalculator.php\n@@\n     public function calculate(int $amount, TaxSettings $settings, array $address): TaxResult\n     {\n-        return ($this->provider ?? new \\App\\Services\\Tax\\ManualTaxProvider)->calculate(new TaxCalculationRequest([['amount' => $amount]], 0, $address, $settings));\n+        $provider = $this->provider;\n+\n+        if ($provider === null) {\n+            $provider = $settings->mode === 'provider' && $settings->provider === 'stripe_tax'\n+                ? new \\App\\Services\\Tax\\StripeTaxProvider(new \\App\\Services\\Tax\\ManualTaxProvider)\n+                : new \\App\\Services\\Tax\\ManualTaxProvider;\n+        }\n+\n+        return $provider->calculate(new TaxCalculationRequest([['amount' => $amount]], 0, $address, $settings));\n*** Update File: /Users/wesner/Herd/shop/app/Services/ShippingCalculator.php\n@@\n-        $matching = $rates->filter(function (ShippingRate $rate) use ($country, $region): bool {\n+        $matching = $rates->filter(function (ShippingRate $rate) use ($country, $region): bool {\n             $countries = array_map('strtoupper', $rate->zone->countries_json ?? []);\n             $regions = array_map('strtoupper', $rate->zone->regions_json ?? []);\n \n-            return ($region !== '' && in_array($region, $regions, true))\n-                || ($country !== '' && in_array($country, $countries, true))\n-                || ($countries === [] && $regions === []);\n+            return $country !== '' && in_array($country, $countries, true)\n+                && ($region === '' || $regions === [] || in_array($region, $regions, true));\n@@\n-            return ($region !== '' && in_array($region, $regions, true)) ? 2 : (($country !== '' && in_array($country, $countries, true)) ? 1 : 0);\n+            return $region !== '' && in_array($region, $regions, true) ? 2 : 1;\n         });\n \n-        return $specificity->sortKeysDesc()->first() ?? collect();\n+        return $specificity->sortKeysDesc()->first()?->sortBy('id')->values() ?? collect();\n     }\n \n-    public function calculate(ShippingRate $rate, Cart $cart): int\n+    public function calculate(ShippingRate $rate, Cart $cart): ?int\n@@\n-            default => $rate->price_amount,\n+            'carrier' => (int) ($config['amount'] ?? $rate->price_amount ?? 0),\n+            default => (int) ($config['amount'] ?? $rate->price_amount ?? 0),\n         };\n     }\n \n-    private function rangeAmount(array $ranges, int $value, int $fallback): int\n+    private function rangeAmount(array $ranges, int $value, ?int $fallback): ?int\n@@\n-                return (int) ($range['amount'] ?? $fallback);\n+                return (int) ($range['amount'] ?? $fallback ?? 0);\n@@\n-        return 0;\n+        return null;\n*** End Patch";
text(await tools.apply_patch(patch));


type: FileChange

type: update

unified_diff:
@@ -15,3 +15,11 @@
     {
-        return ($this->provider ?? new \App\Services\Tax\ManualTaxProvider)->calculate(new TaxCalculationRequest([['amount' => $amount]], 0, $address, $settings));
+        $provider = $this->provider;
+
+        if ($provider === null) {
+            $provider = $settings->mode === 'provider' && $settings->provider === 'stripe_tax'
+                ? new \App\Services\Tax\StripeTaxProvider(new \App\Services\Tax\ManualTaxProvider)
+                : new \App\Services\Tax\ManualTaxProvider;
+        }
+
+        return $provider->calculate(new TaxCalculationRequest([['amount' => $amount]], 0, $address, $settings));
     }

unified_diff:
@@ -22,5 +22,4 @@

-            return ($region !== '' && in_array($region, $regions, true))
-                || ($country !== '' && in_array($country, $countries, true))
-                || ($countries === [] && $regions === []);
+            return $country !== '' && in_array($country, $countries, true)
+                && ($region === '' || $regions === [] || in_array($region, $regions, true));
         });
@@ -31,9 +30,9 @@

-            return ($region !== '' && in_array($region, $regions, true)) ? 2 : (($country !== '' && in_array($country, $countries, true)) ? 1 : 0);
+            return $region !== '' && in_array($region, $regions, true) ? 2 : 1;
         });

-        return $specificity->sortKeysDesc()->first() ?? collect();
+        return $specificity->sortKeysDesc()->first()?->sortBy('id')->values() ?? collect();
     }

-    public function calculate(ShippingRate $rate, Cart $cart): int
+    public function calculate(ShippingRate $rate, Cart $cart): ?int
     {
@@ -48,3 +47,4 @@
             'price' => $this->rangeAmount($config['ranges'] ?? [], $subtotal, $rate->price_amount),
-            default => $rate->price_amount,
+            'carrier' => (int) ($config['amount'] ?? $rate->price_amount ?? 0),
+            default => (int) ($config['amount'] ?? $rate->price_amount ?? 0),
         };
@@ -52,3 +52,3 @@

-    private function rangeAmount(array $ranges, int $value, int $fallback): int
+    private function rangeAmount(array $ranges, int $value, ?int $fallback): ?int
     {
@@ -56,3 +56,3 @@
             if ($value >= (int) ($range['min_g'] ?? $range['min_amount'] ?? 0) && $value <= (int) ($range['max_g'] ?? $range['max_amount'] ?? PHP_INT_MAX)) {
-                return (int) ($range['amount'] ?? $fallback);
+                return (int) ($range['amount'] ?? $fallback ?? 0);
             }
@@ -60,3 +60,3 @@

-        return 0;
+        return null;
     }

unified_diff:
@@ -28,3 +28,3 @@
         $data = $request->validate(['cart_id' => ['required', 'integer'], 'email' => ['required', 'email']]);
-        $cart = Cart::query()->with('lines')->findOrFail($data['cart_id']);
+        $cart = Cart::query()->where('status', 'active')->with('lines')->findOrFail($data['cart_id']);
         $customerId = $request->user('customer')?->getKey();

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

+use App\Enums\DiscountType;
 use App\Models\Checkout;
@@ -26,2 +27,4 @@

+        $discounts = collect();
+
         if ($checkout->discount_code !== null) {
@@ -30,19 +33,40 @@
             if ($discount?->isAvailable()) {
-                $result = $this->discounts->calculate($discount, $subtotal, $lines->map(fn ($line): array => [
-                    'line_id' => $line->id,
-                    'amount' => $line->unit_price_amount * $line->quantity,
-                    'product_id' => $line->variant->product_id,
-                    'collection_ids' => $line->variant->product->collections->modelKeys(),
-                ])->all());
-                $discountAmount = $result->amount;
-                $freeShipping = $result->freeShipping;
+                $discounts->push($discount);
+            }
+        }

-                foreach ($lines as $line) {
-                    $lineDiscount = $result->allocations[$line->id] ?? 0;
-                    $line->updateQuietly(['line_discount_amount' => $lineDiscount, 'line_total_amount' => max(0, $line->line_subtotal_amount - $lineDiscount)]);
-                }
+        Discount::withoutGlobalScopes()
+            ->where('store_id', $checkout->store_id)
+            ->where('type', DiscountType::Automatic)
+            ->get()
+            ->filter(fn (Discount $discount): bool => $discount->isAvailable())
+            ->each(fn (Discount $discount): mixed => $discounts->push($discount));
+
+        foreach ($discounts as $discount) {
+            $currentSubtotal = (int) $lines->sum('line_total_amount');
+            $minimum = (int) ($discount->rules_json['min_purchase_amount'] ?? $discount->rules_json['minimum_purchase_amount'] ?? 0);
+
+            if ($currentSubtotal < $minimum) {
+                continue;
             }
+
+            $result = $this->discounts->calculate($discount, $currentSubtotal, $lines->map(fn ($line): array => [
+                'line_id' => $line->id,
+                'amount' => $line->line_total_amount,
+                'product_id' => $line->variant->product_id,
+                'collection_ids' => $line->variant->product->collections->modelKeys(),
+            ])->all());
+            $discountAmount += $result->amount;
+            $freeShipping = $freeShipping || $result->freeShipping;
+
+            foreach ($lines as $line) {
+                $lineDiscount = $result->allocations[$line->id] ?? 0;
+                $line->updateQuietly([
+                    'line_discount_amount' => $line->line_discount_amount + $lineDiscount,
+                    'line_total_amount' => max(0, $line->line_total_amount - $lineDiscount),
+                ]);
+            }
         }

-        $shippingAmount = $checkout->shippingRate !== null && ! $freeShipping ? $this->shipping->calculate($checkout->shippingRate, $cart) : 0;
+        $shippingAmount = $checkout->shippingRate !== null && ! $freeShipping ? ($this->shipping->calculate($checkout->shippingRate, $cart) ?? 0) : 0;
         $taxSettings = TaxSettings::withoutGlobalScopes()->firstOrCreate(['store_id' => $checkout->store_id], ['default_rate_basis_points' => 0]);
@@ -64,3 +88,12 @@
         $result = new PricingResult($subtotal, $discountAmount, $shippingAmount, $taxLines, $taxTotal, $total, $cart->currency);
-        $checkout->update(['totals_json' => $result->toArray()]);
+        $checkout->update([
+            'totals_json' => $result->toArray(),
+            'tax_provider_snapshot_json' => [
+                'provider' => $taxSettings->provider,
+                'mode' => $taxSettings->mode,
+                'rates' => $taxSettings->rates_json ?? [],
+                'captured_at' => now()->toIso8601String(),
+                'tax_total' => $taxTotal,
+            ],
+        ]);

stdout:
Success. Updated the following files:
M /Users/wesner/Herd/shop/app/Http/Controllers/Api/StorefrontCheckoutController.php
M /Users/wesner/Herd/shop/app/Services/PricingEngine.php
M /Users/wesner/Herd/shop/app/Services/TaxCalculator.php
M /Users/wesner/Herd/shop/app/Services/ShippingCalculator.php


text:
Script completed
Wall time 0.0 seconds
Output:


status: completed

input:
const r = await tools.exec_command({
  cmd: "sed -n '1,260p' tests/Feature/CommerceValidationAndCleanupTest.php; sed -n '1,220p' tests/Feature/CommerceFlowTest.php; sed -n '1,220p' app/Services/OrderService.php; sed -n '1,180p' app/Services/InventoryService.php",
  workdir: "/Users/wesner/Herd/shop",
  yield_time_ms: 1000,
  max_output_tokens: 32000
});
text(r.output);


command: sed -n '1,260p' tests/Feature/CommerceValidationAndCleanupTest.php; sed -n '1,220p' tests/Feature/CommerceFlowTest.php; sed -n '1,220p' app/Services/OrderService.php; sed -n '1,180p' app/Services/InventoryService.php

InventoryService.php

stdout:
<?php

use App\Enums\CheckoutStatus;
use App\Jobs\CleanupAbandonedCarts;
use App\Jobs\ExpireAbandonedCheckouts;
use App\Models\Checkout;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\ShippingRate;
use App\Models\Store;
use App\Services\CartService;
use App\Services\InventoryService;
use Database\Seeders\ShopSeeder;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter;

uses(LazilyRefreshDatabase::class);

beforeEach(function (): void {
    config(['cache.default' => 'array']);
    $this->seed(ShopSeeder::class);
    $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    app()->instance('current_store', $this->store);
    $this->variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail();
});

test('checkout addresses validate every required nested field', function (): void {
    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json();
    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", [
        'variant_id' => $this->variant->getKey(),
        'quantity' => 1,
        'cart_version' => 1,
    ])->assertCreated();
    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', [
        'cart_id' => $cart['id'],
        'email' => 'address-validation@example.test',
    ])->assertCreated()->json();

    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", [
        'shipping_address' => ['first_name' => 'Incomplete'],
    ])->assertUnprocessable()->assertJsonValidationErrors([
        'shipping_address.last_name',
        'shipping_address.address1',
        'shipping_address.city',
        'shipping_address.country',
        'shipping_address.country_code',
        'shipping_address.postal_code',
    ]);

    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", [
        'shipping_address' => [
            'first_name' => 'Jane',
            'last_name' => 'Doe',
            'address1' => '123 Main St',
            'city' => 'Berlin',
            'country' => 'Germany',
            'country_code' => 'DE',
            'postal_code' => '10115',
        ],
        'use_shipping_as_billing' => false,
    ])->assertUnprocessable()->assertJsonValidationErrors([
        'billing_address',
        'billing_address.first_name',
    ]);
});

test('credit card payments require all card fields while other payment methods do not', function (): void {
    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json();
    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", [
        'variant_id' => $this->variant->getKey(),
        'quantity' => 1,
        'cart_version' => 1,
    ])->assertCreated();
    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', [
        'cart_id' => $cart['id'],
        'email' => 'card-validation@example.test',
    ])->assertCreated()->json();
    $address = [
        'first_name' => 'Jane',
        'last_name' => 'Doe',
        'address1' => '123 Main St',
        'city' => 'Berlin',
        'country' => 'Germany',
        'country_code' => 'DE',
        'postal_code' => '10115',
    ];
    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", ['shipping_address' => $address])->assertOk();
    $rate = ShippingRate::query()->firstOrFail();
    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/shipping-method", ['shipping_method_id' => $rate->getKey()])->assertOk();

    $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/pay", [
        'payment_method' => 'credit_card',
        'card_number' => '4242 4242 4242 4242',
    ])->assertUnprocessable()->assertJsonValidationErrors([
        'card_expiry',
        'card_cvc',
        'card_holder',
    ]);
});

test('abandoned cart cleanup does not release inventory owned by another checkout', function (): void {
    $carts = app(CartService::class);
    $inventory = InventoryItem::query()->where('variant_id', $this->variant->getKey())->firstOrFail();

    $expiredCart = $carts->create($this->store);
    $carts->addLine($expiredCart, $this->variant->getKey(), 1);
    $expiredCheckout = Checkout::withoutGlobalScopes()->create([
        'store_id' => $this->store->getKey(),
        'cart_id' => $expiredCart->getKey(),
        'email' => 'expired@example.test',
        'status' => CheckoutStatus::PaymentSelected,
        'expires_at' => now()->subMinute(),
    ]);
    app(InventoryService::class)->reserve($inventory, 1);

    app(ExpireAbandonedCheckouts::class)->handle(app(InventoryService::class));

    $activeCart = $carts->create($this->store);
    $carts->addLine($activeCart, $this->variant->getKey(), 1);
    $activeCheckout = Checkout::withoutGlobalScopes()->create([
        'store_id' => $this->store->getKey(),
        'cart_id' => $activeCart->getKey(),
        'email' => 'active@example.test',
        'status' => CheckoutStatus::PaymentSelected,
        'expires_at' => now()->addHour(),
    ]);
    app(InventoryService::class)->reserve($inventory->refresh(), 1);
    DB::table('carts')->where('id', $expiredCart->getKey())->update(['updated_at' => now()->subDays(15)]);

    app(CleanupAbandonedCarts::class)->handle(app(InventoryService::class));

    expect($expiredCheckout->refresh()->status)->toBe(CheckoutStatus::Expired)
        ->and($activeCheckout->refresh()->status)->toBe(CheckoutStatus::PaymentSelected)
        ->and($expiredCart->refresh()->status->value)->toBe('abandoned')
        ->and($inventory->refresh()->quantity_reserved)->toBe(1);
});

test('commerce rate limiters match the specification', function (): void {
    $request = Request::create('/api/storefront/v1/search', 'GET', [], [], [], ['REMOTE_ADDR' => '192.0.2.1']);
    $search = RateLimiter::limiter('search');
    $webhooks = RateLimiter::limiter('webhooks');

    expect($search)->not->toBeNull()
        ->and($webhooks)->not->toBeNull()
        ->and($search($request)->maxAttempts)->toBe(30)
        ->and($webhooks($request)->maxAttempts)->toBe(100)
        ->and($search($request)->decaySeconds)->toBe(60)
        ->and($webhooks($request)->decaySeconds)->toBe(60);
});
<?php

use App\Enums\PaymentStatus;
use App\Models\InventoryItem;
use App\Models\Order;
use App\Models\Product;
use App\Models\ShippingRate;
use App\Models\Store;
use App\Services\OrderService;
use Database\Seeders\ShopSeeder;

uses(\Illuminate\Foundation\Testing\LazilyRefreshDatabase::class);

beforeEach(function (): void {
    config(['cache.default' => 'array']);
    $this->seed(ShopSeeder::class);
    $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    app()->instance('current_store', $this->store);
});

test('guest cart and card checkout create a paid order', function (): void {
    $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail();

    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts', ['currency' => 'EUR'])
        ->assertCreated()
        ->json();

    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", [
        'variant_id' => $variant->getKey(),
        'quantity' => 1,
        'cart_version' => 1,
    ])->assertCreated();

    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', [
        'cart_id' => $cart['id'],
        'email' => 'flow@example.test',
    ])->assertCreated()->json();

    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", [
        'shipping_address' => [
            'first_name' => 'Flow',
            'last_name' => 'Tester',
            'address1' => '1 Test Street',
            'city' => 'Berlin',
            'country' => 'Germany',
            'country_code' => 'DE',
            'postal_code' => '10115',
        ],
    ])->assertOk()->assertJsonPath('status', 'addressed');

    $rate = ShippingRate::query()->firstOrFail();
    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/shipping-method", [
        'shipping_method_id' => $rate->getKey(),
    ])->assertOk()->assertJsonPath('status', 'shipping_selected');

    $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/pay", [
        'payment_method' => 'credit_card',
        'card_number' => '4242424242424242',
        'card_expiry' => '12/28',
        'card_cvc' => '123',
        'card_holder' => 'Flow Tester',
    ])->assertOk()->assertJsonPath('order.financial_status', 'paid');

    $order = Order::query()->latest('id')->firstOrFail();

    expect($order->payment_method)->toBe('credit_card')
        ->and($order->financial_status->value)->toBe('paid')
        ->and($order->payments()->firstOrFail()->status)->toBe(PaymentStatus::Captured)
        ->and($order->checkout->status->value)->toBe('completed');
});

test('declined payments release the reservation and do not create an order', function (): void {
    $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail();
    $inventoryBefore = InventoryItem::query()->where('variant_id', $variant->getKey())->firstOrFail()->quantity_reserved;

    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json();
    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1]);
    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'declined@example.test'])->json();
    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", ['shipping_address' => ['first_name' => 'Declined', 'last_name' => 'Tester', 'address1' => '1 Test Street', 'city' => 'Berlin', 'country' => 'Germany', 'country_code' => 'DE', 'postal_code' => '10115']]);
    $rate = ShippingRate::query()->firstOrFail();
    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/shipping-method", ['shipping_method_id' => $rate->getKey()]);

    $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/pay", ['payment_method' => 'credit_card', 'card_number' => '4000000000000002', 'card_expiry' => '12/28', 'card_cvc' => '123', 'card_holder' => 'Declined Tester'])->assertUnprocessable();

    expect(InventoryItem::query()->where('variant_id', $variant->getKey())->firstOrFail()->quantity_reserved)->toBe($inventoryBefore)
        ->and(Order::query()->where('email', 'declined@example.test')->exists())->toBeFalse();
});

test('stale cart versions return a conflict response', function (): void {
    $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail();
    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json();

    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1])->assertCreated();
    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1])->assertConflict();
});

test('guest cart ids authorize stateless access within the current tenant', function (): void {
    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->assertCreated()->json();

    $this->flushSession();

    $this->getJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}")
        ->assertOk()
        ->assertJsonPath('id', $cart['id']);
});

test('guest cart ids cannot cross tenant boundaries', function (): void {
    $otherStore = Store::factory()->create();
    $otherCart = \App\Models\Cart::withoutEvents(fn (): \App\Models\Cart => \App\Models\Cart::withoutGlobalScopes()->create([
        'store_id' => $otherStore->getKey(),
        'currency' => 'USD',
        'cart_version' => 1,
        'status' => 'active',
    ]));

    $this->flushSession();

    $this->getJson("http://shop.test/api/storefront/v1/carts/{$otherCart->getKey()}")
        ->assertNotFound();
});

test('cart and checkout APIs return domain errors as unprocessable responses', function (): void {
    $soldOutVariant = Product::query()->where('handle', 'sold-out-limited-tee')->firstOrFail()->variants()->firstOrFail();
    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json();

    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", [
        'variant_id' => $soldOutVariant->getKey(),
        'quantity' => 1,
        'cart_version' => 1,
    ])->assertUnprocessable()->assertJsonPath('code', 'insufficient_inventory');

    $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail();
    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1])->assertCreated();
    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'discount-error@example.test'])->assertCreated()->json();

    $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/apply-discount", ['code' => 'MISSING'])
        ->assertUnprocessable()
        ->assertJsonPath('code', 'discount_not_found');
});

test('bank transfer keeps inventory reserved until admin confirmation', function (): void {
    $variant = Product::query()->where('handle', 'classic-cotton-t-shirt')->firstOrFail()->variants()->firstOrFail();
    $cart = $this->postJson('http://shop.test/api/storefront/v1/carts')->json();
    $this->postJson("http://shop.test/api/storefront/v1/carts/{$cart['id']}/lines", ['variant_id' => $variant->getKey(), 'quantity' => 1, 'cart_version' => 1]);
    $checkout = $this->postJson('http://shop.test/api/storefront/v1/checkouts', ['cart_id' => $cart['id'], 'email' => 'bank@example.test'])->json();
    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/address", ['shipping_address' => ['first_name' => 'Bank', 'last_name' => 'Tester', 'address1' => '1 Test Street', 'city' => 'Berlin', 'country' => 'Germany', 'country_code' => 'DE', 'postal_code' => '10115']]);
    $rate = ShippingRate::query()->firstOrFail();
    $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/shipping-method", ['shipping_method_id' => $rate->getKey()]);

    $response = $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkout['id']}/pay", ['payment_method' => 'bank_transfer'])->assertOk()->json();
    $order = Order::query()->whereKey($response['order']['id'])->firstOrFail();
    $inventory = InventoryItem::query()->where('variant_id', $variant->getKey())->firstOrFail();

    expect($order->financial_status->value)->toBe('pending')->and($inventory->quantity_reserved)->toBe(1);

    app(OrderService::class)->confirmPayment($order);
    $inventory = $inventory->refresh();

    expect($order->refresh()->financial_status->value)->toBe('paid')
        ->and($inventory->quantity_on_hand)->toBe(79)
        ->and($inventory->quantity_reserved)->toBe(0);
});
<?php

namespace App\Services;

use App\Enums\FinancialStatus;
use App\Enums\FulfillmentStatus;
use App\Enums\OrderStatus;
use App\Enums\PaymentStatus;
use App\Events\CheckoutCompleted;
use App\Events\OrderCancelled;
use App\Events\OrderCreated;
use App\Events\OrderPaid;
use App\Models\Checkout;
use App\Models\Customer;
use App\Models\Discount;
use App\Models\Order;
use App\Models\Store;
use App\ValueObjects\PaymentResult;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

class OrderService
{
    public function __construct(private readonly InventoryService $inventory, private readonly AuditLogger $audit) {}

    public function createFromCheckout(Checkout $checkout, ?PaymentResult $paymentResult = null): Order
    {
        return DB::transaction(function () use ($checkout, $paymentResult): Order {
            $checkout->load(['cart.lines.variant.product', 'cart.lines.variant.inventory', 'shippingRate']);
            $existing = Order::withoutGlobalScopes()->where('checkout_id', $checkout->getKey())->first();

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

            $totals = $checkout->totals_json ?? ['subtotal' => 0, 'discount' => 0, 'shipping' => 0, 'tax' => 0, 'total' => 0, 'currency' => $checkout->cart->currency];
            if ($checkout->customer_id === null) {
                $customer = Customer::withoutGlobalScopes()->firstOrCreate(
                    ['store_id' => $checkout->store_id, 'email' => strtolower($checkout->email)],
                    ['first_name' => 'Guest', 'last_name' => 'Customer', 'status' => 'active', 'metadata' => ['guest_checkout' => true]],
                );
                $checkout->update(['customer_id' => $customer->getKey()]);
            }

            $discount = $checkout->discount_code === null ? null : Discount::withoutGlobalScopes()->where('store_id', $checkout->store_id)->whereRaw('lower(code) = ?', [strtolower($checkout->discount_code)])->first();
            $status = $paymentResult?->status === PaymentStatus::Captured ? FinancialStatus::Paid : FinancialStatus::Pending;
            $taxByLine = $this->allocateTaxLines($checkout->cart->lines, $totals['tax_lines'] ?? []);
            $order = Order::withoutGlobalScopes()->create([
                'store_id' => $checkout->store_id,
                'customer_id' => $checkout->customer_id,
                'checkout_id' => $checkout->getKey(),
                'order_number' => $this->generateOrderNumber(Store::findOrFail($checkout->store_id)),
                'currency' => $totals['currency'],
                'status' => $status === FinancialStatus::Paid ? OrderStatus::Paid : OrderStatus::Pending,
                'financial_status' => $status,
                'fulfillment_status' => 'unfulfilled',
                'payment_method' => $checkout->payment_method,
                'email' => $checkout->email,
                'shipping_address_json' => $checkout->shipping_address_json,
                'billing_address_json' => $checkout->billing_address_json,
                'subtotal_amount' => $totals['subtotal'],
                'discount_amount' => $totals['discount'] ?? 0,
                'shipping_amount' => $totals['shipping'] ?? 0,
                'tax_amount' => $totals['tax'] ?? 0,
                'total_amount' => $totals['total'],
                'placed_at' => now(),
            ]);

            foreach ($checkout->cart->lines as $line) {
                $order->lines()->create([
                    'product_id' => $line->variant->product_id,
                    'variant_id' => $line->variant_id,
                    'product_title' => $line->variant->product->title,
                    'title_snapshot' => $line->variant->product->title.' · '.$line->variant->title,
                    'variant_title' => $line->variant->title,
                    'sku' => $line->variant->sku,
                    'sku_snapshot' => $line->variant->sku,
                    'quantity' => $line->quantity,
                    'unit_price_amount' => $line->unit_price_amount,
                    'line_subtotal_amount' => $line->line_subtotal_amount,
                    'line_discount_amount' => $line->line_discount_amount,
                    'line_total_amount' => $line->line_total_amount,
                    'tax_lines_json' => $taxByLine[$line->getKey()] ?? [],
                    'discount_allocations_json' => $discount === null || $line->line_discount_amount < 1 ? [] : [['discount_id' => $discount->getKey(), 'amount' => $line->line_discount_amount]],
                ]);

                if ($paymentResult?->status === PaymentStatus::Captured && $line->variant->inventory !== null) {
                    $this->inventory->commit($line->variant->inventory, $line->quantity);
                }
            }

            $checkout->update(['status' => 'completed']);
            $checkout->cart->update(['status' => 'converted']);
            CheckoutCompleted::dispatch($checkout->refresh(), $order);
            OrderCreated::dispatch($order);
            $this->audit->record('order.created', $order, ['store_id' => $order->store_id, 'order_number' => $order->order_number]);
            $order->load(['lines', 'payments']);

            if ($status === FinancialStatus::Paid) {
                OrderPaid::dispatch($order);
                $this->audit->record('order.paid', $order, ['store_id' => $order->store_id, 'order_number' => $order->order_number]);

                if ($checkout->cart->lines->every(fn ($line): bool => ! $line->variant->requires_shipping)) {
                    $fulfillment = $order->fulfillments()->create(['status' => 'delivered', 'fulfilled_at' => now(), 'delivered_at' => now()]);

                    foreach ($order->lines as $line) {
                        $fulfillment->lines()->create(['order_line_id' => $line->getKey(), 'quantity' => $line->quantity]);
                    }

                    $order->update(['status' => OrderStatus::Fulfilled, 'fulfillment_status' => FulfillmentStatus::Fulfilled]);
                }
            }

            return $order;
        });
    }

    public function generateOrderNumber(Store $store): string
    {
        $lastNumber = Order::withoutGlobalScopes()->where('store_id', $store->getKey())->selectRaw("max(cast(replace(order_number, '#', '') as integer)) as value")->value('value');

        return (string) config('shop.order_prefix', '#').(max(1000, (int) $lastNumber) + 1);
    }

    public function cancel(Order $order, string $reason): void
    {
        if ($order->fulfillment_status !== FulfillmentStatus::Unfulfilled) {
            throw new \LogicException('Fulfilled orders cannot be cancelled.');
        }

        DB::transaction(function () use ($order, $reason): void {
            $wasPending = $order->financial_status === FinancialStatus::Pending;
            $order->load('lines.variant.inventory')->update(['status' => OrderStatus::Cancelled, 'financial_status' => $order->financial_status === FinancialStatus::Pending ? FinancialStatus::Voided : $order->financial_status, 'metadata' => array_merge($order->metadata ?? [], ['cancellation_reason' => $reason])]);

            foreach ($order->lines as $line) {
                if ($line->variant?->inventory !== null && $wasPending) {
                    $this->inventory->release($line->variant->inventory, $line->quantity);
                }
            }

            $order->payments()->whereIn('status', [PaymentStatus::Pending, PaymentStatus::Authorized])->update(['status' => PaymentStatus::Failed]);

            OrderCancelled::dispatch($order->refresh());
            $this->audit->record('order.cancelled', $order, ['store_id' => $order->store_id, 'order_number' => $order->order_number, 'reason' => $reason]);
        });
    }

    public function confirmPayment(Order $order): void
    {
        if ($order->payment_method !== 'bank_transfer' || $order->financial_status !== FinancialStatus::Pending) {
            throw new \LogicException('Only pending bank transfer orders can be confirmed.');
        }

        DB::transaction(function () use ($order): void {
            $order->load('lines.variant.inventory')->update(['financial_status' => FinancialStatus::Paid, 'status' => OrderStatus::Paid]);

            foreach ($order->lines as $line) {
                if ($line->variant?->inventory !== null) {
                    $this->inventory->commit($line->variant->inventory, $line->quantity);
                }
            }

            $order->payments()->where('status', PaymentStatus::Pending)->update(['status' => PaymentStatus::Captured]);
            OrderPaid::dispatch($order->refresh());
            $this->audit->record('order.paid', $order, ['store_id' => $order->store_id, 'order_number' => $order->order_number]);

            if ($order->lines->every(fn ($line): bool => ! $line->variant?->requires_shipping)) {
                $this->autoFulfillDigitalOrder($order->refresh());
            }
        });
    }

    private function autoFulfillDigitalOrder(Order $order): void
    {
        if ($order->fulfillments()->exists()) {
            return;
        }

        $fulfillment = $order->fulfillments()->create(['status' => 'delivered', 'fulfilled_at' => now(), 'delivered_at' => now()]);
        foreach ($order->lines as $line) {
            $fulfillment->lines()->create(['order_line_id' => $line->getKey(), 'quantity' => $line->quantity]);
        }
        $order->update(['status' => OrderStatus::Fulfilled, 'fulfillment_status' => FulfillmentStatus::Fulfilled]);
    }

    /**
     * @param  \Illuminate\Support\Collection<int, \App\Models\CartLine>  $lines
     * @param  array<int, array{title?: string, rate?: int, amount?: int}>  $taxLines
     * @return array<int, array<int, array<string, mixed>>>
     */
    private function allocateTaxLines(Collection $lines, array $taxLines): array
    {
        $base = max(1, (int) $lines->sum('line_total_amount'));
        $allocations = $lines->mapWithKeys(fn ($line): array => [$line->getKey() => []])->all();

        foreach ($taxLines as $taxLine) {
            $remaining = (int) ($taxLine['amount'] ?? 0);
            $lineCount = $lines->count();

            foreach ($lines->values() as $index => $line) {
                $amount = $index === $lineCount - 1
                    ? $remaining
                    : intdiv((int) ($taxLine['amount'] ?? 0) * (int) $line->line_total_amount, $base);
                $remaining -= $amount;

                if ($amount > 0) {
                    $allocations[$line->getKey()][] = [...$taxLine, 'amount' => $amount];
                }
            }
        }

        return $allocations;
    }
}
<?php

namespace App\Services;

use App\Exceptions\InsufficientInventoryException;
use App\Models\InventoryItem;
use Illuminate\Support\Facades\DB;

class InventoryService
{
    public function checkAvailability(InventoryItem $item, int $quantity): bool
    {
        return $item->canSell($quantity);
    }

    public function reserve(InventoryItem $item, int $quantity): void
    {
        $this->assertPositiveQuantity($quantity);

        DB::transaction(function () use ($item, $quantity): void {
            $lockedItem = InventoryItem::withoutGlobalScopes()->lockForUpdate()->findOrFail($item->getKey());

            if (! $lockedItem->canSell($quantity)) {
                throw new InsufficientInventoryException;
            }

            $lockedItem->increment('quantity_reserved', $quantity);
        });
    }

    public function release(InventoryItem $item, int $quantity): void
    {
        $this->assertPositiveQuantity($quantity);

        DB::transaction(function () use ($item, $quantity): void {
            $lockedItem = InventoryItem::withoutGlobalScopes()->lockForUpdate()->findOrFail($item->getKey());
            $lockedItem->update(['quantity_reserved' => max(0, $lockedItem->quantity_reserved - $quantity)]);
        });
    }

    public function commit(InventoryItem $item, int $quantity): void
    {
        $this->assertPositiveQuantity($quantity);

        DB::transaction(function () use ($item, $quantity): void {
            $lockedItem = InventoryItem::withoutGlobalScopes()->lockForUpdate()->findOrFail($item->getKey());
            $lockedItem->update([
                'quantity_on_hand' => $lockedItem->quantity_on_hand - $quantity,
                'quantity_reserved' => max(0, $lockedItem->quantity_reserved - $quantity),
            ]);
        });
    }

    public function restock(InventoryItem $item, int $quantity): void
    {
        $this->assertPositiveQuantity($quantity);

        DB::transaction(function () use ($item, $quantity): void {
            $lockedItem = InventoryItem::withoutGlobalScopes()->lockForUpdate()->findOrFail($item->getKey());
            $lockedItem->increment('quantity_on_hand', $quantity);
        });
    }

    private function assertPositiveQuantity(int $quantity): void
    {
        if ($quantity < 1) {
            throw new \InvalidArgumentException('Inventory quantity must be positive.');
        }
    }
}