stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/app/Services/FulfillmentService.php
M /Users/fabianwesner/Herd/shop/app/Services/RefundService.php

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

+use App\Enums\FinancialStatus;
+use App\Enums\FulfillmentShipmentStatus;
+use App\Enums\FulfillmentStatus;
+use App\Enums\OrderStatus;
+use App\Events\FulfillmentCreated;
+use App\Events\FulfillmentDelivered;
+use App\Events\FulfillmentShipped;
+use App\Exceptions\InvalidFulfillmentOperationException;
+use App\Models\Fulfillment;
+use App\Models\FulfillmentLine;
+use App\Models\Order;
+use App\Models\OrderLine;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+
 class FulfillmentService
@@ -7,7 +22,199 @@
     /**
-     * Create a new class instance.
+     * @param  array<int|string, int>  $lines
+     * @param  array<string, mixed>  $trackingData
      */
-    public function __construct()
+    public function create(Order $order, array $lines, array $trackingData = []): Fulfillment
     {
-        //
+        return DB::transaction(function () use ($order, $lines, $trackingData): Fulfillment {
+            $order = $this->freshOrder($order);
+            $this->assertCanFulfill($order);
+
+            $requestedLines = $this->normalizeLines($lines);
+
+            if ($requestedLines->isEmpty()) {
+                throw InvalidFulfillmentOperationException::because('At least one fulfillment line is required.');
+            }
+
+            $requestedLines->each(function (int $quantity, int $lineId) use ($order): void {
+                $line = $order->lines->firstWhere('id', $lineId);
+
+                if (! $line instanceof OrderLine) {
+                    throw InvalidFulfillmentOperationException::because('Fulfillment line does not belong to this order.');
+                }
+
+                $remaining = $line->quantity - $this->fulfilledQuantity($line);
+
+                if ($quantity > $remaining) {
+                    throw InvalidFulfillmentOperationException::because('Fulfillment quantity exceeds the unfulfilled quantity.');
+                }
+            });
+
+            $fulfillment = Fulfillment::query()->create([
+                'order_id' => $order->getKey(),
+                'status' => FulfillmentShipmentStatus::Pending,
+                'tracking_company' => data_get($trackingData, 'tracking_company'),
+                'tracking_number' => data_get($trackingData, 'tracking_number'),
+                'tracking_url' => data_get($trackingData, 'tracking_url'),
+            ]);
+
+            $requestedLines->each(function (int $quantity, int $lineId) use ($fulfillment): void {
+                $fulfillment->lines()->create([
+                    'order_line_id' => $lineId,
+                    'quantity' => $quantity,
+                ]);
+            });
+
+            $this->updateOrderFulfillmentStatus($order);
+
+            $fulfillment = $fulfillment->refresh()->load('lines.orderLine');
+
+            event(new FulfillmentCreated($fulfillment));
+
+            return $fulfillment;
+        });
+    }
+
+    public function markShipped(Fulfillment $fulfillment): Fulfillment
+    {
+        return DB::transaction(function () use ($fulfillment): Fulfillment {
+            $fulfillment = $this->freshFulfillment($fulfillment);
+
+            if ($fulfillment->status !== FulfillmentShipmentStatus::Pending) {
+                throw InvalidFulfillmentOperationException::because('Only pending fulfillments can be marked as shipped.');
+            }
+
+            $fulfillment->forceFill([
+                'status' => FulfillmentShipmentStatus::Shipped,
+                'shipped_at' => now(),
+            ])->save();
+
+            $fulfillment = $fulfillment->refresh()->load('lines.orderLine');
+
+            event(new FulfillmentShipped($fulfillment));
+
+            return $fulfillment;
+        });
+    }
+
+    public function markDelivered(Fulfillment $fulfillment): Fulfillment
+    {
+        return DB::transaction(function () use ($fulfillment): Fulfillment {
+            $fulfillment = $this->freshFulfillment($fulfillment);
+
+            if ($fulfillment->status !== FulfillmentShipmentStatus::Shipped) {
+                throw InvalidFulfillmentOperationException::because('Only shipped fulfillments can be marked as delivered.');
+            }
+
+            $fulfillment->forceFill([
+                'status' => FulfillmentShipmentStatus::Delivered,
+                'delivered_at' => now(),
+            ])->save();
+
+            $fulfillment = $fulfillment->refresh()->load('lines.orderLine');
+
+            event(new FulfillmentDelivered($fulfillment));
+
+            return $fulfillment;
+        });
+    }
+
+    public function autoFulfillDigital(Order $order): ?Fulfillment
+    {
+        return DB::transaction(function () use ($order): ?Fulfillment {
+            $order = $this->freshOrder($order);
+
+            if ($order->fulfillments()->exists() || $order->lines->isEmpty()) {
+                return null;
+            }
+
+            $allDigital = $order->lines->every(fn (OrderLine $line): bool => $line->variant?->requires_shipping === false);
+
+            if (! $allDigital) {
+                return null;
+            }
+
+            $fulfillment = Fulfillment::query()->create([
+                'order_id' => $order->getKey(),
+                'status' => FulfillmentShipmentStatus::Delivered,
+                'shipped_at' => now(),
+                'delivered_at' => now(),
+            ]);
+
+            $order->lines->each(function (OrderLine $line) use ($fulfillment): void {
+                $fulfillment->lines()->create([
+                    'order_line_id' => $line->getKey(),
+                    'quantity' => $line->quantity,
+                ]);
+            });
+
+            $order->forceFill([
+                'status' => OrderStatus::Fulfilled,
+                'fulfillment_status' => FulfillmentStatus::Fulfilled,
+            ])->save();
+
+            $fulfillment = $fulfillment->refresh()->load('lines.orderLine');
+
+            event(new FulfillmentCreated($fulfillment));
+            event(new FulfillmentDelivered($fulfillment));
+
+            return $fulfillment;
+        });
+    }
+
+    private function freshOrder(Order $order): Order
+    {
+        return Order::withoutGlobalScopes()
+            ->with([
+                'lines.variant' => fn ($query) => $query->withoutGlobalScopes(),
+                'fulfillments.lines',
+            ])
+            ->whereKey($order->getKey())
+            ->lockForUpdate()
+            ->firstOrFail();
+    }
+
+    private function freshFulfillment(Fulfillment $fulfillment): Fulfillment
+    {
+        return Fulfillment::query()
+            ->with('lines.orderLine')
+            ->whereKey($fulfillment->getKey())
+            ->lockForUpdate()
+            ->firstOrFail();
+    }
+
+    private function assertCanFulfill(Order $order): void
+    {
+        if (! in_array($order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true)) {
+            throw InvalidFulfillmentOperationException::because('Fulfillment cannot be created until payment is confirmed.');
+        }
+    }
+
+    /**
+     * @param  array<int|string, int>  $lines
+     * @return Collection<int, int>
+     */
+    private function normalizeLines(array $lines): Collection
+    {
+        return collect($lines)
+            ->mapWithKeys(fn (mixed $quantity, int|string $lineId): array => [(int) $lineId => (int) $quantity])
+            ->filter(fn (int $quantity): bool => $quantity > 0);
+    }
+
+    private function fulfilledQuantity(OrderLine $line): int
+    {
+        return (int) FulfillmentLine::query()
+            ->where('order_line_id', $line->getKey())
+            ->sum('quantity');
+    }
+
+    private function updateOrderFulfillmentStatus(Order $order): void
+    {
+        $allFulfilled = $order->lines->every(function (OrderLine $line): bool {
+            return $this->fulfilledQuantity($line) >= $line->quantity;
+        });
+
+        $order->forceFill([
+            'status' => $allFulfilled ? OrderStatus::Fulfilled : $order->status,
+            'fulfillment_status' => $allFulfilled ? FulfillmentStatus::Fulfilled : FulfillmentStatus::Partial,
+        ])->save();
     }

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

+use App\Enums\FinancialStatus;
+use App\Enums\OrderStatus;
+use App\Enums\PaymentStatus;
+use App\Enums\RefundStatus;
+use App\Events\OrderRefunded;
+use App\Exceptions\InvalidRefundOperationException;
+use App\Models\InventoryItem;
+use App\Models\Order;
+use App\Models\OrderLine;
+use App\Models\Payment;
+use App\Models\Refund;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\DB;
+
 class RefundService
@@ -7,7 +21,186 @@
     /**
-     * Create a new class instance.
+     * @param  array{amount?: int, lines?: array<int|string, int>, reason?: string|null, restock?: bool}  $request
+     */
+    public function __construct(
+        private readonly PaymentService $payments,
+        private readonly InventoryService $inventory,
+    ) {}
+
+    public function process(Order $order, array $request = []): Refund
+    {
+        return DB::transaction(function () use ($order, $request): Refund {
+            $order = $this->freshOrder($order);
+            $payment = $this->refundablePayment($order);
+            $refundable = $this->refundableAmount($order);
+            $lineQuantities = $this->lineQuantities($order, $request['lines'] ?? []);
+            $amount = $this->refundAmount($order, $request, $lineQuantities, $refundable);
+
+            $result = $this->payments->refund($payment, $amount);
+
+            if (! $result->success) {
+                throw InvalidRefundOperationException::because($result->errorMessage ?? 'Refund could not be processed.');
+            }
+
+            $refund = $order->refunds()->create([
+                'payment_id' => $payment->getKey(),
+                'amount' => $amount,
+                'reason' => data_get($request, 'reason'),
+                'status' => $result->status,
+                'provider_refund_id' => $result->referenceId,
+            ]);
+
+            if ((bool) data_get($request, 'restock', false)) {
+                $this->restock($order, $lineQuantities);
+            }
+
+            $this->updateOrderFinancialStatus($order, $payment);
+
+            $order = $order->refresh();
+            $refund = $refund->refresh();
+
+            event(new OrderRefunded($order, $refund));
+
+            return $refund;
+        });
+    }
+
+    private function freshOrder(Order $order): Order
+    {
+        return Order::withoutGlobalScopes()
+            ->with(['lines', 'payments', 'refunds'])
+            ->whereKey($order->getKey())
+            ->lockForUpdate()
+            ->firstOrFail();
+    }
+
+    private function refundablePayment(Order $order): Payment
+    {
+        $payment = $order->payments
+            ->where('status', PaymentStatus::Captured)
+            ->sortByDesc('id')
+            ->first();
+
+        if (! $payment instanceof Payment) {
+            throw InvalidRefundOperationException::because('Order does not have a captured payment to refund.');
+        }
+
+        return $payment;
+    }
+
+    private function refundableAmount(Order $order): int
+    {
+        $refunded = $order->refunds
+            ->reject(fn (Refund $refund): bool => $refund->status === RefundStatus::Failed)
+            ->sum('amount');
+
+        return $order->total_amount - $refunded;
+    }
+
+    /**
+     * @param  array<string, mixed>  $request
+     * @param  Collection<int, int>  $lineQuantities
      */
-    public function __construct()
+    private function refundAmount(Order $order, array $request, Collection $lineQuantities, int $refundable): int
     {
-        //
+        $amount = array_key_exists('amount', $request)
+            ? (int) $request['amount']
+            : ($lineQuantities->isNotEmpty() ? $this->lineRefundAmount($order, $lineQuantities) : $refundable);
+
+        if ($amount <= 0) {
+            throw InvalidRefundOperationException::because('Refund amount must be greater than zero.');
+        }
+
+        if ($amount > $refundable) {
+            throw InvalidRefundOperationException::because('Refund amount exceeds the remaining refundable amount.');
+        }
+
+        return $amount;
+    }
+
+    /**
+     * @param  array<int|string, int>  $lines
+     * @return Collection<int, int>
+     */
+    private function lineQuantities(Order $order, array $lines): Collection
+    {
+        return collect($lines)
+            ->mapWithKeys(fn (mixed $quantity, int|string $lineId): array => [(int) $lineId => (int) $quantity])
+            ->filter(fn (int $quantity): bool => $quantity > 0)
+            ->each(function (int $quantity, int $lineId) use ($order): void {
+                $line = $order->lines->firstWhere('id', $lineId);
+
+                if (! $line instanceof OrderLine) {
+                    throw InvalidRefundOperationException::because('Refund line does not belong to this order.');
+                }
+
+                if ($quantity > $line->quantity) {
+                    throw InvalidRefundOperationException::because('Refund quantity exceeds the ordered quantity.');
+                }
+            });
+    }
+
+    /**
+     * @param  Collection<int, int>  $lineQuantities
+     */
+    private function lineRefundAmount(Order $order, Collection $lineQuantities): int
+    {
+        return $lineQuantities->sum(function (int $quantity, int $lineId) use ($order): int {
+            $line = $order->lines->firstWhere('id', $lineId);
+
+            if (! $line instanceof OrderLine) {
+                return 0;
+            }
+
+            return (int) round($line->total_amount / $line->quantity * $quantity);
+        });
+    }
+
+    /**
+     * @param  Collection<int, int>  $lineQuantities
+     */
+    private function restock(Order $order, Collection $lineQuantities): void
+    {
+        $linesToRestock = $lineQuantities->isNotEmpty()
+            ? $lineQuantities
+            : $order->lines->mapWithKeys(fn (OrderLine $line): array => [$line->getKey() => $line->quantity]);
+
+        $linesToRestock->each(function (int $quantity, int $lineId) use ($order): void {
+            $line = $order->lines->firstWhere('id', $lineId);
+
+            if (! $line instanceof OrderLine || $line->variant_id === null) {
+                return;
+            }
+
+            $item = InventoryItem::withoutGlobalScopes()
+                ->where('variant_id', $line->variant_id)
+                ->first();
+
+            if ($item instanceof InventoryItem) {
+                $this->inventory->restock($item, $quantity);
+            }
+        });
+    }
+
+    private function updateOrderFinancialStatus(Order $order, Payment $payment): void
+    {
+        $totalRefunded = $order->refunds()
+            ->where('status', '!=', RefundStatus::Failed)
+            ->sum('amount');
+
+        if ($totalRefunded >= $order->total_amount) {
+            $order->forceFill([
+                'status' => OrderStatus::Refunded,
+                'financial_status' => FinancialStatus::Refunded,
+            ])->save();
+
+            $payment->forceFill([
+                'status' => PaymentStatus::Refunded,
+            ])->save();
+
+            return;
+        }
+
+        $order->forceFill([
+            'financial_status' => FinancialStatus::PartiallyRefunded,
+        ])->save();
     }


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


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Services/OrderService.php
@@
 use App\Enums\PaymentStatus;
+use App\Events\OrderCancelled;
 use App\Events\OrderCreated;
 use App\Events\OrderPaid;
 use App\Exceptions\InvalidCheckoutTransitionException;
+use App\Exceptions\InvalidOrderOperationException;
 use App\Exceptions\PaymentFailedException;
 use App\Models\CartLine;
 use App\Models\Checkout;
 use App\Models\Discount;
 use App\Models\InventoryItem;
 use App\Models\Order;
+use App\Models\OrderLine;
 use App\Models\ProductOptionValue;
 use App\Models\ProductVariant;
 use App\Models\Store;
@@
         private readonly PaymentService $payments,
         private readonly InventoryService $inventory,
         private readonly PricingEngine $pricing,
+        private readonly FulfillmentService $fulfillments,
     ) {}
@@
                 if ($paidImmediately) {
                     $this->commitReservedInventory($checkout);
                 }
@@
                 $this->incrementDiscountUsage($checkout);

+                if ($paidImmediately) {
+                    $this->fulfillments->autoFulfillDigital($order);
+                }
+
                 $order = $order->refresh()->load(['lines', 'payments', 'refunds', 'fulfillments.lines']);
@@
         }
     }
+
+    public function confirmBankTransferPayment(Order $order): Order
+    {
+        return DB::transaction(function () use ($order): Order {
+            $order = $this->freshOrder($order);
+            $this->assertPendingBankTransfer($order);
+
+            $order->payments()
+                ->where('status', PaymentStatus::Pending)
+                ->update(['status' => PaymentStatus::Captured]);
+
+            $this->commitReservedOrderInventory($order);
+
+            $order->forceFill([
+                'status' => OrderStatus::Paid,
+                'financial_status' => FinancialStatus::Paid,
+            ])->save();
+
+            $this->fulfillments->autoFulfillDigital($order);
+
+            $order = $order->refresh()->load(['lines', 'payments', 'refunds', 'fulfillments.lines']);
+
+            event(new OrderPaid($order));
+
+            return $order;
+        });
+    }
+
+    public function cancelUnpaidBankTransferOrder(Order $order): Order
+    {
+        return DB::transaction(function () use ($order): Order {
+            $order = $this->freshOrder($order);
+            $this->assertPendingBankTransfer($order);
+
+            $this->releaseReservedOrderInventory($order);
+
+            $order->payments()
+                ->where('status', PaymentStatus::Pending)
+                ->update(['status' => PaymentStatus::Failed]);
+
+            $order->forceFill([
+                'status' => OrderStatus::Cancelled,
+                'financial_status' => FinancialStatus::Voided,
+            ])->save();
+
+            $order = $order->refresh()->load(['lines', 'payments', 'refunds', 'fulfillments.lines']);
+
+            event(new OrderCancelled($order));
+
+            return $order;
+        });
+    }
@@
     private function freshCheckout(Checkout $checkout): Checkout
@@
             ->firstOrFail();
     }
+
+    private function freshOrder(Order $order): Order
+    {
+        return Order::withoutGlobalScopes()
+            ->with(['lines', 'payments', 'store.settings'])
+            ->whereKey($order->getKey())
+            ->lockForUpdate()
+            ->firstOrFail();
+    }
@@
     private function commitReservedInventory(Checkout $checkout): void
     {
         $this->cartLines($checkout)->each(function (CartLine $line): void {
-            $this->inventory->commit($this->inventoryItem($line), $line->quantity);
+            $this->inventory->commit($this->inventoryItemForVariant($line->variant_id), $line->quantity);
         });
     }
+
+    private function commitReservedOrderInventory(Order $order): void
+    {
+        $order->lines->each(function (OrderLine $line): void {
+            if ($line->variant_id !== null) {
+                $this->inventory->commit($this->inventoryItemForVariant($line->variant_id), $line->quantity);
+            }
+        });
+    }
+
+    private function releaseReservedOrderInventory(Order $order): void
+    {
+        $order->lines->each(function (OrderLine $line): void {
+            if ($line->variant_id !== null) {
+                $this->inventory->release($this->inventoryItemForVariant($line->variant_id), $line->quantity);
+            }
+        });
+    }
@@
             }

             $this->cartLines($checkout)->each(function (CartLine $line): void {
-                $this->inventory->release($this->inventoryItem($line), $line->quantity);
+                $this->inventory->release($this->inventoryItemForVariant($line->variant_id), $line->quantity);
             });
@@
-    private function inventoryItem(CartLine $line): InventoryItem
+    private function assertPendingBankTransfer(Order $order): void
     {
+        if ($order->payment_method !== PaymentMethod::BankTransfer || $order->financial_status !== FinancialStatus::Pending) {
+            throw InvalidOrderOperationException::because('Order is not a pending bank transfer order.');
+        }
+    }
+
+    private function inventoryItemForVariant(int $variantId): InventoryItem
+    {
         return InventoryItem::withoutGlobalScopes()
-            ->where('variant_id', $line->variant_id)
+            ->where('variant_id', $variantId)
             ->firstOrFail();
     }
*** Update File: /Users/fabianwesner/Herd/shop/app/Jobs/CancelUnpaidBankTransferOrders.php
@@
 namespace App\Jobs;

+use App\Enums\FinancialStatus;
+use App\Enums\PaymentMethod;
+use App\Models\Order;
+use App\Services\OrderService;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Queue\Queueable;

 class CancelUnpaidBankTransferOrders implements ShouldQueue
 {
     use Queueable;

-    /**
-     * Create a new job instance.
-     */
-    public function __construct()
-    {
-        //
-    }
-
     /**
      * Execute the job.
      */
-    public function handle(): void
+    public function handle(OrderService $orders): void
     {
-        //
+        Order::withoutGlobalScopes()
+            ->with('store.settings')
+            ->where('payment_method', PaymentMethod::BankTransfer)
+            ->where('financial_status', FinancialStatus::Pending)
+            ->whereNotNull('placed_at')
+            ->lazyById()
+            ->each(function (Order $order) use ($orders): void {
+                $cancelDays = max(1, (int) data_get($order->store?->settings?->settings_json, 'bank_transfer_cancel_days', 7));
+
+                if ($order->placed_at->lessThanOrEqualTo(now()->subDays($cancelDays))) {
+                    $orders->cancelUnpaidBankTransferOrder($order);
+                }
+            });
     }
 }
*** Update File: /Users/fabianwesner/Herd/shop/routes/console.php
@@
 use App\Jobs\CleanupAbandonedCarts;
+use App\Jobs\CancelUnpaidBankTransferOrders;
 use App\Jobs\ExpireAbandonedCheckouts;
@@
 Schedule::job(new ExpireAbandonedCheckouts)->everyFifteenMinutes();
 Schedule::job(new CleanupAbandonedCarts)->daily();
+Schedule::job(new CancelUnpaidBankTransferOrders)->daily();
*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/app/Services/OrderService.php
M /Users/fabianwesner/Herd/shop/app/Jobs/CancelUnpaidBankTransferOrders.php
M /Users/fabianwesner/Herd/shop/routes/console.php

type: update

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

+use App\Enums\FinancialStatus;
+use App\Enums\PaymentMethod;
+use App\Models\Order;
+use App\Services\OrderService;
 use Illuminate\Contracts\Queue\ShouldQueue;
@@ -12,15 +16,19 @@
     /**
-     * Create a new job instance.
+     * Execute the job.
      */
-    public function __construct()
+    public function handle(OrderService $orders): void
     {
-        //
-    }
+        Order::withoutGlobalScopes()
+            ->with('store.settings')
+            ->where('payment_method', PaymentMethod::BankTransfer)
+            ->where('financial_status', FinancialStatus::Pending)
+            ->whereNotNull('placed_at')
+            ->lazyById()
+            ->each(function (Order $order) use ($orders): void {
+                $cancelDays = max(1, (int) data_get($order->store?->settings?->settings_json, 'bank_transfer_cancel_days', 7));

-    /**
-     * Execute the job.
-     */
-    public function handle(): void
-    {
-        //
+                if ($order->placed_at->lessThanOrEqualTo(now()->subDays($cancelDays))) {
+                    $orders->cancelUnpaidBankTransferOrder($order);
+                }
+            });
     }

unified_diff:
@@ -11,2 +11,3 @@
 use App\Enums\PaymentStatus;
+use App\Events\OrderCancelled;
 use App\Events\OrderCreated;
@@ -14,2 +15,3 @@
 use App\Exceptions\InvalidCheckoutTransitionException;
+use App\Exceptions\InvalidOrderOperationException;
 use App\Exceptions\PaymentFailedException;
@@ -20,2 +22,3 @@
 use App\Models\Order;
+use App\Models\OrderLine;
 use App\Models\ProductOptionValue;
@@ -34,2 +37,3 @@
         private readonly PricingEngine $pricing,
+        private readonly FulfillmentService $fulfillments,
     ) {}
@@ -121,2 +125,6 @@

+                if ($paidImmediately) {
+                    $this->fulfillments->autoFulfillDigital($order);
+                }
+
                 $order = $order->refresh()->load(['lines', 'payments', 'refunds', 'fulfillments.lines']);
@@ -138,2 +146,54 @@

+    public function confirmBankTransferPayment(Order $order): Order
+    {
+        return DB::transaction(function () use ($order): Order {
+            $order = $this->freshOrder($order);
+            $this->assertPendingBankTransfer($order);
+
+            $order->payments()
+                ->where('status', PaymentStatus::Pending)
+                ->update(['status' => PaymentStatus::Captured]);
+
+            $this->commitReservedOrderInventory($order);
+
+            $order->forceFill([
+                'status' => OrderStatus::Paid,
+                'financial_status' => FinancialStatus::Paid,
+            ])->save();
+
+            $this->fulfillments->autoFulfillDigital($order);
+
+            $order = $order->refresh()->load(['lines', 'payments', 'refunds', 'fulfillments.lines']);
+
+            event(new OrderPaid($order));
+
+            return $order;
+        });
+    }
+
+    public function cancelUnpaidBankTransferOrder(Order $order): Order
+    {
+        return DB::transaction(function () use ($order): Order {
+            $order = $this->freshOrder($order);
+            $this->assertPendingBankTransfer($order);
+
+            $this->releaseReservedOrderInventory($order);
+
+            $order->payments()
+                ->where('status', PaymentStatus::Pending)
+                ->update(['status' => PaymentStatus::Failed]);
+
+            $order->forceFill([
+                'status' => OrderStatus::Cancelled,
+                'financial_status' => FinancialStatus::Voided,
+            ])->save();
+
+            $order = $order->refresh()->load(['lines', 'payments', 'refunds', 'fulfillments.lines']);
+
+            event(new OrderCancelled($order));
+
+            return $order;
+        });
+    }
+
     private function freshCheckout(Checkout $checkout): Checkout
@@ -147,2 +207,11 @@

+    private function freshOrder(Order $order): Order
+    {
+        return Order::withoutGlobalScopes()
+            ->with(['lines', 'payments', 'store.settings'])
+            ->whereKey($order->getKey())
+            ->lockForUpdate()
+            ->firstOrFail();
+    }
+
     private function paymentMethod(Checkout $checkout): PaymentMethod
@@ -230,3 +299,3 @@
         $this->cartLines($checkout)->each(function (CartLine $line): void {
-            $this->inventory->commit($this->inventoryItem($line), $line->quantity);
+            $this->inventory->commit($this->inventoryItemForVariant($line->variant_id), $line->quantity);
         });
@@ -234,2 +303,20 @@

+    private function commitReservedOrderInventory(Order $order): void
+    {
+        $order->lines->each(function (OrderLine $line): void {
+            if ($line->variant_id !== null) {
+                $this->inventory->commit($this->inventoryItemForVariant($line->variant_id), $line->quantity);
+            }
+        });
+    }
+
+    private function releaseReservedOrderInventory(Order $order): void
+    {
+        $order->lines->each(function (OrderLine $line): void {
+            if ($line->variant_id !== null) {
+                $this->inventory->release($this->inventoryItemForVariant($line->variant_id), $line->quantity);
+            }
+        });
+    }
+
     private function releaseFailedPaymentReservation(Checkout $checkout): void
@@ -244,3 +331,3 @@
             $this->cartLines($checkout)->each(function (CartLine $line): void {
-                $this->inventory->release($this->inventoryItem($line), $line->quantity);
+                $this->inventory->release($this->inventoryItemForVariant($line->variant_id), $line->quantity);
             });
@@ -269,6 +356,13 @@

-    private function inventoryItem(CartLine $line): InventoryItem
+    private function assertPendingBankTransfer(Order $order): void
+    {
+        if ($order->payment_method !== PaymentMethod::BankTransfer || $order->financial_status !== FinancialStatus::Pending) {
+            throw InvalidOrderOperationException::because('Order is not a pending bank transfer order.');
+        }
+    }
+
+    private function inventoryItemForVariant(int $variantId): InventoryItem
     {
         return InventoryItem::withoutGlobalScopes()
-            ->where('variant_id', $line->variant_id)
+            ->where('variant_id', $variantId)
             ->firstOrFail();

unified_diff:
@@ -3,2 +3,3 @@
 use App\Jobs\CleanupAbandonedCarts;
+use App\Jobs\CancelUnpaidBankTransferOrders;
 use App\Jobs\ExpireAbandonedCheckouts;
@@ -14 +15,2 @@
 Schedule::job(new CleanupAbandonedCarts)->daily();
+Schedule::job(new CancelUnpaidBankTransferOrders)->daily();

status: completed


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/app/Services/OrderService.php\nM /Users/fabianwesner/Herd/shop/app/Jobs/CancelUnpaidBankTransferOrders.php\nM /Users/fabianwesner/Herd/shop/routes/console.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Services/OrderService.php
@@
             $order->payments()
-                ->where('status', PaymentStatus::Pending)
-                ->update(['status' => PaymentStatus::Captured]);
+                ->where('status', PaymentStatus::Pending->value)
+                ->update(['status' => PaymentStatus::Captured->value]);
@@
             $order->payments()
-                ->where('status', PaymentStatus::Pending)
-                ->update(['status' => PaymentStatus::Failed]);
+                ->where('status', PaymentStatus::Pending->value)
+                ->update(['status' => PaymentStatus::Failed->value]);
*** Update File: /Users/fabianwesner/Herd/shop/app/Services/RefundService.php
@@
         $totalRefunded = $order->refunds()
-            ->where('status', '!=', RefundStatus::Failed)
+            ->where('status', '!=', RefundStatus::Failed->value)
             ->sum('amount');
*** Update File: /Users/fabianwesner/Herd/shop/app/Jobs/CancelUnpaidBankTransferOrders.php
@@
         Order::withoutGlobalScopes()
             ->with('store.settings')
-            ->where('payment_method', PaymentMethod::BankTransfer)
-            ->where('financial_status', FinancialStatus::Pending)
+            ->where('payment_method', PaymentMethod::BankTransfer->value)
+            ->where('financial_status', FinancialStatus::Pending->value)
*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/app/Services/OrderService.php
M /Users/fabianwesner/Herd/shop/app/Services/RefundService.php
M /Users/fabianwesner/Herd/shop/app/Jobs/CancelUnpaidBankTransferOrders.php

unified_diff:
@@ -186,3 +186,3 @@
         $totalRefunded = $order->refunds()
-            ->where('status', '!=', RefundStatus::Failed)
+            ->where('status', '!=', RefundStatus::Failed->value)
             ->sum('amount');

unified_diff:
@@ -22,4 +22,4 @@
             ->with('store.settings')
-            ->where('payment_method', PaymentMethod::BankTransfer)
-            ->where('financial_status', FinancialStatus::Pending)
+            ->where('payment_method', PaymentMethod::BankTransfer->value)
+            ->where('financial_status', FinancialStatus::Pending->value)
             ->whereNotNull('placed_at')

unified_diff:
@@ -153,4 +153,4 @@
             $order->payments()
-                ->where('status', PaymentStatus::Pending)
-                ->update(['status' => PaymentStatus::Captured]);
+                ->where('status', PaymentStatus::Pending->value)
+                ->update(['status' => PaymentStatus::Captured->value]);

@@ -182,4 +182,4 @@
             $order->payments()
-                ->where('status', PaymentStatus::Pending)
-                ->update(['status' => PaymentStatus::Failed]);
+                ->where('status', PaymentStatus::Pending->value)
+                ->update(['status' => PaymentStatus::Failed->value]);


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/app/Services/OrderService.php\nM /Users/fabianwesner/Herd/shop/app/Services/RefundService.php\nM /Users/fabianwesner/Herd/shop/app/Jobs/CancelUnpaidBankTransferOrders.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/tests/Feature/Orders/RefundServiceTest.php
@@
 <?php

-test('example', function () {
-    $response = $this->get('/');
-
-    $response->assertStatus(200);
-});
+use App\Enums\FinancialStatus;
+use App\Enums\OrderStatus;
+use App\Enums\PaymentStatus;
+use App\Enums\RefundStatus;
+use App\Events\OrderRefunded;
+use App\Exceptions\InvalidRefundOperationException;
+use App\Models\InventoryItem;
+use App\Models\Order;
+use App\Models\OrderLine;
+use App\Models\Payment;
+use App\Models\Product;
+use App\Models\ProductVariant;
+use App\Models\Store;
+use App\Services\RefundService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Event;
+
+uses(RefreshDatabase::class);
+
+/**
+ * @return array{0: Order, 1: OrderLine, 2: ProductVariant}
+ */
+function refundServiceOrder(int $quantity = 2, int $unitPrice = 5000): array
+{
+    $store = Store::factory()->create();
+    app()->instance('current_store', $store);
+
+    $product = Product::factory()
+        ->withDefaultVariant($unitPrice)
+        ->create(['store_id' => $store->getKey()]);
+    $variant = ProductVariant::withoutGlobalScopes()
+        ->where('product_id', $product->getKey())
+        ->firstOrFail();
+
+    InventoryItem::withoutGlobalScopes()
+        ->where('variant_id', $variant->getKey())
+        ->update([
+            'quantity_on_hand' => 8,
+            'quantity_reserved' => 0,
+        ]);
+
+    $total = $quantity * $unitPrice;
+    $order = Order::factory()->paid()->create([
+        'store_id' => $store->getKey(),
+        'subtotal_amount' => $total,
+        'shipping_amount' => 0,
+        'tax_amount' => 0,
+        'total_amount' => $total,
+    ]);
+    $line = OrderLine::factory()->create([
+        'order_id' => $order->getKey(),
+        'product_id' => $product->getKey(),
+        'variant_id' => $variant->getKey(),
+        'quantity' => $quantity,
+        'unit_price_amount' => $unitPrice,
+        'total_amount' => $total,
+    ]);
+
+    Payment::factory()->create([
+        'order_id' => $order->getKey(),
+        'status' => PaymentStatus::Captured,
+        'amount' => $total,
+    ]);
+
+    return [$order, $line, $variant];
+}
+
+test('refund service processes a partial line refund and restocks inventory', function () {
+    [$order, $line, $variant] = refundServiceOrder();
+
+    Event::fake();
+
+    $refund = app(RefundService::class)->process($order, [
+        'lines' => [$line->getKey() => 1],
+        'reason' => 'Customer return',
+        'restock' => true,
+    ]);
+
+    expect($refund->amount)->toBe(5000)
+        ->and($refund->status)->toBe(RefundStatus::Processed)
+        ->and($refund->provider_refund_id)->toStartWith('mock_refund_')
+        ->and($order->refresh()->status)->toBe(OrderStatus::Paid)
+        ->and($order->financial_status)->toBe(FinancialStatus::PartiallyRefunded)
+        ->and($order->payments()->first()?->status)->toBe(PaymentStatus::Captured)
+        ->and(InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->first()?->quantity_on_hand)->toBe(9);
+
+    Event::assertDispatched(OrderRefunded::class, fn (OrderRefunded $event): bool => $event->refund->is($refund));
+});
+
+test('refund service marks order and payment refunded when the full amount is refunded', function () {
+    [$order] = refundServiceOrder();
+
+    $refund = app(RefundService::class)->process($order);
+
+    expect($refund->amount)->toBe(10000)
+        ->and($order->refresh()->status)->toBe(OrderStatus::Refunded)
+        ->and($order->financial_status)->toBe(FinancialStatus::Refunded)
+        ->and($order->payments()->first()?->status)->toBe(PaymentStatus::Refunded);
+});
+
+test('refund service rejects over-refunds and orders without captured payments', function () {
+    [$order] = refundServiceOrder();
+
+    expect(fn () => app(RefundService::class)->process($order, ['amount' => 10001]))
+        ->toThrow(InvalidRefundOperationException::class);
+
+    $order->payments()->update(['status' => PaymentStatus::Pending]);
+
+    expect(fn () => app(RefundService::class)->process($order, ['amount' => 500]))
+        ->toThrow(InvalidRefundOperationException::class);
+});
*** Update File: /Users/fabianwesner/Herd/shop/tests/Feature/Orders/FulfillmentServiceTest.php
@@
 <?php

-test('example', function () {
-    $response = $this->get('/');
-
-    $response->assertStatus(200);
-});
+use App\Enums\FinancialStatus;
+use App\Enums\FulfillmentShipmentStatus;
+use App\Enums\FulfillmentStatus;
+use App\Enums\OrderStatus;
+use App\Events\FulfillmentCreated;
+use App\Events\FulfillmentDelivered;
+use App\Events\FulfillmentShipped;
+use App\Exceptions\InvalidFulfillmentOperationException;
+use App\Models\Order;
+use App\Models\OrderLine;
+use App\Models\Product;
+use App\Models\ProductVariant;
+use App\Models\Store;
+use App\Services\FulfillmentService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Event;
+
+uses(RefreshDatabase::class);
+
+/**
+ * @return array{0: Order, 1: OrderLine, 2: OrderLine}
+ */
+function fulfillmentServiceOrder(FinancialStatus $financialStatus = FinancialStatus::Paid): array
+{
+    $store = Store::factory()->create();
+    app()->instance('current_store', $store);
+
+    $firstProduct = Product::factory()->withDefaultVariant(2500)->create(['store_id' => $store->getKey()]);
+    $secondProduct = Product::factory()->withDefaultVariant(1500)->create(['store_id' => $store->getKey()]);
+    $firstVariant = ProductVariant::withoutGlobalScopes()->where('product_id', $firstProduct->getKey())->firstOrFail();
+    $secondVariant = ProductVariant::withoutGlobalScopes()->where('product_id', $secondProduct->getKey())->firstOrFail();
+
+    $order = Order::factory()->paid()->create([
+        'store_id' => $store->getKey(),
+        'financial_status' => $financialStatus,
+        'status' => $financialStatus === FinancialStatus::Pending ? OrderStatus::Pending : OrderStatus::Paid,
+    ]);
+    $firstLine = OrderLine::factory()->create([
+        'order_id' => $order->getKey(),
+        'product_id' => $firstProduct->getKey(),
+        'variant_id' => $firstVariant->getKey(),
+        'quantity' => 2,
+        'unit_price_amount' => 2500,
+        'total_amount' => 5000,
+    ]);
+    $secondLine = OrderLine::factory()->create([
+        'order_id' => $order->getKey(),
+        'product_id' => $secondProduct->getKey(),
+        'variant_id' => $secondVariant->getKey(),
+        'quantity' => 1,
+        'unit_price_amount' => 1500,
+        'total_amount' => 1500,
+    ]);
+
+    return [$order, $firstLine, $secondLine];
+}
+
+test('fulfillment service blocks fulfillment until payment is confirmed', function () {
+    [$order, $line] = fulfillmentServiceOrder(FinancialStatus::Pending);
+
+    expect(fn () => app(FulfillmentService::class)->create($order, [$line->getKey() => 1]))
+        ->toThrow(InvalidFulfillmentOperationException::class);
+});
+
+test('fulfillment service creates partial and complete fulfillments', function () {
+    [$order, $firstLine, $secondLine] = fulfillmentServiceOrder();
+
+    Event::fake();
+
+    $firstFulfillment = app(FulfillmentService::class)->create($order, [
+        $firstLine->getKey() => 2,
+    ], [
+        'tracking_company' => 'DHL',
+        'tracking_number' => 'DHL123',
+    ]);
+
+    expect($firstFulfillment->status)->toBe(FulfillmentShipmentStatus::Pending)
+        ->and($order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Partial)
+        ->and($order->status)->toBe(OrderStatus::Paid);
+
+    $secondFulfillment = app(FulfillmentService::class)->create($order, [
+        $secondLine->getKey() => 1,
+    ]);
+
+    expect($secondFulfillment->lines)->toHaveCount(1)
+        ->and($order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled)
+        ->and($order->status)->toBe(OrderStatus::Fulfilled);
+
+    Event::assertDispatched(FulfillmentCreated::class, 2);
+});
+
+test('fulfillment service rejects over-fulfillment and invalid transitions', function () {
+    [$order, $line] = fulfillmentServiceOrder();
+    $fulfillment = app(FulfillmentService::class)->create($order, [$line->getKey() => 1]);
+
+    expect(fn () => app(FulfillmentService::class)->create($order, [$line->getKey() => 2]))
+        ->toThrow(InvalidFulfillmentOperationException::class);
+
+    expect(fn () => app(FulfillmentService::class)->markDelivered($fulfillment))
+        ->toThrow(InvalidFulfillmentOperationException::class);
+});
+
+test('fulfillment service marks shipments as shipped and delivered', function () {
+    [$order, $line] = fulfillmentServiceOrder();
+    $fulfillment = app(FulfillmentService::class)->create($order, [$line->getKey() => 1]);
+
+    Event::fake();
+
+    $shipped = app(FulfillmentService::class)->markShipped($fulfillment);
+    $delivered = app(FulfillmentService::class)->markDelivered($shipped);
+
+    expect($shipped->status)->toBe(FulfillmentShipmentStatus::Shipped)
+        ->and($shipped->shipped_at)->not->toBeNull()
+        ->and($delivered->status)->toBe(FulfillmentShipmentStatus::Delivered)
+        ->and($delivered->delivered_at)->not->toBeNull();
+
+    Event::assertDispatched(FulfillmentShipped::class);
+    Event::assertDispatched(FulfillmentDelivered::class);
+});
*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/tests/Feature/Orders/RefundServiceTest.php
M /Users/fabianwesner/Herd/shop/tests/Feature/Orders/FulfillmentServiceTest.php

unified_diff:
@@ -2,6 +2,120 @@

-test('example', function () {
-    $response = $this->get('/');
+use App\Enums\FinancialStatus;
+use App\Enums\FulfillmentShipmentStatus;
+use App\Enums\FulfillmentStatus;
+use App\Enums\OrderStatus;
+use App\Events\FulfillmentCreated;
+use App\Events\FulfillmentDelivered;
+use App\Events\FulfillmentShipped;
+use App\Exceptions\InvalidFulfillmentOperationException;
+use App\Models\Order;
+use App\Models\OrderLine;
+use App\Models\Product;
+use App\Models\ProductVariant;
+use App\Models\Store;
+use App\Services\FulfillmentService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Event;
+
+uses(RefreshDatabase::class);
+
+/**
+ * @return array{0: Order, 1: OrderLine, 2: OrderLine}
+ */
+function fulfillmentServiceOrder(FinancialStatus $financialStatus = FinancialStatus::Paid): array
+{
+    $store = Store::factory()->create();
+    app()->instance('current_store', $store);
+
+    $firstProduct = Product::factory()->withDefaultVariant(2500)->create(['store_id' => $store->getKey()]);
+    $secondProduct = Product::factory()->withDefaultVariant(1500)->create(['store_id' => $store->getKey()]);
+    $firstVariant = ProductVariant::withoutGlobalScopes()->where('product_id', $firstProduct->getKey())->firstOrFail();
+    $secondVariant = ProductVariant::withoutGlobalScopes()->where('product_id', $secondProduct->getKey())->firstOrFail();
+
+    $order = Order::factory()->paid()->create([
+        'store_id' => $store->getKey(),
+        'financial_status' => $financialStatus,
+        'status' => $financialStatus === FinancialStatus::Pending ? OrderStatus::Pending : OrderStatus::Paid,
+    ]);
+    $firstLine = OrderLine::factory()->create([
+        'order_id' => $order->getKey(),
+        'product_id' => $firstProduct->getKey(),
+        'variant_id' => $firstVariant->getKey(),
+        'quantity' => 2,
+        'unit_price_amount' => 2500,
+        'total_amount' => 5000,
+    ]);
+    $secondLine = OrderLine::factory()->create([
+        'order_id' => $order->getKey(),
+        'product_id' => $secondProduct->getKey(),
+        'variant_id' => $secondVariant->getKey(),
+        'quantity' => 1,
+        'unit_price_amount' => 1500,
+        'total_amount' => 1500,
+    ]);
+
+    return [$order, $firstLine, $secondLine];
+}
+
+test('fulfillment service blocks fulfillment until payment is confirmed', function () {
+    [$order, $line] = fulfillmentServiceOrder(FinancialStatus::Pending);
+
+    expect(fn () => app(FulfillmentService::class)->create($order, [$line->getKey() => 1]))
+        ->toThrow(InvalidFulfillmentOperationException::class);
+});
+
+test('fulfillment service creates partial and complete fulfillments', function () {
+    [$order, $firstLine, $secondLine] = fulfillmentServiceOrder();
+
+    Event::fake();
+
+    $firstFulfillment = app(FulfillmentService::class)->create($order, [
+        $firstLine->getKey() => 2,
+    ], [
+        'tracking_company' => 'DHL',
+        'tracking_number' => 'DHL123',
+    ]);
+
+    expect($firstFulfillment->status)->toBe(FulfillmentShipmentStatus::Pending)
+        ->and($order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Partial)
+        ->and($order->status)->toBe(OrderStatus::Paid);
+
+    $secondFulfillment = app(FulfillmentService::class)->create($order, [
+        $secondLine->getKey() => 1,
+    ]);
+
+    expect($secondFulfillment->lines)->toHaveCount(1)
+        ->and($order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled)
+        ->and($order->status)->toBe(OrderStatus::Fulfilled);
+
+    Event::assertDispatched(FulfillmentCreated::class, 2);
+});
+
+test('fulfillment service rejects over-fulfillment and invalid transitions', function () {
+    [$order, $line] = fulfillmentServiceOrder();
+    $fulfillment = app(FulfillmentService::class)->create($order, [$line->getKey() => 1]);
+
+    expect(fn () => app(FulfillmentService::class)->create($order, [$line->getKey() => 2]))
+        ->toThrow(InvalidFulfillmentOperationException::class);
+
+    expect(fn () => app(FulfillmentService::class)->markDelivered($fulfillment))
+        ->toThrow(InvalidFulfillmentOperationException::class);
+});
+
+test('fulfillment service marks shipments as shipped and delivered', function () {
+    [$order, $line] = fulfillmentServiceOrder();
+    $fulfillment = app(FulfillmentService::class)->create($order, [$line->getKey() => 1]);
+
+    Event::fake();
+
+    $shipped = app(FulfillmentService::class)->markShipped($fulfillment);
+    $delivered = app(FulfillmentService::class)->markDelivered($shipped);

-    $response->assertStatus(200);
+    expect($shipped->status)->toBe(FulfillmentShipmentStatus::Shipped)
+        ->and($shipped->shipped_at)->not->toBeNull()
+        ->and($delivered->status)->toBe(FulfillmentShipmentStatus::Delivered)
+        ->and($delivered->delivered_at)->not->toBeNull();
+
+    Event::assertDispatched(FulfillmentShipped::class);
+    Event::assertDispatched(FulfillmentDelivered::class);
 });

unified_diff:
@@ -2,6 +2,112 @@

-test('example', function () {
-    $response = $this->get('/');
+use App\Enums\FinancialStatus;
+use App\Enums\OrderStatus;
+use App\Enums\PaymentStatus;
+use App\Enums\RefundStatus;
+use App\Events\OrderRefunded;
+use App\Exceptions\InvalidRefundOperationException;
+use App\Models\InventoryItem;
+use App\Models\Order;
+use App\Models\OrderLine;
+use App\Models\Payment;
+use App\Models\Product;
+use App\Models\ProductVariant;
+use App\Models\Store;
+use App\Services\RefundService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Event;
+
+uses(RefreshDatabase::class);
+
+/**
+ * @return array{0: Order, 1: OrderLine, 2: ProductVariant}
+ */
+function refundServiceOrder(int $quantity = 2, int $unitPrice = 5000): array
+{
+    $store = Store::factory()->create();
+    app()->instance('current_store', $store);
+
+    $product = Product::factory()
+        ->withDefaultVariant($unitPrice)
+        ->create(['store_id' => $store->getKey()]);
+    $variant = ProductVariant::withoutGlobalScopes()
+        ->where('product_id', $product->getKey())
+        ->firstOrFail();
+
+    InventoryItem::withoutGlobalScopes()
+        ->where('variant_id', $variant->getKey())
+        ->update([
+            'quantity_on_hand' => 8,
+            'quantity_reserved' => 0,
+        ]);
+
+    $total = $quantity * $unitPrice;
+    $order = Order::factory()->paid()->create([
+        'store_id' => $store->getKey(),
+        'subtotal_amount' => $total,
+        'shipping_amount' => 0,
+        'tax_amount' => 0,
+        'total_amount' => $total,
+    ]);
+    $line = OrderLine::factory()->create([
+        'order_id' => $order->getKey(),
+        'product_id' => $product->getKey(),
+        'variant_id' => $variant->getKey(),
+        'quantity' => $quantity,
+        'unit_price_amount' => $unitPrice,
+        'total_amount' => $total,
+    ]);
+
+    Payment::factory()->create([
+        'order_id' => $order->getKey(),
+        'status' => PaymentStatus::Captured,
+        'amount' => $total,
+    ]);
+
+    return [$order, $line, $variant];
+}
+
+test('refund service processes a partial line refund and restocks inventory', function () {
+    [$order, $line, $variant] = refundServiceOrder();
+
+    Event::fake();
+
+    $refund = app(RefundService::class)->process($order, [
+        'lines' => [$line->getKey() => 1],
+        'reason' => 'Customer return',
+        'restock' => true,
+    ]);
+
+    expect($refund->amount)->toBe(5000)
+        ->and($refund->status)->toBe(RefundStatus::Processed)
+        ->and($refund->provider_refund_id)->toStartWith('mock_refund_')
+        ->and($order->refresh()->status)->toBe(OrderStatus::Paid)
+        ->and($order->financial_status)->toBe(FinancialStatus::PartiallyRefunded)
+        ->and($order->payments()->first()?->status)->toBe(PaymentStatus::Captured)
+        ->and(InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->first()?->quantity_on_hand)->toBe(9);
+
+    Event::assertDispatched(OrderRefunded::class, fn (OrderRefunded $event): bool => $event->refund->is($refund));
+});
+
+test('refund service marks order and payment refunded when the full amount is refunded', function () {
+    [$order] = refundServiceOrder();
+
+    $refund = app(RefundService::class)->process($order);
+
+    expect($refund->amount)->toBe(10000)
+        ->and($order->refresh()->status)->toBe(OrderStatus::Refunded)
+        ->and($order->financial_status)->toBe(FinancialStatus::Refunded)
+        ->and($order->payments()->first()?->status)->toBe(PaymentStatus::Refunded);
+});
+
+test('refund service rejects over-refunds and orders without captured payments', function () {
+    [$order] = refundServiceOrder();
+
+    expect(fn () => app(RefundService::class)->process($order, ['amount' => 10001]))
+        ->toThrow(InvalidRefundOperationException::class);

-    $response->assertStatus(200);
+    $order->payments()->update(['status' => PaymentStatus::Pending]);
+
+    expect(fn () => app(RefundService::class)->process($order, ['amount' => 500]))
+        ->toThrow(InvalidRefundOperationException::class);
 });


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/tests/Feature/Orders/RefundServiceTest.php\nM /Users/fabianwesner/Herd/shop/tests/Feature/Orders/FulfillmentServiceTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/tests/Feature/Orders/BankTransferOrderTest.php
@@
 <?php

-test('example', function () {
-    $response = $this->get('/');
-
-    $response->assertStatus(200);
-});
+use App\Enums\FinancialStatus;
+use App\Enums\FulfillmentShipmentStatus;
+use App\Enums\FulfillmentStatus;
+use App\Enums\OrderStatus;
+use App\Enums\PaymentMethod;
+use App\Enums\PaymentStatus;
+use App\Events\OrderCancelled;
+use App\Events\OrderPaid;
+use App\Jobs\CancelUnpaidBankTransferOrders;
+use App\Models\InventoryItem;
+use App\Models\Order;
+use App\Models\OrderLine;
+use App\Models\Payment;
+use App\Models\Product;
+use App\Models\ProductVariant;
+use App\Models\Store;
+use App\Models\StoreSettings;
+use App\Services\OrderService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Event;
+
+uses(RefreshDatabase::class);
+
+/**
+ * @return array{0: Order, 1: ProductVariant}
+ */
+function bankTransferOrder(bool $digital = false, int $ageDays = 1): array
+{
+    $store = Store::factory()->create();
+    app()->instance('current_store', $store);
+
+    StoreSettings::query()->create([
+        'store_id' => $store->getKey(),
+        'settings_json' => ['bank_transfer_cancel_days' => 7],
+    ]);
+
+    $product = Product::factory()->withDefaultVariant(2500)->create(['store_id' => $store->getKey()]);
+    $variant = ProductVariant::withoutGlobalScopes()->where('product_id', $product->getKey())->firstOrFail();
+    $variant->forceFill([
+        'requires_shipping' => ! $digital,
+        'weight_g' => $digital ? 0 : $variant->weight_g,
+    ])->save();
+
+    InventoryItem::withoutGlobalScopes()
+        ->where('variant_id', $variant->getKey())
+        ->update([
+            'quantity_on_hand' => 10,
+            'quantity_reserved' => 2,
+        ]);
+
+    $order = Order::factory()->bankTransfer()->create([
+        'store_id' => $store->getKey(),
+        'payment_method' => PaymentMethod::BankTransfer,
+        'status' => OrderStatus::Pending,
+        'financial_status' => FinancialStatus::Pending,
+        'placed_at' => now()->subDays($ageDays),
+        'subtotal_amount' => 5000,
+        'shipping_amount' => 0,
+        'tax_amount' => 0,
+        'total_amount' => 5000,
+    ]);
+    OrderLine::factory()->create([
+        'order_id' => $order->getKey(),
+        'product_id' => $product->getKey(),
+        'variant_id' => $variant->getKey(),
+        'quantity' => 2,
+        'unit_price_amount' => 2500,
+        'total_amount' => 5000,
+    ]);
+    Payment::factory()->bankTransfer()->create([
+        'order_id' => $order->getKey(),
+        'amount' => 5000,
+    ]);
+
+    return [$order, $variant];
+}
+
+test('confirming bank transfer payment captures payment and commits reserved inventory', function () {
+    [$order, $variant] = bankTransferOrder();
+
+    Event::fake();
+
+    $paidOrder = app(OrderService::class)->confirmBankTransferPayment($order);
+    $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail();
+
+    expect($paidOrder->status)->toBe(OrderStatus::Paid)
+        ->and($paidOrder->financial_status)->toBe(FinancialStatus::Paid)
+        ->and($paidOrder->payments->first()->status)->toBe(PaymentStatus::Captured)
+        ->and($inventory->quantity_on_hand)->toBe(8)
+        ->and($inventory->quantity_reserved)->toBe(0);
+
+    Event::assertDispatched(OrderPaid::class);
+});
+
+test('confirming digital bank transfer payment auto-fulfills the order', function () {
+    [$order] = bankTransferOrder(digital: true);
+
+    $paidOrder = app(OrderService::class)->confirmBankTransferPayment($order);
+
+    expect($paidOrder->status)->toBe(OrderStatus::Fulfilled)
+        ->and($paidOrder->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled)
+        ->and($paidOrder->fulfillments)->toHaveCount(1)
+        ->and($paidOrder->fulfillments->first()->status)->toBe(FulfillmentShipmentStatus::Delivered);
+});
+
+test('cancel job voids stale bank transfer orders and releases reservations', function () {
+    [$oldOrder, $oldVariant] = bankTransferOrder(ageDays: 8);
+    [$newOrder, $newVariant] = bankTransferOrder(ageDays: 2);
+
+    Event::fake();
+
+    (new CancelUnpaidBankTransferOrders)->handle(app(OrderService::class));
+
+    $oldInventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $oldVariant->getKey())->firstOrFail();
+    $newInventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $newVariant->getKey())->firstOrFail();
+
+    expect($oldOrder->refresh()->status)->toBe(OrderStatus::Cancelled)
+        ->and($oldOrder->financial_status)->toBe(FinancialStatus::Voided)
+        ->and($oldOrder->payments()->first()?->status)->toBe(PaymentStatus::Failed)
+        ->and($oldInventory->quantity_on_hand)->toBe(10)
+        ->and($oldInventory->quantity_reserved)->toBe(0)
+        ->and($newOrder->refresh()->status)->toBe(OrderStatus::Pending)
+        ->and($newInventory->quantity_reserved)->toBe(2);
+
+    Event::assertDispatched(OrderCancelled::class);
+});
*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/tests/Feature/Orders/BankTransferOrderTest.php

unified_diff:
@@ -2,6 +2,127 @@

-test('example', function () {
-    $response = $this->get('/');
+use App\Enums\FinancialStatus;
+use App\Enums\FulfillmentShipmentStatus;
+use App\Enums\FulfillmentStatus;
+use App\Enums\OrderStatus;
+use App\Enums\PaymentMethod;
+use App\Enums\PaymentStatus;
+use App\Events\OrderCancelled;
+use App\Events\OrderPaid;
+use App\Jobs\CancelUnpaidBankTransferOrders;
+use App\Models\InventoryItem;
+use App\Models\Order;
+use App\Models\OrderLine;
+use App\Models\Payment;
+use App\Models\Product;
+use App\Models\ProductVariant;
+use App\Models\Store;
+use App\Models\StoreSettings;
+use App\Services\OrderService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Event;
+
+uses(RefreshDatabase::class);
+
+/**
+ * @return array{0: Order, 1: ProductVariant}
+ */
+function bankTransferOrder(bool $digital = false, int $ageDays = 1): array
+{
+    $store = Store::factory()->create();
+    app()->instance('current_store', $store);
+
+    StoreSettings::query()->create([
+        'store_id' => $store->getKey(),
+        'settings_json' => ['bank_transfer_cancel_days' => 7],
+    ]);
+
+    $product = Product::factory()->withDefaultVariant(2500)->create(['store_id' => $store->getKey()]);
+    $variant = ProductVariant::withoutGlobalScopes()->where('product_id', $product->getKey())->firstOrFail();
+    $variant->forceFill([
+        'requires_shipping' => ! $digital,
+        'weight_g' => $digital ? 0 : $variant->weight_g,
+    ])->save();
+
+    InventoryItem::withoutGlobalScopes()
+        ->where('variant_id', $variant->getKey())
+        ->update([
+            'quantity_on_hand' => 10,
+            'quantity_reserved' => 2,
+        ]);

-    $response->assertStatus(200);
+    $order = Order::factory()->bankTransfer()->create([
+        'store_id' => $store->getKey(),
+        'payment_method' => PaymentMethod::BankTransfer,
+        'status' => OrderStatus::Pending,
+        'financial_status' => FinancialStatus::Pending,
+        'placed_at' => now()->subDays($ageDays),
+        'subtotal_amount' => 5000,
+        'shipping_amount' => 0,
+        'tax_amount' => 0,
+        'total_amount' => 5000,
+    ]);
+    OrderLine::factory()->create([
+        'order_id' => $order->getKey(),
+        'product_id' => $product->getKey(),
+        'variant_id' => $variant->getKey(),
+        'quantity' => 2,
+        'unit_price_amount' => 2500,
+        'total_amount' => 5000,
+    ]);
+    Payment::factory()->bankTransfer()->create([
+        'order_id' => $order->getKey(),
+        'amount' => 5000,
+    ]);
+
+    return [$order, $variant];
+}
+
+test('confirming bank transfer payment captures payment and commits reserved inventory', function () {
+    [$order, $variant] = bankTransferOrder();
+
+    Event::fake();
+
+    $paidOrder = app(OrderService::class)->confirmBankTransferPayment($order);
+    $inventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $variant->getKey())->firstOrFail();
+
+    expect($paidOrder->status)->toBe(OrderStatus::Paid)
+        ->and($paidOrder->financial_status)->toBe(FinancialStatus::Paid)
+        ->and($paidOrder->payments->first()->status)->toBe(PaymentStatus::Captured)
+        ->and($inventory->quantity_on_hand)->toBe(8)
+        ->and($inventory->quantity_reserved)->toBe(0);
+
+    Event::assertDispatched(OrderPaid::class);
+});
+
+test('confirming digital bank transfer payment auto-fulfills the order', function () {
+    [$order] = bankTransferOrder(digital: true);
+
+    $paidOrder = app(OrderService::class)->confirmBankTransferPayment($order);
+
+    expect($paidOrder->status)->toBe(OrderStatus::Fulfilled)
+        ->and($paidOrder->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled)
+        ->and($paidOrder->fulfillments)->toHaveCount(1)
+        ->and($paidOrder->fulfillments->first()->status)->toBe(FulfillmentShipmentStatus::Delivered);
+});
+
+test('cancel job voids stale bank transfer orders and releases reservations', function () {
+    [$oldOrder, $oldVariant] = bankTransferOrder(ageDays: 8);
+    [$newOrder, $newVariant] = bankTransferOrder(ageDays: 2);
+
+    Event::fake();
+
+    (new CancelUnpaidBankTransferOrders)->handle(app(OrderService::class));
+
+    $oldInventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $oldVariant->getKey())->firstOrFail();
+    $newInventory = InventoryItem::withoutGlobalScopes()->where('variant_id', $newVariant->getKey())->firstOrFail();
+
+    expect($oldOrder->refresh()->status)->toBe(OrderStatus::Cancelled)
+        ->and($oldOrder->financial_status)->toBe(FinancialStatus::Voided)
+        ->and($oldOrder->payments()->first()?->status)->toBe(PaymentStatus::Failed)
+        ->and($oldInventory->quantity_on_hand)->toBe(10)
+        ->and($oldInventory->quantity_reserved)->toBe(0)
+        ->and($newOrder->refresh()->status)->toBe(OrderStatus::Pending)
+        ->and($newInventory->quantity_reserved)->toBe(2);
+
+    Event::assertDispatched(OrderCancelled::class);
 });


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/tests/Feature/Orders/BankTransferOrderTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


message: The refund, fulfillment, and bank-transfer service tests are in place. I’m formatting now, then I’ll run the new order service suite to catch transaction or enum-cast issues.

phase: commentary


type: message

role: assistant

text: The refund, fulfillment, and bank-transfer service tests are in place. I’m formatting now, then I’ll run the new order service suite to catch transaction or enum-cast issues.


output:
Chunk ID: 491979
Wall time: 0.5594 seconds
Process exited with code 0
Original token count: 22
Output:
{"result":"fixed","files":[{"path":"routes\/console.php","fixers":["ordered_imports"]}]}


command:
/bin/zsh
-lc
vendor/bin/pint --dirty --format agent

type: unknown

cmd: vendor/bin/pint --dirty --format agent

result: fixed


cmd: php artisan test --compact tests/Feature/Orders


output:
Chunk ID: 56887e
Wall time: 0.8815 seconds
Process exited with code 2
Original token count: 551
Output:

  ..⨯.........⨯..
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Orders\BankTransferOrderTes…  ModelNotFoundException
  No query results for model [App\Models\InventoryItem].

  at vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php:780
    776▕         if (! is_null($model = $this->first($columns))) {
    777▕             return $model;
    778▕         }
    779▕
  ➜ 780▕         throw (new ModelNotFoundException)->setModel(get_class($this->model));
    781▕     }
    782▕
    783▕     /**
    784▕      * Execute the query and get the first result or call a callback.

      +1 vendor frames
  2   app/Services/InventoryService.php:99
  3   app/Services/InventoryService.php:46

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Orders\RefundServiceTest > refu…  ArgumentCountError
  Too few arguments to function App\Services\RefundService::{closure:App\Services\RefundService::lineRefundAmount():147}(), 1 passed in /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Collections/Traits/EnumeratesValues.php on line 584 and exactly 2 expected

  at vendor/laravel/framework/src/Illuminate/Collections/Traits/EnumeratesValues.php:584
    580▕         $callback = is_null($callback)
    581▕             ? $this->identity()
    582▕             : $this->valueRetriever($callback);
    583▕
  ➜ 584▕         return $this->reduce(fn ($result, $item) => $result + $callback($item), 0);
    585▕     }
    586▕
    587▕     /**
    588▕      * Apply the callback if the collection is empty.

      +1 vendor frames
  2   app/Services/RefundService.php:147
  3   app/Services/RefundService.php:107

  Tests:    2 failed, 13 passed (70 assertions)
  Duration: 0.61s


command: php artisan test --compact tests/Feature/Orders

aggregated_output:
  ..⨯.........⨯..
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Orders\BankTransferOrderTes…  ModelNotFoundException
  No query results for model [App\Models\InventoryItem].

  at vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php:780
    776▕         if (! is_null($model = $this->first($columns))) {
    777▕             return $model;
    778▕         }
    779▕
  ➜ 780▕         throw (new ModelNotFoundException)->setModel(get_class($this->model));
    781▕     }
    782▕
    783▕     /**
    784▕      * Execute the query and get the first result or call a callback.

      +1 vendor frames
  2   app/Services/InventoryService.php:99
  3   app/Services/InventoryService.php:46

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Orders\RefundServiceTest > refu…  ArgumentCountError
  Too few arguments to function App\Services\RefundService::{closure:App\Services\RefundService::lineRefundAmount():147}(), 1 passed in /Users/fabianwesner/Herd/shop/vendor/laravel/framework/src/Illuminate/Collections/Traits/EnumeratesValues.php on line 584 and exactly 2 expected

  at vendor/laravel/framework/src/Illuminate/Collections/Traits/EnumeratesValues.php:584
    580▕         $callback = is_null($callback)
    581▕             ? $this->identity()
    582▕             : $this->valueRetriever($callback);
    583▕
  ➜ 584▕         return $this->reduce(fn ($result, $item) => $result + $callback($item), 0);
    585▕     }
    586▕
    587▕     /**
    588▕      * Apply the callback if the collection is empty.

      +1 vendor frames
  2   app/Services/RefundService.php:147
  3   app/Services/RefundService.php:107

  Tests:    2 failed, 13 passed (70 assertions)
  Duration: 0.61s

status: failed


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Services/InventoryService.php
@@
     private function freshItem(InventoryItem $item): InventoryItem
     {
-        return InventoryItem::query()
+        return InventoryItem::withoutGlobalScopes()
             ->whereKey($item->getKey())
             ->lockForUpdate()
             ->firstOrFail();
     }
 }
*** Update File: /Users/fabianwesner/Herd/shop/app/Services/RefundService.php
@@
     private function lineRefundAmount(Order $order, Collection $lineQuantities): int
     {
-        return $lineQuantities->sum(function (int $quantity, int $lineId) use ($order): int {
+        return $lineQuantities->reduce(function (int $total, int $quantity, int $lineId) use ($order): int {
             $line = $order->lines->firstWhere('id', $lineId);

             if (! $line instanceof OrderLine) {
-                return 0;
+                return $total;
             }

-            return (int) round($line->total_amount / $line->quantity * $quantity);
-        });
+            return $total + (int) round($line->total_amount / $line->quantity * $quantity);
+        }, 0);
     }
*** End Patch


output:
apply_patch verification failed: Failed to find expected lines in /Users/fabianwesner/Herd/shop/app/Services/InventoryService.php:
    private function freshItem(InventoryItem $item): InventoryItem
    {
        return InventoryItem::query()
            ->whereKey($item->getKey())
            ->lockForUpdate()
            ->firstOrFail();
    }
}


cmd: sed -n '86,110p' app/Services/InventoryService.php


cmd: sed -n '136,160p' app/Services/RefundService.php


command: sed -n '136,160p' app/Services/RefundService.php

RefundService.php

type: read

aggregated_output:
                if ($quantity > $line->quantity) {
                    throw InvalidRefundOperationException::because('Refund quantity exceeds the ordered quantity.');
                }
            });
    }

    /**
     * @param  Collection<int, int>  $lineQuantities
     */
    private function lineRefundAmount(Order $order, Collection $lineQuantities): int
    {
        return $lineQuantities->sum(function (int $quantity, int $lineId) use ($order): int {
            $line = $order->lines->firstWhere('id', $lineId);

            if (! $line instanceof OrderLine) {
                return 0;
            }

            return (int) round($line->total_amount / $line->quantity * $quantity);
        });
    }

    /**
     * @param  Collection<int, int>  $lineQuantities
     */


command: sed -n '86,110p' app/Services/InventoryService.php

aggregated_output:
        });
    }

    private function available(InventoryItem $item): int
    {
        return $item->quantity_on_hand - $item->quantity_reserved;
    }

    private function freshItem(InventoryItem $item): InventoryItem
    {
        return InventoryItem::query()
            ->whereKey($item->getKey())
            ->lockForUpdate()
            ->firstOrFail();
    }

    private function assertPositiveQuantity(int $quantity): void
    {
        if ($quantity <= 0) {
            throw new InvalidArgumentException('Inventory quantity must be greater than zero.');
        }
    }
}


output:
Chunk ID: 49ee8a
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 148
Output:
        });
    }

    private function available(InventoryItem $item): int
    {
        return $item->quantity_on_hand - $item->quantity_reserved;
    }

    private function freshItem(InventoryItem $item): InventoryItem
    {
        return InventoryItem::query()
            ->whereKey($item->getKey())
            ->lockForUpdate()
            ->firstOrFail();
    }

    private function assertPositiveQuantity(int $quantity): void
    {
        if ($quantity <= 0) {
            throw new InvalidArgumentException('Inventory quantity must be greater than zero.');
        }
    }
}


output:
Chunk ID: 7e6da5
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 197
Output:
                if ($quantity > $line->quantity) {
                    throw InvalidRefundOperationException::because('Refund quantity exceeds the ordered quantity.');
                }
            });
    }

    /**
     * @param  Collection<int, int>  $lineQuantities
     */
    private function lineRefundAmount(Order $order, Collection $lineQuantities): int
    {
        return $lineQuantities->sum(function (int $quantity, int $lineId) use ($order): int {
            $line = $order->lines->firstWhere('id', $lineId);

            if (! $line instanceof OrderLine) {
                return 0;
            }

            return (int) round($line->total_amount / $line->quantity * $quantity);
        });
    }

    /**
     * @param  Collection<int, int>  $lineQuantities
     */


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Services/InventoryService.php
@@
-        return InventoryItem::query()
+        return InventoryItem::withoutGlobalScopes()
             ->whereKey($item->getKey())
             ->lockForUpdate()
             ->firstOrFail();
*** Update File: /Users/fabianwesner/Herd/shop/app/Services/RefundService.php
@@
-        return $lineQuantities->sum(function (int $quantity, int $lineId) use ($order): int {
+        return $lineQuantities->reduce(function (int $total, int $quantity, int $lineId) use ($order): int {
             $line = $order->lines->firstWhere('id', $lineId);

             if (! $line instanceof OrderLine) {
-                return 0;
+                return $total;
             }

-            return (int) round($line->total_amount / $line->quantity * $quantity);
-        });
+            return $total + (int) round($line->total_amount / $line->quantity * $quantity);
+        }, 0);
     }
*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/app/Services/InventoryService.php
M /Users/fabianwesner/Herd/shop/app/Services/RefundService.php

unified_diff:
@@ -95,3 +95,3 @@
     {
-        return InventoryItem::query()
+        return InventoryItem::withoutGlobalScopes()
             ->whereKey($item->getKey())

unified_diff:
@@ -146,3 +146,3 @@
     {
-        return $lineQuantities->sum(function (int $quantity, int $lineId) use ($order): int {
+        return $lineQuantities->reduce(function (int $total, int $quantity, int $lineId) use ($order): int {
             $line = $order->lines->firstWhere('id', $lineId);
@@ -150,7 +150,7 @@
             if (! $line instanceof OrderLine) {
-                return 0;
+                return $total;
             }

-            return (int) round($line->total_amount / $line->quantity * $quantity);
-        });
+            return $total + (int) round($line->total_amount / $line->quantity * $quantity);
+        }, 0);
     }


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


cmd: vendor/bin/pint --dirty --format agent && php artisan test --compact tests/Feature/Orders


output:
Chunk ID: f8fefc
Wall time: 1.4737 seconds
Process exited with code 0
Original token count: 24
Output:
{"result":"pass"}
  ...............

  Tests:    15 passed (86 assertions)
  Duration: 0.58s


command: vendor/bin/pint --dirty --format agent && php artisan test --compact tests/Feature/Orders

aggregated_output:
{"result":"pass"}
  ...............

  Tests:    15 passed (86 assertions)
  Duration: 0.58s


message: The initial failures exposed one real multi-store issue: inventory commits/releases inside jobs need to bypass the current-store scope when the item is already resolved by ID. That fix is in and the order suite is green; I’m widening to cart/checkout/payment coverage now.


text: The initial failures exposed one real multi-store issue: inventory commits/releases inside jobs need to bypass the current-store scope when the item is already resolved by ID. That fix is in and the order suite is green; I’m widening to cart/checkout/payment coverage now.


cmd: php artisan test --compact tests/Feature/Cart tests/Feature/Checkout tests/Feature/Payments tests/Feature/Orders


output:
Chunk ID: a40496
Wall time: 0.9472 seconds
Process exited with code 0
Original token count: 24
Output:

  ................................

  Tests:    32 passed (167 assertions)
  Duration: 0.83s


command: php artisan test --compact tests/Feature/Cart tests/Feature/Checkout tests/Feature/Payments tests/Feature/Orders


output:
Chunk ID: 12b90a
Wall time: 11.0818 seconds
Process exited with code 0
Original token count: 47
Output:

  ............................................................................
  .........................................

  Tests:    117 passed (537 assertions)
  Duration: 10.98s


command:
/bin/zsh
-lc
php artisan test --compact

cmd: php artisan test --compact


cmd: php artisan migrate:fresh --seed --no-interaction


output:
Chunk ID: 812a6d
Wall time: 0.1102 seconds
Process exited with code 0
Original token count: 62
Output:

  */15 * * * *  App\Jobs\ExpireAbandonedCheckouts  Next Due: 11 minutes from now
  0    0 * * *  App\Jobs\CleanupAbandonedCarts ... Next Due: 23 hours from now
  0    0 * * *  App\Jobs\CancelUnpaidBankTransferOrders  Next Due: 23 hours from now


command:
/bin/zsh
-lc
php artisan schedule:list

cmd: php artisan schedule:list

aggregated_output:
  */15 * * * *  App\Jobs\ExpireAbandonedCheckouts  Next Due: 11 minutes from now
  0    0 * * *  App\Jobs\CleanupAbandonedCarts ... Next Due: 23 hours from now
  0    0 * * *  App\Jobs\CancelUnpaidBankTransferOrders  Next Due: 23 hours from now


output:
Chunk ID: 9fc61d
Wall time: 0.9381 seconds
Process exited with code 0
Original token count: 1629
Output:

  Dropping all tables ............................................ 4.93ms DONE

   INFO  Preparing database.

  Creating migration table ....................................... 1.77ms DONE

   INFO  Running migrations.

  0001_01_01_000000_create_users_table ........................... 1.65ms DONE
  0001_01_01_000001_create_cache_table ........................... 0.43ms DONE
  0001_01_01_000002_create_jobs_table ............................ 0.99ms DONE
  2025_08_14_170933_add_two_factor_columns_to_users_table ........ 0.45ms DONE
  2026_05_03_211409_create_organizations_table ................... 0.22ms DONE
  2026_05_03_211413_create_stores_table .......................... 4.44ms DONE
  2026_05_03_211416_create_store_domains_table ................... 0.40ms DONE
  2026_05_03_211419_create_store_settings_table .................. 0.16ms DONE
  2026_05_03_211425_create_store_users_table ..................... 0.33ms DONE
  2026_05_03_211556_create_customers_table ....................... 0.31ms DONE
  2026_05_03_212158_create_customer_password_reset_tokens_table .. 0.20ms DONE
  2026_05_03_213856_create_products_table ........................ 0.68ms DONE
  2026_05_03_213857_create_product_options_table ................. 0.34ms DONE
  2026_05_03_213858_create_product_option_values_table ........... 0.31ms DONE
  2026_05_03_213859_create_product_variants_table ................ 0.59ms DONE
  2026_05_03_213900_create_variant_option_values_table ........... 0.25ms DONE
  2026_05_03_213901_create_inventory_items_table ................. 0.31ms DONE
  2026_05_03_213902_create_collections_table ..................... 0.40ms DONE
  2026_05_03_213903_create_collection_products_table ............. 0.32ms DONE
  2026_05_03_213904_create_product_media_table ................... 0.63ms DONE
  2026_05_03_223824_create_themes_table .......................... 0.36ms DONE
  2026_05_03_223830_create_theme_files_table ..................... 0.31ms DONE
  2026_05_03_223837_create_theme_settings_table .................. 0.17ms DONE
  2026_05_03_223841_create_pages_table ........................... 0.39ms DONE
  2026_05_03_223844_create_navigation_menus_table ................ 0.30ms DONE
  2026_05_03_223848_create_navigation_items_table ................ 0.41ms DONE
  2026_05_03_225400_create_carts_table ........................... 0.50ms DONE
  2026_05_03_225405_create_cart_lines_table ...................... 0.39ms DONE
  2026_05_03_225409_create_checkouts_table ....................... 0.69ms DONE
  2026_05_03_225413_create_shipping_zones_table .................. 0.31ms DONE
  2026_05_03_225418_create_shipping_rates_table .................. 0.39ms DONE
  2026_05_03_225422_create_tax_settings_table .................... 0.22ms DONE
  2026_05_03_225427_create_discounts_table ....................... 0.55ms DONE
  2026_05_04_000408_create_customer_addresses_table .............. 0.32ms DONE
  2026_05_04_000408_create_orders_table .......................... 0.89ms DONE
  2026_05_04_000410_create_order_lines_table ..................... 0.46ms DONE
  2026_05_04_000411_create_payments_table ........................ 0.50ms DONE
  2026_05_04_000412_create_refunds_table ......................... 0.41ms DONE
  2026_05_04_000413_create_fulfillments_table .................... 0.57ms DONE
  2026_05_04_000414_create_fulfillment_lines_table ............... 0.38ms DONE

   INFO  Seeding database.

  Database\Seeders\OrganizationSeeder ................................ RUNNING
  Database\Seeders\OrganizationSeeder .............................. 5 ms DONE

  Database\Seeders\StoreSeeder ....................................... RUNNING
  Database\Seeders\StoreSeeder ..................................... 1 ms DONE

  Database\Seeders\StoreDomainSeeder ................................. RUNNING
  Database\Seeders\StoreDomainSeeder ............................... 1 ms DONE

  Database\Seeders\UserSeeder ........................................ RUNNING
  Database\Seeders\UserSeeder .................................... 264 ms DONE

  Database\Seeders\StoreUserSeeder ................................... RUNNING
  Database\Seeders\StoreUserSeeder ................................. 1 ms DONE

  Database\Seeders\StoreSettingsSeeder ............................... RUNNING
  Database\Seeders\StoreSettingsSeeder ............................. 2 ms DONE

  Database\Seeders\CollectionSeeder .................................. RUNNING
  Database\Seeders\CollectionSeeder ................................ 3 ms DONE

  Database\Seeders\ProductSeeder ..................................... RUNNING
  Database\Seeders\ProductSeeder ................................. 195 ms DONE

  Database\Seeders\ThemeSeeder ....................................... RUNNING
  Database\Seeders\ThemeSeeder ..................................... 1 ms DONE

  Database\Seeders\ThemeFileSeeder ................................... RUNNING
  Database\Seeders\ThemeFileSeeder ................................. 1 ms DONE

  Database\Seeders\ThemeSettingsSeeder ............................... RUNNING
  Database\Seeders\ThemeSettingsSeeder ............................. 1 ms DONE

  Database\Seeders\PageSeeder ........................................ RUNNING
  Database\Seeders\PageSeeder ...................................... 2 ms DONE

  Database\Seeders\NavigationMenuSeeder .............................. RUNNING
  Database\Seeders\NavigationMenuSeeder ............................ 1 ms DONE

  Database\Seeders\NavigationItemSeeder .............................. RUNNING
  Database\Seeders\NavigationItemSeeder ............................ 4 ms DONE

  Database\Seeders\TaxSettingsSeeder ................................. RUNNING
  Database\Seeders\TaxSettingsSeeder ............................... 1 ms DONE

  Database\Seeders\ShippingZoneSeeder ................................ RUNNING
  Database\Seeders\ShippingZoneSeeder .............................. 1 ms DONE

  Database\Seeders\ShippingRateSeeder ................................ RUNNING
  Database\Seeders\ShippingRateSeeder .............................. 1 ms DONE

  Database\Seeders\DiscountSeeder .................................... RUNNING
  Database\Seeders\DiscountSeeder .................................. 3 ms DONE

  Database\Seeders\CustomerSeeder .................................... RUNNING
  Database\Seeders\CustomerSeeder ................................ 264 ms DONE


command: php artisan migrate:fresh --seed --no-interaction


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/specs/progress.md
@@
-| Business logic | `specs/05-BUSINESS-LOGIC.md` | partial | `ResolveStore`, `BelongsToStore`, `StoreScope`, role checks, customer guard provider, catalog product lifecycle transitions, SKU uniqueness, variant matrix rebuilding, inventory reserve/release/commit/restock, automatic variant inventory, variant option mapping, media resize/cleanup job, `CartService`, `DiscountService`, `ShippingCalculator`, `TaxCalculator`, `PricingEngine`, checkout transitions through completion, checkout expiration, abandoned cart cleanup, session cart lookup, cart discount handoff to checkout, customer login cart merge, product add-to-cart mutations, `PaymentService`, mock PSP, and idempotent order creation are implemented. Refund, fulfillment, bank-transfer confirmation/cancel jobs, and customer order-history logic are still missing. |
+| Business logic | `specs/05-BUSINESS-LOGIC.md` | partial | `ResolveStore`, `BelongsToStore`, `StoreScope`, role checks, customer guard provider, catalog product lifecycle transitions, SKU uniqueness, variant matrix rebuilding, inventory reserve/release/commit/restock, automatic variant inventory, variant option mapping, media resize/cleanup job, `CartService`, `DiscountService`, `ShippingCalculator`, `TaxCalculator`, `PricingEngine`, checkout transitions through completion, checkout expiration, abandoned cart cleanup, session cart lookup, cart discount handoff to checkout, customer login cart merge, product add-to-cart mutations, `PaymentService`, mock PSP, idempotent order creation, `RefundService`, `FulfillmentService`, bank-transfer payment confirmation, and unpaid bank-transfer cancellation are implemented. Customer order-history logic and UI orchestration are still missing. |
@@
-| Roadmap phases | `specs/09-IMPLEMENTATION-ROADMAP.md` | in progress | Phase 1 foundation, Phase 2 catalog data/UI, Phase 3 theme/content/navigation data with storefront consumption, Phase 4 cart/checkout/pricing backend foundation, Phase 4 storefront cart/checkout UI through `payment_selected`, the Phase 4 cart/checkout REST API surface, cart-page estimates, and Phase 5 order/payment backend foundation are implemented. Phase 5 fulfillment/refund/admin/customer order surfaces are next. |
+| Roadmap phases | `specs/09-IMPLEMENTATION-ROADMAP.md` | in progress | Phase 1 foundation, Phase 2 catalog data/UI, Phase 3 theme/content/navigation data with storefront consumption, Phase 4 cart/checkout/pricing backend foundation, Phase 4 storefront cart/checkout UI through `payment_selected`, the Phase 4 cart/checkout REST API surface, cart-page estimates, Phase 5 order/payment backend foundation, and Phase 5 refund/fulfillment services are implemented. Phase 5 checkout submission, admin order management, and customer order surfaces are next. |
@@
 - 2026-05-04: `php artisan test --compact` passed after the Phase 5 order/payment backend foundation: 107 tests, 489 assertions.
 - 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after adding Phase 5 order/payment/fulfillment migrations and factories.
 - 2026-05-04: Boost schema summaries confirmed Phase 5 runtime tables: orders, order_lines, payments, refunds, fulfillments, and fulfillment_lines.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 job/scheduling, transaction, event, relationship aggregate, and Pest testing docs before refund/fulfillment service changes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Orders` passed after adding refund, fulfillment, and bank-transfer service coverage: 15 tests, 86 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Cart tests/Feature/Checkout tests/Feature/Payments tests/Feature/Orders` passed after refund/fulfillment changes: 32 tests, 167 assertions.
+- 2026-05-04: `php artisan test --compact` passed after refund/fulfillment changes: 117 tests, 537 assertions.
+- 2026-05-04: `php artisan schedule:list` confirmed `CancelUnpaidBankTransferOrders` is scheduled daily alongside checkout/cart cleanup jobs.
+- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after refund/fulfillment service changes.
@@
 - `orders.checkout_id` is intentionally added beyond the original schema table to enforce idempotent checkout completion without duplicate orders.
 - Failed card payments release reserved inventory and move the checkout back to `shipping_selected` so customers can retry payment selection.
 - Bank transfer order completion creates a pending order and payment while keeping inventory reserved until the later admin payment-confirmation flow.
+- Inventory commit/release/restock locks by primary key without the current-store global scope because scheduled jobs can operate across stores in one run.
@@
-- Refund services, fulfillment services, bank-transfer admin confirmation/cancel flows, order confirmation UI, customer account order views, and admin order management are still pending Phase 5.
+- Admin UI controls for bank-transfer confirmation, refund creation, and fulfillment creation/transitions are still missing, although the underlying services now exist.
+- Order confirmation UI, customer account order views, and admin order management screens are still pending Phase 5.
@@
-Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through payment selection, cart-page estimates, cart/checkout REST APIs, and Phase 5 order/payment backend foundation are implemented, with known auth/token, media UI, theme admin UI, fulfillment/refund/order UI, remaining API, browser-test, and database CHECK constraint gaps tracked above.
+Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through payment selection, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, and Phase 5 refund/fulfillment services are implemented, with known auth/token, media UI, theme admin UI, order UI, remaining API, browser-test, and database CHECK constraint gaps tracked above.
*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/specs/progress.md

unified_diff:
@@ -32,3 +32,3 @@
 | Storefront UI | `specs/04-STOREFRONT-UI.md` | partial | Storefront layout, home, collections index/detail, product detail, search, breadcrumbs, price display, product cards, DB-backed pages, seeded navigation menus, cached theme settings, product add-to-cart persistence, cart drawer, cart page with discount and shipping estimates, and checkout address/shipping/discount/payment-selection UI render seeded data. Order confirmation and customer order views are still missing. |
-| Business logic | `specs/05-BUSINESS-LOGIC.md` | partial | `ResolveStore`, `BelongsToStore`, `StoreScope`, role checks, customer guard provider, catalog product lifecycle transitions, SKU uniqueness, variant matrix rebuilding, inventory reserve/release/commit/restock, automatic variant inventory, variant option mapping, media resize/cleanup job, `CartService`, `DiscountService`, `ShippingCalculator`, `TaxCalculator`, `PricingEngine`, checkout transitions through completion, checkout expiration, abandoned cart cleanup, session cart lookup, cart discount handoff to checkout, customer login cart merge, product add-to-cart mutations, `PaymentService`, mock PSP, and idempotent order creation are implemented. Refund, fulfillment, bank-transfer confirmation/cancel jobs, and customer order-history logic are still missing. |
+| Business logic | `specs/05-BUSINESS-LOGIC.md` | partial | `ResolveStore`, `BelongsToStore`, `StoreScope`, role checks, customer guard provider, catalog product lifecycle transitions, SKU uniqueness, variant matrix rebuilding, inventory reserve/release/commit/restock, automatic variant inventory, variant option mapping, media resize/cleanup job, `CartService`, `DiscountService`, `ShippingCalculator`, `TaxCalculator`, `PricingEngine`, checkout transitions through completion, checkout expiration, abandoned cart cleanup, session cart lookup, cart discount handoff to checkout, customer login cart merge, product add-to-cart mutations, `PaymentService`, mock PSP, idempotent order creation, `RefundService`, `FulfillmentService`, bank-transfer payment confirmation, and unpaid bank-transfer cancellation are implemented. Customer order-history logic and UI orchestration are still missing. |
 | Auth/security | `specs/06-AUTH-AND-SECURITY.md` | partial | Admin Livewire login/logout, customer guard/provider, customer login/register, login rate limiting, session hardening, and resource policies implemented. Customer password reset and Sanctum token auth still missing. |
@@ -36,3 +36,3 @@
 | Browser E2E plan | `specs/08-PLAYWRIGHT-E2E-PLAN.md` | partial | Manual Playwright MCP smoke coverage has verified storefront catalog browsing, product detail, add-to-cart, cart drawer/page, checkout through payment selection, search, DB-backed content pages, seeded navigation, admin product/collection/inventory pages, and auth flows without console warnings/errors on successful pages. Automated browser tests are still missing. |
-| Roadmap phases | `specs/09-IMPLEMENTATION-ROADMAP.md` | in progress | Phase 1 foundation, Phase 2 catalog data/UI, Phase 3 theme/content/navigation data with storefront consumption, Phase 4 cart/checkout/pricing backend foundation, Phase 4 storefront cart/checkout UI through `payment_selected`, the Phase 4 cart/checkout REST API surface, cart-page estimates, and Phase 5 order/payment backend foundation are implemented. Phase 5 fulfillment/refund/admin/customer order surfaces are next. |
+| Roadmap phases | `specs/09-IMPLEMENTATION-ROADMAP.md` | in progress | Phase 1 foundation, Phase 2 catalog data/UI, Phase 3 theme/content/navigation data with storefront consumption, Phase 4 cart/checkout/pricing backend foundation, Phase 4 storefront cart/checkout UI through `payment_selected`, the Phase 4 cart/checkout REST API surface, cart-page estimates, Phase 5 order/payment backend foundation, and Phase 5 refund/fulfillment services are implemented. Phase 5 checkout submission, admin order management, and customer order surfaces are next. |

@@ -113,2 +113,8 @@
 - 2026-05-04: Boost schema summaries confirmed Phase 5 runtime tables: orders, order_lines, payments, refunds, fulfillments, and fulfillment_lines.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 job/scheduling, transaction, event, relationship aggregate, and Pest testing docs before refund/fulfillment service changes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Orders` passed after adding refund, fulfillment, and bank-transfer service coverage: 15 tests, 86 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Cart tests/Feature/Checkout tests/Feature/Payments tests/Feature/Orders` passed after refund/fulfillment changes: 32 tests, 167 assertions.
+- 2026-05-04: `php artisan test --compact` passed after refund/fulfillment changes: 117 tests, 537 assertions.
+- 2026-05-04: `php artisan schedule:list` confirmed `CancelUnpaidBankTransferOrders` is scheduled daily alongside checkout/cart cleanup jobs.
+- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after refund/fulfillment service changes.

@@ -135,2 +141,3 @@
 - Bank transfer order completion creates a pending order and payment while keeping inventory reserved until the later admin payment-confirmation flow.
+- Inventory commit/release/restock locks by primary key without the current-store global scope because scheduled jobs can operate across stores in one run.

@@ -147,3 +154,4 @@
 - Storefront search/suggest, analytics event, order lookup, checkout payment processing, and admin REST API endpoints are still missing.
-- Refund services, fulfillment services, bank-transfer admin confirmation/cancel flows, order confirmation UI, customer account order views, and admin order management are still pending Phase 5.
+- Admin UI controls for bank-transfer confirmation, refund creation, and fulfillment creation/transitions are still missing, although the underlying services now exist.
+- Order confirmation UI, customer account order views, and admin order management screens are still pending Phase 5.
 - Automated browser tests from Spec 08 are still missing; current browser coverage is manual Playwright MCP smoke verification.
@@ -154,2 +162,2 @@

-Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through payment selection, cart-page estimates, cart/checkout REST APIs, and Phase 5 order/payment backend foundation are implemented, with known auth/token, media UI, theme admin UI, fulfillment/refund/order UI, remaining API, browser-test, and database CHECK constraint gaps tracked above.
+Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through payment selection, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, and Phase 5 refund/fulfillment services are implemented, with known auth/token, media UI, theme admin UI, order UI, remaining API, browser-test, and database CHECK constraint gaps tracked above.


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/specs/progress.md\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


command:
/bin/zsh
-lc
git status --short && git diff --check

cmd: git status --short && git diff --check

aggregated_output:
 M app/Services/InventoryService.php
 M app/Services/OrderService.php
 M routes/console.php
 M specs/progress.md
?? .playwright-mcp/console-2026-05-03T23-28-42-443Z.log
?? .playwright-mcp/console-2026-05-04T00-00-00-955Z.log
?? app/Events/FulfillmentCreated.php
?? app/Events/FulfillmentDelivered.php
?? app/Events/FulfillmentShipped.php
?? app/Events/OrderCancelled.php
?? app/Events/OrderRefunded.php
?? app/Exceptions/InvalidFulfillmentOperationException.php
?? app/Exceptions/InvalidRefundOperationException.php
?? app/Jobs/CancelUnpaidBankTransferOrders.php
?? app/Services/FulfillmentService.php
?? app/Services/RefundService.php
?? tests/Feature/Orders/BankTransferOrderTest.php
?? tests/Feature/Orders/FulfillmentServiceTest.php
?? tests/Feature/Orders/RefundServiceTest.php