text:
Script completed
Wall time 0.1 seconds
Output:

text:
<?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.');
        }
    }
}


exec

status: completed

input:
const r = await tools.exec_command({
  cmd: "sed -n '1,180p' app/Models/Store.php; sed -n '1,130p' database/factories/StoreSettingsFactory.php; sed -n '1,120p' app/Livewire/Storefront/CartDrawer.php; sed -n '1,260p' resources/views/livewire/storefront/cart-drawer.blade.php",
  workdir: "/Users/wesner/Herd/shop",
  yield_time_ms: 1000,
  max_output_tokens: 26000
});
text(r.output);


type: CommandExecution

command: sed -n '1,180p' app/Models/Store.php; sed -n '1,130p' database/factories/StoreSettingsFactory.php; sed -n '1,120p' app/Livewire/Storefront/CartDrawer.php; sed -n '1,260p' resources/views/livewire/storefront/cart-drawer.blade.php

Store.php

type: read

cmd: sed -n '1,180p' app/Models/Store.php

cart-drawer.blade.php

status: completed

stdout:
<?php

namespace App\Models;

use App\Enums\StoreStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;

class Store extends Model
{
    use HasFactory;

    protected $fillable = ['organization_id', 'name', 'handle', 'status', 'default_currency', 'default_locale', 'timezone', 'primary_domain', 'metadata'];

    protected $attributes = [
        'status' => StoreStatus::Active->value,
        'default_currency' => 'USD',
        'default_locale' => 'en',
        'timezone' => 'UTC',
    ];

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

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

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

    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class, 'store_users')->using(StoreUser::class)->withPivot('role')->withTimestamps();
    }

    public function settings(): HasOne
    {
        return $this->hasOne(StoreSettings::class);
    }

    public function isActive(): bool
    {
        return $this->status === StoreStatus::Active;
    }
}
<?php

namespace Database\Factories;

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

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\StoreSettings>
 */
class StoreSettingsFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'settings_json' => [],
            'general_json' => [],
            'checkout_json' => [],
            'notification_json' => [],
            'social_json' => [],
        ];
    }
}
<?php

namespace App\Livewire\Storefront;

use App\Exceptions\InsufficientInventoryException;
use App\Exceptions\InvalidDiscountException;
use App\Models\Cart;
use App\Services\CartService;
use App\Services\CheckoutService;
use App\Services\DiscountService;
use Livewire\Attributes\On;
use Livewire\Component;

class CartDrawer extends Component
{
    public bool $open = false;

    public Cart $cart;

    public string $discountCode = '';

    public int $discountAmount = 0;

    public string $message = '';

    public function mount(CartService $carts): void
    {
        $this->cart = $carts->getOrCreateForSession(app('current_store'), auth('customer')->user());
        $this->discountCode = (string) ($this->cart->discount_code ?? '');
    }

    #[On('open-cart-drawer')]
    public function open(): void
    {
        $this->refreshCart();
        $this->open = true;
    }

    #[On('cart-updated')]
    public function refreshCart(): void
    {
        $this->cart = app(CartService::class)->getOrCreateForSession(app('current_store'), auth('customer')->user());
        $this->discountCode = (string) ($this->cart->discount_code ?? '');
        $this->discountAmount = $this->calculateDiscountAmount();
        $this->open = true;
    }

    public function increase(int $lineId): void
    {
        $line = $this->cart->lines->firstWhere('id', $lineId);

        if ($line === null) {
            return;
        }

        try {
            app(CartService::class)->updateLineQuantity($this->cart, $lineId, $line->quantity + 1);
            $this->refreshCart();
        } catch (InsufficientInventoryException $exception) {
            $this->addError('cart', $exception->getMessage());
        }
    }

    public function decrease(int $lineId): void
    {
        $line = $this->cart->lines->firstWhere('id', $lineId);

        if ($line === null || $line->quantity < 2) {
            return;
        }

        app(CartService::class)->updateLineQuantity($this->cart, $lineId, $line->quantity - 1);
        $this->refreshCart();
    }

    public function remove(int $lineId): void
    {
        if (! $this->cart->lines->contains('id', $lineId)) {
            return;
        }

        app(CartService::class)->removeLine($this->cart, $lineId);
        $this->message = 'Item removed from your cart.';
        $this->refreshCart();
    }

    public function applyDiscount(DiscountService $discounts): void
    {
        $this->validate(['discountCode' => ['required', 'string', 'max:64']]);

        try {
            $discount = $discounts->validate(trim($this->discountCode), app('current_store'), $this->cart);
            $this->cart->update(['discount_code' => $discount->code]);
            $this->message = 'Discount applied.';
            $this->resetValidation('discountCode');
            $this->refreshCart();
        } catch (InvalidDiscountException $exception) {
            $this->addError('discountCode', $exception->getMessage());
        }
    }

    public function removeDiscount(): void
    {
        $this->cart->update(['discount_code' => null]);
        $this->discountCode = '';
        $this->message = 'Discount removed.';
        $this->resetValidation('discountCode');
        $this->refreshCart();
    }

    public function checkout(CheckoutService $checkouts): void
    {
        if ($this->cart->lines->isEmpty()) {
            $this->addError('cart', 'Your cart is empty.');

            return;
        }

        $checkout = $checkouts->create($this->cart, auth('customer')->user()?->email ?? 'guest@example.com', auth('customer')->user());
        $this->redirect(route('checkout.show', $checkout), navigate: true);

Your cart
<button type="button" class="rounded-full border px-3 py-2" wire:click="close" aria-label="Close cart">Close</button>

        @if($cart->lines->isEmpty())

Your cart is empty
Browse collections

@else

@foreach($cart->lines as $line)
id }}">
⌂

variant->product->handle) }}" wire:navigate wire:click="close">{{ $line->variant->product->title }}
{{ $line->variant->title }}

<button type="button" wire:click="decrease({{ $line->id }})" class="h-8 w-8 rounded-full border" aria-label="Decrease quantity">−</button>{{ $line->quantity }}<button type="button" wire:click="increase({{ $line->id }})" class="h-8 w-8 rounded-full border" aria-label="Increase quantity">+</button><button type="button" wire:click="remove({{ $line->id }})" class="ml-auto text-xs text-red-600 underline">Remove</button>

{{ $this->formatMoney($line->line_total_amount) }}

@endforeach

            @error('cart')
{{ $message }}
@enderror
            @error('discountCode')
{{ $message }}
@enderror
            @if($message)
{{ $message }}
@endif

<form wire:submit="applyDiscount" class="flex gap-2">Discount code<input id="drawer-discount" type="text" wire:model.blur="discountCode" placeholder="Discount code" class="min-w-0 flex-1 rounded-lg border px-3 py-2 text-sm dark:bg-zinc-950"><button class="rounded-lg border px-3 py-2 text-sm font-semibold">Apply</button></form>

Subtotal

{{ $this->formatMoney($cart->subtotalAmount()) }}

@if($discountAmount > 0)

Discount

-{{ $this->formatMoney($discountAmount) }}

@endif

Total before shipping

{{ $this->formatMoney(max(0, $cart->subtotalAmount() - $discountAmount)) }}

<button type="button" wire:click="checkout" wire:loading.attr="disabled" class="mt-4 block w-full rounded-full bg-blue-600 px-5 py-3 text-center font-semibold text-white">CheckoutStarting checkout…</button>View cart

        @endif


text:
Script completed
Wall time 0.1 seconds
Output:

text:
<?php

namespace App\Models;

use App\Enums\StoreStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;

class Store extends Model
{
    use HasFactory;

    protected $fillable = ['organization_id', 'name', 'handle', 'status', 'default_currency', 'default_locale', 'timezone', 'primary_domain', 'metadata'];

    protected $attributes = [
        'status' => StoreStatus::Active->value,
        'default_currency' => 'USD',
        'default_locale' => 'en',
        'timezone' => 'UTC',
    ];

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

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

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

    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class, 'store_users')->using(StoreUser::class)->withPivot('role')->withTimestamps();
    }

    public function settings(): HasOne
    {
        return $this->hasOne(StoreSettings::class);
    }

    public function isActive(): bool
    {
        return $this->status === StoreStatus::Active;
    }
}
<?php

namespace Database\Factories;

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

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\StoreSettings>
 */
class StoreSettingsFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'store_id' => Store::factory(),
            'settings_json' => [],
            'general_json' => [],
            'checkout_json' => [],
            'notification_json' => [],
            'social_json' => [],
        ];
    }
}
<?php

namespace App\Livewire\Storefront;

use App\Exceptions\InsufficientInventoryException;
use App\Exceptions\InvalidDiscountException;
use App\Models\Cart;
use App\Services\CartService;
use App\Services\CheckoutService;
use App\Services\DiscountService;
use Livewire\Attributes\On;
use Livewire\Component;

class CartDrawer extends Component
{
    public bool $open = false;

    public Cart $cart;

    public string $discountCode = '';

    public int $discountAmount = 0;

    public string $message = '';

    public function mount(CartService $carts): void
    {
        $this->cart = $carts->getOrCreateForSession(app('current_store'), auth('customer')->user());
        $this->discountCode = (string) ($this->cart->discount_code ?? '');
    }

    #[On('open-cart-drawer')]
    public function open(): void
    {
        $this->refreshCart();
        $this->open = true;
    }

    #[On('cart-updated')]
    public function refreshCart(): void
    {
        $this->cart = app(CartService::class)->getOrCreateForSession(app('current_store'), auth('customer')->user());
        $this->discountCode = (string) ($this->cart->discount_code ?? '');
        $this->discountAmount = $this->calculateDiscountAmount();
        $this->open = true;
    }

    public function increase(int $lineId): void
    {
        $line = $this->cart->lines->firstWhere('id', $lineId);

        if ($line === null) {
            return;
        }

        try {
            app(CartService::class)->updateLineQuantity($this->cart, $lineId, $line->quantity + 1);
            $this->refreshCart();
        } catch (InsufficientInventoryException $exception) {
            $this->addError('cart', $exception->getMessage());
        }
    }

    public function decrease(int $lineId): void
    {
        $line = $this->cart->lines->firstWhere('id', $lineId);

        if ($line === null || $line->quantity < 2) {
            return;
        }

        app(CartService::class)->updateLineQuantity($this->cart, $lineId, $line->quantity - 1);
        $this->refreshCart();
    }

    public function remove(int $lineId): void
    {
        if (! $this->cart->lines->contains('id', $lineId)) {
            return;
        }

        app(CartService::class)->removeLine($this->cart, $lineId);
        $this->message = 'Item removed from your cart.';
        $this->refreshCart();
    }

    public function applyDiscount(DiscountService $discounts): void
    {
        $this->validate(['discountCode' => ['required', 'string', 'max:64']]);

        try {
            $discount = $discounts->validate(trim($this->discountCode), app('current_store'), $this->cart);
            $this->cart->update(['discount_code' => $discount->code]);
            $this->message = 'Discount applied.';
            $this->resetValidation('discountCode');
            $this->refreshCart();
        } catch (InvalidDiscountException $exception) {
            $this->addError('discountCode', $exception->getMessage());
        }
    }

    public function removeDiscount(): void
    {
        $this->cart->update(['discount_code' => null]);
        $this->discountCode = '';
        $this->message = 'Discount removed.';
        $this->resetValidation('discountCode');
        $this->refreshCart();
    }

    public function checkout(CheckoutService $checkouts): void
    {
        if ($this->cart->lines->isEmpty()) {
            $this->addError('cart', 'Your cart is empty.');

            return;
        }

        $checkout = $checkouts->create($this->cart, auth('customer')->user()?->email ?? 'guest@example.com', auth('customer')->user());
        $this->redirect(route('checkout.show', $checkout), navigate: true);

Your cart
<button type="button" class="rounded-full border px-3 py-2" wire:click="close" aria-label="Close cart">Close</button>

        @if($cart->lines->isEmpty())

Your cart is empty
Browse collections

@else

@foreach($cart->lines as $line)
id }}">
⌂

variant->product->handle) }}" wire:navigate wire:click="close">{{ $line->variant->product->title }}
{{ $line->variant->title }}

<button type="button" wire:click="decrease({{ $line->id }})" class="h-8 w-8 rounded-full border" aria-label="Decrease quantity">−</button>{{ $line->quantity }}<button type="button" wire:click="increase({{ $line->id }})" class="h-8 w-8 rounded-full border" aria-label="Increase quantity">+</button><button type="button" wire:click="remove({{ $line->id }})" class="ml-auto text-xs text-red-600 underline">Remove</button>

{{ $this->formatMoney($line->line_total_amount) }}

@endforeach

            @error('cart')
{{ $message }}
@enderror
            @error('discountCode')
{{ $message }}
@enderror
            @if($message)
{{ $message }}
@endif

<form wire:submit="applyDiscount" class="flex gap-2">Discount code<input id="drawer-discount" type="text" wire:model.blur="discountCode" placeholder="Discount code" class="min-w-0 flex-1 rounded-lg border px-3 py-2 text-sm dark:bg-zinc-950"><button class="rounded-lg border px-3 py-2 text-sm font-semibold">Apply</button></form>

Subtotal

{{ $this->formatMoney($cart->subtotalAmount()) }}

@if($discountAmount > 0)

Discount

-{{ $this->formatMoney($discountAmount) }}

@endif

Total before shipping

{{ $this->formatMoney(max(0, $cart->subtotalAmount() - $discountAmount)) }}

<button type="button" wire:click="checkout" wire:loading.attr="disabled" class="mt-4 block w-full rounded-full bg-blue-600 px-5 py-3 text-center font-semibold text-white">CheckoutStarting checkout…</button>View cart

        @endif


exec

status: completed

input:
const r = await tools.exec_command({
  cmd: "sed -n '100,220p' app/Livewire/Storefront/CartDrawer.php",
  workdir: "/Users/wesner/Herd/shop",
  yield_time_ms: 1000,
  max_output_tokens: 6000
});
text(r.output);


command: sed -n '100,220p' app/Livewire/Storefront/CartDrawer.php

CartDrawer.php

stdout:
    }

    public function removeDiscount(): void
    {
        $this->cart->update(['discount_code' => null]);
        $this->discountCode = '';
        $this->message = 'Discount removed.';
        $this->resetValidation('discountCode');
        $this->refreshCart();
    }

    public function checkout(CheckoutService $checkouts): void
    {
        if ($this->cart->lines->isEmpty()) {
            $this->addError('cart', 'Your cart is empty.');

            return;
        }

        $checkout = $checkouts->create($this->cart, auth('customer')->user()?->email ?? 'guest@example.com', auth('customer')->user());
        $this->redirect(route('checkout.show', $checkout), navigate: true);
    }

    public function formatMoney(int|float $amount): string
    {
        return number_format((float) $amount / 100, 2, '.', ',').' '.($this->cart->currency ?: 'EUR');
    }

    public function close(): void
    {
        $this->open = false;
    }

    public function render(): mixed
    {
        return view('livewire.storefront.cart-drawer');
    }

    private function calculateDiscountAmount(): int
    {
        if ($this->cart->discount_code === null || $this->cart->lines->isEmpty()) {
            return 0;
        }

        try {
            $discount = app(DiscountService::class)->validate($this->cart->discount_code, app('current_store'), $this->cart);
        } catch (InvalidDiscountException) {
            return 0;
        }

        return app(DiscountService::class)->calculate($discount, (int) $this->cart->lines->sum('line_subtotal_amount'), $this->cart->lines->map(fn ($line): array => [
            'line_id' => $line->id,
            'amount' => $line->line_subtotal_amount,
            'product_id' => $line->variant->product_id,
            'collection_ids' => $line->variant->product->collections->modelKeys(),
        ])->all())->amount;
    }
}


text:
Script completed
Wall time 0.1 seconds
Output:

text:
    }

    public function removeDiscount(): void
    {
        $this->cart->update(['discount_code' => null]);
        $this->discountCode = '';
        $this->message = 'Discount removed.';
        $this->resetValidation('discountCode');
        $this->refreshCart();
    }

    public function checkout(CheckoutService $checkouts): void
    {
        if ($this->cart->lines->isEmpty()) {
            $this->addError('cart', 'Your cart is empty.');

            return;
        }

        $checkout = $checkouts->create($this->cart, auth('customer')->user()?->email ?? 'guest@example.com', auth('customer')->user());
        $this->redirect(route('checkout.show', $checkout), navigate: true);
    }

    public function formatMoney(int|float $amount): string
    {
        return number_format((float) $amount / 100, 2, '.', ',').' '.($this->cart->currency ?: 'EUR');
    }

    public function close(): void
    {
        $this->open = false;
    }

    public function render(): mixed
    {
        return view('livewire.storefront.cart-drawer');
    }

    private function calculateDiscountAmount(): int
    {
        if ($this->cart->discount_code === null || $this->cart->lines->isEmpty()) {
            return 0;
        }

        try {
            $discount = app(DiscountService::class)->validate($this->cart->discount_code, app('current_store'), $this->cart);
        } catch (InvalidDiscountException) {
            return 0;
        }

        return app(DiscountService::class)->calculate($discount, (int) $this->cart->lines->sum('line_subtotal_amount'), $this->cart->lines->map(fn ($line): array => [
            'line_id' => $line->id,
            'amount' => $line->line_subtotal_amount,
            'product_id' => $line->variant->product_id,
            'collection_ids' => $line->variant->product->collections->modelKeys(),
        ])->all())->amount;
    }
}


exec

status: completed

input:
const r = await tools.exec_command({
  cmd: "sed -n '1,300p' app/Services/CartService.php; rg -n \"cleanup|abandoned|bank_transfer\" specs/09-IMPLEMENTATION-ROADMAP.md specs/05-BUSINESS-LOGIC.md | head -100",
  workdir: "/Users/wesner/Herd/shop",
  yield_time_ms: 1000,
  max_output_tokens: 20000
});
text(r.output);


command: sed -n '1,300p' app/Services/CartService.php; rg -n "cleanup|abandoned|bank_transfer" specs/09-IMPLEMENTATION-ROADMAP.md specs/05-BUSINESS-LOGIC.md | head -100

CartService.php

type: search

cmd: rg -n 'cleanup|abandoned|bank_transfer' specs/09-IMPLEMENTATION-ROADMAP.md specs/05-BUSINESS-LOGIC.md

stdout:
<?php

namespace App\Services;

use App\Enums\ProductStatus;
use App\Enums\VariantStatus;
use App\Exceptions\CartVersionConflictException;
use App\Exceptions\InsufficientInventoryException;
use App\Models\Cart;
use App\Models\CartLine;
use App\Models\Customer;
use App\Models\ProductVariant;
use App\Models\Store;
use Illuminate\Support\Facades\DB;

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

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

    public function addLine(Cart $cart, int $variantId, int $quantity): CartLine
    {
        if ($quantity < 1 || $quantity > 9999) {
            throw new \InvalidArgumentException('Quantity must be between 1 and 9999.');
        }

        return DB::transaction(function () use ($cart, $variantId, $quantity): CartLine {
            $cart = Cart::withoutGlobalScopes()->lockForUpdate()->with('lines')->findOrFail($cart->getKey());
            $variant = ProductVariant::with(['product', 'inventory'])->findOrFail($variantId);

            if ($variant->product->store_id !== $cart->store_id || $variant->product->status !== ProductStatus::Active || $variant->status !== VariantStatus::Active) {
                abort(404);
            }

            $line = $cart->lines->firstWhere('variant_id', $variant->getKey());
            $newQuantity = ($line?->quantity ?? 0) + $quantity;

            if ($variant->inventory !== null && ! $variant->inventory->canSell($newQuantity)) {
                throw new InsufficientInventoryException;
            }

            $line ??= new CartLine(['cart_id' => $cart->getKey(), 'variant_id' => $variant->getKey()]);
            $line->fill([
                'quantity' => $newQuantity,
                'unit_price_amount' => $variant->price_amount,
                'line_subtotal_amount' => $variant->price_amount * $newQuantity,
                'line_discount_amount' => 0,
                'line_total_amount' => $variant->price_amount * $newQuantity,
            ])->save();
            $cart->increment('cart_version');

            return $line->load('variant.product');
        });
    }

    public function updateLineQuantity(Cart $cart, int $lineId, int $quantity): CartLine
    {
        if ($quantity < 1 || $quantity > 9999) {
            throw new \InvalidArgumentException('Quantity must be between 1 and 9999.');
        }

        return DB::transaction(function () use ($cart, $lineId, $quantity): CartLine {
            $line = $cart->lines()->with(['variant.inventory'])->findOrFail($lineId);

            if ($line->variant->inventory !== null && ! $line->variant->inventory->canSell($quantity)) {
                throw new InsufficientInventoryException;
            }

            $line->update(['quantity' => $quantity, 'line_subtotal_amount' => $line->unit_price_amount * $quantity, 'line_total_amount' => $line->unit_price_amount * $quantity]);
            $cart->increment('cart_version');

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

    public function removeLine(Cart $cart, int $lineId): void
    {
        DB::transaction(function () use ($cart, $lineId): void {
            $cart->lines()->findOrFail($lineId)->delete();
            $cart->increment('cart_version');
        });
    }

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

    public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart
    {
        $key = 'cart_id_'.$store->getKey();
        $cart = $customer?->carts()->where('status', 'active')->latest()->first();

        if ($cart === null) {
            $sessionCartId = session($key, session('cart_id'));
            $cart = Cart::withoutGlobalScopes()->whereKey($sessionCartId)->where('store_id', $store->getKey())->where('status', 'active')->first();
        }

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

        return $cart->load(['lines.variant.product', 'lines.variant.inventory']);
    }

    public function mergeOnLogin(Cart $guest, Cart $customer): Cart
    {
        if ($guest->store_id !== $customer->store_id) {
            throw new \InvalidArgumentException('Carts must belong to the same store.');
        }

        DB::transaction(function () use ($guest, $customer): void {
            $guest = Cart::withoutGlobalScopes()->lockForUpdate()->with('lines.variant.product')->findOrFail($guest->getKey());
            $customer = Cart::withoutGlobalScopes()->lockForUpdate()->with('lines')->findOrFail($customer->getKey());
            $customerLines = $customer->lines->keyBy('variant_id');

            foreach ($guest->lines as $guestLine) {
                $variant = $guestLine->variant;

                if ($variant === null || $variant->product === null || $variant->product->status !== ProductStatus::Active || $variant->status !== VariantStatus::Active) {
                    continue;
                }

                $customerLine = $customerLines->get($guestLine->variant_id);
                $quantity = max((int) ($customerLine?->quantity ?? 0), (int) $guestLine->quantity);

                if ($variant->inventory !== null && ! $variant->inventory->canSell($quantity)) {
                    continue;
                }

                $attributes = [
                    'quantity' => $quantity,
                    'unit_price_amount' => $variant->price_amount,
                    'line_subtotal_amount' => $variant->price_amount * $quantity,
                    'line_discount_amount' => 0,
                    'line_total_amount' => $variant->price_amount * $quantity,
                ];

                if ($customerLine === null) {
                    $customerLine = $customer->lines()->create(['variant_id' => $variant->getKey(), ...$attributes]);
                    $customerLines->put($variant->getKey(), $customerLine);
                } else {
                    $customerLine->update($attributes);
                }

                $customer->increment('cart_version');
            }

            $guest->update(['status' => 'abandoned']);
            session()->forget(['cart_id_'.$guest->store_id, 'cart_id']);
        });

        return $customer->refresh()->load('lines');
    }
}
specs/05-BUSINESS-LOGIC.md:241:| **Release** | Checkout expires or is abandoned | No change | -quantity | None |
specs/05-BUSINESS-LOGIC.md:261:    active --> abandoned : Cleanup job (14 days inactive)
specs/05-BUSINESS-LOGIC.md:281:    MARK guestCart as abandoned
specs/05-BUSINESS-LOGIC.md:342:- Marks them as `abandoned`.
specs/05-BUSINESS-LOGIC.md:494:- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.
specs/05-BUSINESS-LOGIC.md:532:       ELSE IF checkout.payment_method = 'bank_transfer':
specs/05-BUSINESS-LOGIC.md:560:       // Note: for bank_transfer, inventory stays reserved until admin confirms payment
specs/05-BUSINESS-LOGIC.md:947:**Enum:** `App\Enums\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)
specs/05-BUSINESS-LOGIC.md:953:| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |
specs/05-BUSINESS-LOGIC.md:1016:When `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:
specs/05-BUSINESS-LOGIC.md:1030:Admin users can confirm that a bank transfer payment has been received via a "Confirm Payment" button on the order detail page (visible only when `payment_method = bank_transfer` AND `financial_status = pending`).
specs/05-BUSINESS-LOGIC.md:1046:- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.
specs/05-BUSINESS-LOGIC.md:1047:- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.
specs/05-BUSINESS-LOGIC.md:1077:| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |
specs/05-BUSINESS-LOGIC.md:1260:| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |
specs/05-BUSINESS-LOGIC.md:1266:When a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):
specs/05-BUSINESS-LOGIC.md:1618:| `App\Jobs\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |
specs/05-BUSINESS-LOGIC.md:1680:| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |
specs/05-BUSINESS-LOGIC.md:1682:| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |
specs/09-IMPLEMENTATION-ROADMAP.md:584:| Job | `App\Jobs\CleanupAbandonedCarts` | Runs daily; marks carts with `status = active` and `updated_at < 14 days ago` as `abandoned` |
specs/09-IMPLEMENTATION-ROADMAP.md:668:| `charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult` | Processes payment in-process. For credit_card: validates magic card numbers, returns success/decline. For paypal: always succeeds. For bank_transfer: returns pending (deferred capture). |
specs/09-IMPLEMENTATION-ROADMAP.md:686:| Job | `App\Jobs\CancelUnpaidBankTransferOrders` | Runs daily via `routes/console.php`; finds bank_transfer orders with `financial_status = pending` and `placed_at < now() - config days`; cancels them and releases reserved inventory |
specs/09-IMPLEMENTATION-ROADMAP.md:689:1. Validate order has `payment_method = bank_transfer` and `financial_status = pending`
specs/09-IMPLEMENTATION-ROADMAP.md:1185:| merges guest cart into customer cart on login | guest has cart with variant A qty 2; customer has cart with variant A qty 1 and variant B qty 3; login | customer cart has variant A qty 3, variant B qty 3; guest cart abandoned | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1293:| merges guest cart into customer cart on login | guest cart: variant A qty 2; customer cart: variant A qty 1, variant B qty 3; merge | customer cart has A qty 3, B qty 3; guest cart status=abandoned | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1445:| creates pending payment for bank transfer | checkout with payment_method=bank_transfer | PaymentResult success=true, status=pending | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1466:| admin can confirm bank transfer payment | order with payment_method=bank_transfer, financial_status=pending | financial_status transitions to paid, inventory committed | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1469:| auto-cancel job cancels unpaid bank transfer orders after config days | order with payment_method=bank_transfer, placed_at > 7 days ago | order cancelled, inventory released | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1470:| auto-cancel job does not cancel orders within config days | order with payment_method=bank_transfer, placed_at = 2 days ago | order remains pending | edge case |
specs/09-IMPLEMENTATION-ROADMAP.md:1471:| auto-fulfills digital products on payment confirmation | bank_transfer order with only digital items, confirm payment | fulfillment auto-created with status=delivered | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1849:| `CleanupAbandonedCarts` | Mark old active carts as abandoned |


text:
Script completed
Wall time 0.1 seconds
Output:

text:
<?php

namespace App\Services;

use App\Enums\ProductStatus;
use App\Enums\VariantStatus;
use App\Exceptions\CartVersionConflictException;
use App\Exceptions\InsufficientInventoryException;
use App\Models\Cart;
use App\Models\CartLine;
use App\Models\Customer;
use App\Models\ProductVariant;
use App\Models\Store;
use Illuminate\Support\Facades\DB;

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

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

    public function addLine(Cart $cart, int $variantId, int $quantity): CartLine
    {
        if ($quantity < 1 || $quantity > 9999) {
            throw new \InvalidArgumentException('Quantity must be between 1 and 9999.');
        }

        return DB::transaction(function () use ($cart, $variantId, $quantity): CartLine {
            $cart = Cart::withoutGlobalScopes()->lockForUpdate()->with('lines')->findOrFail($cart->getKey());
            $variant = ProductVariant::with(['product', 'inventory'])->findOrFail($variantId);

            if ($variant->product->store_id !== $cart->store_id || $variant->product->status !== ProductStatus::Active || $variant->status !== VariantStatus::Active) {
                abort(404);
            }

            $line = $cart->lines->firstWhere('variant_id', $variant->getKey());
            $newQuantity = ($line?->quantity ?? 0) + $quantity;

            if ($variant->inventory !== null && ! $variant->inventory->canSell($newQuantity)) {
                throw new InsufficientInventoryException;
            }

            $line ??= new CartLine(['cart_id' => $cart->getKey(), 'variant_id' => $variant->getKey()]);
            $line->fill([
                'quantity' => $newQuantity,
                'unit_price_amount' => $variant->price_amount,
                'line_subtotal_amount' => $variant->price_amount * $newQuantity,
                'line_discount_amount' => 0,
                'line_total_amount' => $variant->price_amount * $newQuantity,
            ])->save();
            $cart->increment('cart_version');

            return $line->load('variant.product');
        });
    }

    public function updateLineQuantity(Cart $cart, int $lineId, int $quantity): CartLine
    {
        if ($quantity < 1 || $quantity > 9999) {
            throw new \InvalidArgumentException('Quantity must be between 1 and 9999.');
        }

        return DB::transaction(function () use ($cart, $lineId, $quantity): CartLine {
            $line = $cart->lines()->with(['variant.inventory'])->findOrFail($lineId);

            if ($line->variant->inventory !== null && ! $line->variant->inventory->canSell($quantity)) {
                throw new InsufficientInventoryException;
            }

            $line->update(['quantity' => $quantity, 'line_subtotal_amount' => $line->unit_price_amount * $quantity, 'line_total_amount' => $line->unit_price_amount * $quantity]);
            $cart->increment('cart_version');

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

    public function removeLine(Cart $cart, int $lineId): void
    {
        DB::transaction(function () use ($cart, $lineId): void {
            $cart->lines()->findOrFail($lineId)->delete();
            $cart->increment('cart_version');
        });
    }

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

    public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart
    {
        $key = 'cart_id_'.$store->getKey();
        $cart = $customer?->carts()->where('status', 'active')->latest()->first();

        if ($cart === null) {
            $sessionCartId = session($key, session('cart_id'));
            $cart = Cart::withoutGlobalScopes()->whereKey($sessionCartId)->where('store_id', $store->getKey())->where('status', 'active')->first();
        }

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

        return $cart->load(['lines.variant.product', 'lines.variant.inventory']);
    }

    public function mergeOnLogin(Cart $guest, Cart $customer): Cart
    {
        if ($guest->store_id !== $customer->store_id) {
            throw new \InvalidArgumentException('Carts must belong to the same store.');
        }

        DB::transaction(function () use ($guest, $customer): void {
            $guest = Cart::withoutGlobalScopes()->lockForUpdate()->with('lines.variant.product')->findOrFail($guest->getKey());
            $customer = Cart::withoutGlobalScopes()->lockForUpdate()->with('lines')->findOrFail($customer->getKey());
            $customerLines = $customer->lines->keyBy('variant_id');

            foreach ($guest->lines as $guestLine) {
                $variant = $guestLine->variant;

                if ($variant === null || $variant->product === null || $variant->product->status !== ProductStatus::Active || $variant->status !== VariantStatus::Active) {
                    continue;
                }

                $customerLine = $customerLines->get($guestLine->variant_id);
                $quantity = max((int) ($customerLine?->quantity ?? 0), (int) $guestLine->quantity);

                if ($variant->inventory !== null && ! $variant->inventory->canSell($quantity)) {
                    continue;
                }

                $attributes = [
                    'quantity' => $quantity,
                    'unit_price_amount' => $variant->price_amount,
                    'line_subtotal_amount' => $variant->price_amount * $quantity,
                    'line_discount_amount' => 0,
                    'line_total_amount' => $variant->price_amount * $quantity,
                ];

                if ($customerLine === null) {
                    $customerLine = $customer->lines()->create(['variant_id' => $variant->getKey(), ...$attributes]);
                    $customerLines->put($variant->getKey(), $customerLine);
                } else {
                    $customerLine->update($attributes);
                }

                $customer->increment('cart_version');
            }

            $guest->update(['status' => 'abandoned']);
            session()->forget(['cart_id_'.$guest->store_id, 'cart_id']);
        });

        return $customer->refresh()->load('lines');
    }
}
specs/05-BUSINESS-LOGIC.md:241:| **Release** | Checkout expires or is abandoned | No change | -quantity | None |
specs/05-BUSINESS-LOGIC.md:261:    active --> abandoned : Cleanup job (14 days inactive)
specs/05-BUSINESS-LOGIC.md:281:    MARK guestCart as abandoned
specs/05-BUSINESS-LOGIC.md:342:- Marks them as `abandoned`.
specs/05-BUSINESS-LOGIC.md:494:- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.
specs/05-BUSINESS-LOGIC.md:532:       ELSE IF checkout.payment_method = 'bank_transfer':
specs/05-BUSINESS-LOGIC.md:560:       // Note: for bank_transfer, inventory stays reserved until admin confirms payment
specs/05-BUSINESS-LOGIC.md:947:**Enum:** `App\Enums\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)
specs/05-BUSINESS-LOGIC.md:953:| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |
specs/05-BUSINESS-LOGIC.md:1016:When `payment_method = bank_transfer`, the order confirmation page and email display mock bank transfer details:
specs/05-BUSINESS-LOGIC.md:1030:Admin users can confirm that a bank transfer payment has been received via a "Confirm Payment" button on the order detail page (visible only when `payment_method = bank_transfer` AND `financial_status = pending`).
specs/05-BUSINESS-LOGIC.md:1046:- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.
specs/05-BUSINESS-LOGIC.md:1047:- Configurable timeout: `store_settings.settings_json` key `bank_transfer_cancel_days`, default = 7.
specs/05-BUSINESS-LOGIC.md:1077:| `bank_transfer` | `pending` | `pending` | Reserve only (reserved incremented, on_hand unchanged) | `pending` |
specs/05-BUSINESS-LOGIC.md:1260:| `bank_transfer` | Inventory reserved only (reserved incremented) | Inventory committed (on_hand decremented, reserved decremented) | No inventory change |
specs/05-BUSINESS-LOGIC.md:1266:When a payment is confirmed (either instant for credit_card/paypal, or via admin confirmation for bank_transfer):
specs/05-BUSINESS-LOGIC.md:1618:| `App\Jobs\CleanupAbandonedCarts` | Daily | Mark stale active carts as abandoned (14-day threshold) |
specs/05-BUSINESS-LOGIC.md:1680:| CartStatus | Active (`active`), Converted (`converted`), Abandoned (`abandoned`) |
specs/05-BUSINESS-LOGIC.md:1682:| PaymentMethod | CreditCard (`credit_card`), Paypal (`paypal`), BankTransfer (`bank_transfer`) |
specs/09-IMPLEMENTATION-ROADMAP.md:584:| Job | `App\Jobs\CleanupAbandonedCarts` | Runs daily; marks carts with `status = active` and `updated_at < 14 days ago` as `abandoned` |
specs/09-IMPLEMENTATION-ROADMAP.md:668:| `charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult` | Processes payment in-process. For credit_card: validates magic card numbers, returns success/decline. For paypal: always succeeds. For bank_transfer: returns pending (deferred capture). |
specs/09-IMPLEMENTATION-ROADMAP.md:686:| Job | `App\Jobs\CancelUnpaidBankTransferOrders` | Runs daily via `routes/console.php`; finds bank_transfer orders with `financial_status = pending` and `placed_at < now() - config days`; cancels them and releases reserved inventory |
specs/09-IMPLEMENTATION-ROADMAP.md:689:1. Validate order has `payment_method = bank_transfer` and `financial_status = pending`
specs/09-IMPLEMENTATION-ROADMAP.md:1185:| merges guest cart into customer cart on login | guest has cart with variant A qty 2; customer has cart with variant A qty 1 and variant B qty 3; login | customer cart has variant A qty 3, variant B qty 3; guest cart abandoned | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1293:| merges guest cart into customer cart on login | guest cart: variant A qty 2; customer cart: variant A qty 1, variant B qty 3; merge | customer cart has A qty 3, B qty 3; guest cart status=abandoned | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1445:| creates pending payment for bank transfer | checkout with payment_method=bank_transfer | PaymentResult success=true, status=pending | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1466:| admin can confirm bank transfer payment | order with payment_method=bank_transfer, financial_status=pending | financial_status transitions to paid, inventory committed | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1469:| auto-cancel job cancels unpaid bank transfer orders after config days | order with payment_method=bank_transfer, placed_at > 7 days ago | order cancelled, inventory released | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1470:| auto-cancel job does not cancel orders within config days | order with payment_method=bank_transfer, placed_at = 2 days ago | order remains pending | edge case |
specs/09-IMPLEMENTATION-ROADMAP.md:1471:| auto-fulfills digital products on payment confirmation | bank_transfer order with only digital items, confirm payment | fulfillment auto-created with status=delivered | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1849:| `CleanupAbandonedCarts` | Mark old active carts as abandoned |


exec

status: completed

input:
const r = await tools.exec_command({
  cmd: "rg -n \"function store\" app/Models/Order.php; sed -n '1,120p' app/Models/Order.php; sed -n '1,120p' app/Models/StoreInvitation.php",
  workdir: "/Users/wesner/Herd/shop",
  yield_time_ms: 1000,
  max_output_tokens: 10000
});
text(r.output);


command: rg -n "function store" app/Models/Order.php; sed -n '1,120p' app/Models/Order.php; sed -n '1,120p' app/Models/StoreInvitation.php

type: search

cmd: rg -n 'function store' app/Models/Order.php

query: function store

Order.php

cmd: sed -n '1,120p' app/Models/Order.php

StoreInvitation.php

stdout:
<?php

namespace App\Models;

use App\Enums\FinancialStatus;
use App\Enums\FulfillmentStatus;
use App\Enums\OrderStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Order extends Model
{
    use BelongsToStore;

    protected $fillable = ['store_id', 'customer_id', 'checkout_id', 'order_number', 'currency', 'status', 'financial_status', 'fulfillment_status', 'payment_method', 'email', 'shipping_address_json', 'billing_address_json', 'subtotal_amount', 'discount_amount', 'shipping_amount', 'tax_amount', 'total_amount', 'placed_at', 'metadata'];

    protected function casts(): array
    {
        return ['status' => OrderStatus::class, 'financial_status' => FinancialStatus::class, 'fulfillment_status' => FulfillmentStatus::class, 'shipping_address_json' => 'array', 'billing_address_json' => 'array', 'placed_at' => 'datetime', 'metadata' => 'array'];
    }

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

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

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

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

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

    public function fulfillments(): HasMany
    {
        return $this->hasMany(Fulfillment::class);
    }
}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class StoreInvitation extends Model
{
    /** @use HasFactory<\Database\Factories\StoreInvitationFactory> */
    use HasFactory;

    protected $fillable = ['store_id', 'email', 'role', 'invited_at', 'expires_at', 'accepted_at'];

    protected function casts(): array
    {
        return ['invited_at' => 'datetime', 'expires_at' => 'datetime', 'accepted_at' => 'datetime'];
    }

    public function store(): \Illuminate\Database\Eloquent\Relations\BelongsTo
    {
        return $this->belongsTo(Store::class);
    }
}


text:
Script completed
Wall time 0.1 seconds
Output:

text:
<?php

namespace App\Models;

use App\Enums\FinancialStatus;
use App\Enums\FulfillmentStatus;
use App\Enums\OrderStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Order extends Model
{
    use BelongsToStore;

    protected $fillable = ['store_id', 'customer_id', 'checkout_id', 'order_number', 'currency', 'status', 'financial_status', 'fulfillment_status', 'payment_method', 'email', 'shipping_address_json', 'billing_address_json', 'subtotal_amount', 'discount_amount', 'shipping_amount', 'tax_amount', 'total_amount', 'placed_at', 'metadata'];

    protected function casts(): array
    {
        return ['status' => OrderStatus::class, 'financial_status' => FinancialStatus::class, 'fulfillment_status' => FulfillmentStatus::class, 'shipping_address_json' => 'array', 'billing_address_json' => 'array', 'placed_at' => 'datetime', 'metadata' => 'array'];
    }

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

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

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

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

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

    public function fulfillments(): HasMany
    {
        return $this->hasMany(Fulfillment::class);
    }
}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class StoreInvitation extends Model
{
    /** @use HasFactory<\Database\Factories\StoreInvitationFactory> */
    use HasFactory;

    protected $fillable = ['store_id', 'email', 'role', 'invited_at', 'expires_at', 'accepted_at'];

    protected function casts(): array
    {
        return ['invited_at' => 'datetime', 'expires_at' => 'datetime', 'accepted_at' => 'datetime'];
    }

    public function store(): \Illuminate\Database\Eloquent\Relations\BelongsTo
    {
        return $this->belongsTo(Store::class);
    }
}


type: AgentMessage

type: Text

text: The focused run exposed three concrete integration issues: the declined-payment exception was not caught by the controller, the analytics test still encoded the old “two page views = two visits” behavior, and one webhook assertion expected the pre-spec timestamp-prefixed signature. Those are being corrected now; I’m also tightening the pricing and shipping paths that the audit identified.

phase: commentary


type: message

role: assistant


exec

status: completed

input:
const patch = "*** Begin Patch\n*** Update File: /Users/wesner/Herd/shop/app/Jobs/CancelUnpaidBankTransferOrders.php\n@@\n use App\\Enums\\FinancialStatus;\n use App\\Models\\Order;\n+use App\\Models\\StoreSettings;\n@@\n-        $days = (int) config('shop.bank_transfer_expiry_days', 7);\n-        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.'));\n+        Order::withoutGlobalScopes()\n+            ->where('payment_method', 'bank_transfer')\n+            ->where('financial_status', FinancialStatus::Pending)\n+            ->with('lines.variant.inventory')\n+            ->each(function (Order $order) use ($orders): void {\n+                $settings = StoreSettings::withoutGlobalScopes()->find($order->store_id);\n+                $days = (int) ($settings?->settings_json['bank_transfer_cancel_days'] ?? config('shop.bank_transfer_expiry_days', 7));\n+\n+                if ($order->placed_at?->isBefore(now()->subDays($days))) {\n+                    $orders->cancel($order, 'Bank transfer payment expired.');\n+                }\n+            });\n*** End Patch";
text(await tools.apply_patch(patch));


type: FileChange

type: update

unified_diff:
@@ -6,2 +6,3 @@
 use App\Models\Order;
+use App\Models\StoreSettings;
 use App\Services\OrderService;
@@ -18,4 +19,14 @@
     {
-        $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.'));
+        Order::withoutGlobalScopes()
+            ->where('payment_method', 'bank_transfer')
+            ->where('financial_status', FinancialStatus::Pending)
+            ->with('lines.variant.inventory')
+            ->each(function (Order $order) use ($orders): void {
+                $settings = StoreSettings::withoutGlobalScopes()->find($order->store_id);
+                $days = (int) ($settings?->settings_json['bank_transfer_cancel_days'] ?? config('shop.bank_transfer_expiry_days', 7));
+
+                if ($order->placed_at?->isBefore(now()->subDays($days))) {
+                    $orders->cancel($order, 'Bank transfer payment expired.');
+                }
+            });
     }

stdout:
Success. Updated the following files:
M /Users/wesner/Herd/shop/app/Jobs/CancelUnpaidBankTransferOrders.php


text:
Script completed
Wall time 0.0 seconds
Output:


status: completed

input:
const r = await tools.exec_command({
  cmd: "find app/Events app/Listeners app/Mail app/Notifications -maxdepth 2 -type f -print 2>/dev/null | sort; sed -n '1,260p' app/Services/FulfillmentService.php; sed -n '1,240p' app/Providers/AppServiceProvider.php",
  workdir: "/Users/wesner/Herd/shop",
  yield_time_ms: 1000,
  max_output_tokens: 26000
});
text(r.output);


command: find app/Events app/Listeners app/Mail app/Notifications -maxdepth 2 -type f -print 2>/dev/null | sort; sed -n '1,260p' app/Services/FulfillmentService.php; sed -n '1,240p' app/Providers/AppServiceProvider.php

type: unknown

stdout:
app/Events/CheckoutAddressed.php
app/Events/CheckoutCompleted.php
app/Events/CheckoutExpired.php
app/Events/CheckoutShippingSelected.php
app/Events/FulfillmentDelivered.php
app/Events/FulfillmentShipped.php
app/Events/OrderCancelled.php
app/Events/OrderCreated.php
app/Events/OrderFulfilled.php
app/Events/OrderPaid.php
app/Events/OrderRefunded.php
app/Events/ProductCreated.php
app/Events/ProductDeleted.php
app/Events/ProductStatusChanged.php
app/Events/ProductUpdated.php
app/Listeners/DispatchWebhooks.php
<?php

namespace App\Services;

use App\Enums\FinancialStatus;
use App\Enums\FulfillmentStatus;
use App\Enums\OrderStatus;
use App\Events\FulfillmentDelivered;
use App\Events\FulfillmentShipped;
use App\Events\OrderFulfilled;
use App\Exceptions\FulfillmentGuardException;
use App\Models\Fulfillment;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

class FulfillmentService
{
    public function create(Order $order, array $lines, ?array $tracking = null): Fulfillment
    {
        if (! in_array($order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true)) {
            throw new FulfillmentGuardException;
        }

        return DB::transaction(function () use ($order, $lines, $tracking): Fulfillment {
            $order->load(['lines', 'fulfillments.lines']);
            $fulfilledQuantities = $order->fulfillments->flatMap->lines->groupBy('order_line_id')->map(fn ($items): int => (int) $items->sum('quantity'));
            $requestedQuantities = [];

            foreach ($lines as $line) {
                $orderLine = $order->lines->firstWhere('id', $line['order_line_id']);
                $quantity = (int) ($line['quantity'] ?? 0);
                $requestedQuantities[$line['order_line_id']] = ($requestedQuantities[$line['order_line_id']] ?? 0) + $quantity;

                if ($orderLine === null || $quantity < 1 || $orderLine->quantity < $requestedQuantities[$line['order_line_id']] + (int) ($fulfilledQuantities[$orderLine->id] ?? 0)) {
                    throw new \InvalidArgumentException('The fulfillment quantity exceeds the unfulfilled quantity.');
                }
            }

            $fulfillment = $order->fulfillments()->create(array_merge($tracking ?? [], ['status' => 'pending']));

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

            $this->refreshOrderStatus($order->refresh());

            return $fulfillment->load('lines.orderLine');
        });
    }

    public function markAsShipped(Fulfillment $fulfillment, ?array $tracking = null): void
    {
        if ($fulfillment->status !== 'pending') {
            throw new \LogicException('Only pending fulfillments can be shipped.');
        }

        $fulfillment->update(array_merge($tracking ?? [], ['status' => 'shipped', 'shipped_at' => now(), 'fulfilled_at' => now()]));
        FulfillmentShipped::dispatch($fulfillment->refresh());
        $this->refreshOrderStatus($fulfillment->load('order')->order);
    }

    public function markAsDelivered(Fulfillment $fulfillment): void
    {
        if ($fulfillment->status !== 'shipped') {
            throw new \LogicException('Only shipped fulfillments can be delivered.');
        }

        $fulfillment->update(['status' => 'delivered', 'delivered_at' => now()]);
        $fulfillment->load('order');
        FulfillmentDelivered::dispatch($fulfillment);

        if ($fulfillment->order !== null) {
            $this->refreshOrderStatus($fulfillment->order);
        }
    }

    private function refreshOrderStatus(Order $order): void
    {
        $order->load(['lines', 'fulfillments.lines']);
        $fulfilledQuantities = [];

        foreach ($order->fulfillments->whereIn('status', ['shipped', 'delivered']) as $fulfillment) {
            foreach ($fulfillment->lines as $line) {
                $fulfilledQuantities[$line->order_line_id] = ($fulfilledQuantities[$line->order_line_id] ?? 0) + $line->quantity;
            }
        }

        $fulfilled = collect($order->lines)->every(fn ($line): bool => ($fulfilledQuantities[$line->id] ?? 0) >= $line->quantity);
        $partial = $fulfilledQuantities !== [];
        $order->update(['fulfillment_status' => $fulfilled ? FulfillmentStatus::Fulfilled : ($partial ? FulfillmentStatus::Partial : FulfillmentStatus::Unfulfilled)]);

        if ($fulfilled) {
            $order->update(['status' => OrderStatus::Fulfilled]);
            OrderFulfilled::dispatch($order->refresh());
        } elseif ($order->status === OrderStatus::Fulfilled) {
            $order->update(['status' => OrderStatus::Paid]);
        }
    }
}
<?php

namespace App\Providers;

use App\Auth\CustomerUserProvider;
use App\Auth\StoreScopedPasswordBrokerManager;
use App\Contracts\PaymentProvider as PaymentProviderContract;
use App\Contracts\TaxProvider;
use App\Events\CheckoutCompleted;
use App\Events\OrderCreated;
use App\Events\OrderFulfilled;
use App\Events\OrderPaid;
use App\Events\OrderRefunded;
use App\Events\ProductCreated;
use App\Events\ProductDeleted;
use App\Events\ProductUpdated;
use App\Listeners\DispatchWebhooks;
use App\Models\Product;
use App\Observers\ProductObserver;
use App\Services\MockPaymentProvider;
use App\Services\Tax\ManualTaxProvider;
use Carbon\CarbonImmutable;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Livewire\Livewire;
use Livewire\Mechanisms\HandleRequests\EndpointResolver;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        $this->app->bind(PaymentProviderContract::class, MockPaymentProvider::class);
        $this->app->bind(TaxProvider::class, ManualTaxProvider::class);
        Auth::provider('customer', fn ($app, array $config): CustomerUserProvider => new CustomerUserProvider($app['hash'], $config['model']));
        $this->app->extend('auth.password', fn ($manager, $app): StoreScopedPasswordBrokerManager => new StoreScopedPasswordBrokerManager($app));
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        $this->configureDefaults();
        Model::preventLazyLoading(! app()->isProduction());
        Product::observe(ProductObserver::class);
        Gate::policy(\App\Models\Collection::class, \App\Policies\CollectionPolicy::class);
        Gate::policy(\App\Models\Customer::class, \App\Policies\CustomerPolicy::class);
        Gate::policy(\App\Models\Discount::class, \App\Policies\DiscountPolicy::class);
        Gate::policy(\App\Models\Order::class, \App\Policies\OrderPolicy::class);
        Gate::policy(\App\Models\Page::class, \App\Policies\PagePolicy::class);
        Gate::policy(\App\Models\Product::class, \App\Policies\ProductPolicy::class);
        Gate::policy(\App\Models\Theme::class, \App\Policies\ThemePolicy::class);
        Event::listen([
            CheckoutCompleted::class,
            OrderCreated::class,
            OrderFulfilled::class,
            OrderPaid::class,
            OrderRefunded::class,
            ProductCreated::class,
            ProductDeleted::class,
            ProductUpdated::class,
        ], DispatchWebhooks::class);
        Livewire::setUpdateRoute(function ($handle): mixed {
            return Route::post(EndpointResolver::updatePath(), $handle)
                ->middleware(['web', 'store.resolve'])
                ->name('shop.livewire.update');
        });
        RateLimiter::for('login', fn (Request $request): Limit => Limit::perMinute(5)->by($request->ip()));
        RateLimiter::for('api.storefront', fn (Request $request): Limit => Limit::perMinute(120)->by($request->ip()));
        RateLimiter::for('api.admin', fn (Request $request): Limit => Limit::perMinute(60)->by($request->user()?->getAuthIdentifier() ?? $request->ip()));
        RateLimiter::for('checkout', fn (Request $request): Limit => Limit::perMinute(10)->by($request->hasSession() ? $request->session()->getId() : $request->ip()));
        RateLimiter::for('search', fn (Request $request): Limit => Limit::perMinute(30)->by($request->ip()));
        RateLimiter::for('analytics', fn (Request $request): Limit => Limit::perMinute(60)->by($request->ip()));
        RateLimiter::for('webhooks', fn (Request $request): Limit => Limit::perMinute(100)->by($request->ip()));
    }

    /**
     * Configure default behaviors for production-ready applications.
     */
    protected function configureDefaults(): void
    {
        Date::use(CarbonImmutable::class);

        DB::prohibitDestructiveCommands(
            app()->isProduction(),
        );

        Password::defaults(fn (): ?Password => app()->isProduction()
            ? Password::min(12)
                ->mixedCase()
                ->letters()
                ->numbers()
                ->symbols()
                ->uncompromised()
            : null
        );
    }
}