release($purchase, Purchase::STATUS_CANCELLED); } public function cancelWithoutRestoringCart(Purchase $purchase): Purchase { return $this->release($purchase, Purchase::STATUS_CANCELLED); } public function expire(Purchase $purchase): Purchase { return $this->release($purchase, Purchase::STATUS_EXPIRED); } public function expireOverdue(): int { $expiredCount = 0; Purchase::query() ->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT]) ->whereNotNull('expires_at') ->where('expires_at', '<=', now()) ->orderBy('id') ->eachById(function (Purchase $purchase) use (&$expiredCount): void { try { $purchase = $this->expire($purchase); } catch (Throwable $exception) { Log::channel('commands')->error('Failed to expire overdue purchase.', [ 'command' => 'reservations:expire', 'purchase_id' => $purchase->getKey(), 'tenant_codigo' => $purchase->tenant_codigo, 'cart_id' => $purchase->cart_id, 'status' => $purchase->status, 'expires_at' => $purchase->expires_at, 'exception' => $exception, ]); return; } if ($purchase->status === Purchase::STATUS_EXPIRED) { $expiredCount++; } }); return $expiredCount; } private function release(Purchase $purchase, string $targetStatus): Purchase { return DB::transaction(function () use ($purchase, $targetStatus): Purchase { $purchase = $this->lockPurchase($purchase); if ($targetStatus !== Purchase::STATUS_EXPIRED) { $this->purchaseState->assertNotExpired($purchase); } if ($purchase->status === Purchase::STATUS_PAID) { if ($targetStatus === Purchase::STATUS_EXPIRED) { return $this->loadPurchase($purchase); } throw ValidationException::withMessages([ 'purchase' => __('api.purchase.paid_cannot_cancel'), ]); } if ($this->isAlreadyReleased($purchase)) { return $this->loadPurchase($purchase); } if ( $targetStatus === Purchase::STATUS_EXPIRED && ($purchase->expires_at === null || $purchase->expires_at->isFuture()) ) { return $this->loadPurchase($purchase); } if ($purchase->items()->exists()) { throw ValidationException::withMessages([ 'items' => __('api.purchase.inconsistent_reservation'), ]); } $this->releaseCartReservations($purchase, $targetStatus); $purchase->update(['status' => $targetStatus]); return $this->loadPurchase($purchase); }); } private function releaseCartReservations( Purchase $purchase, string $targetStatus, ): void { $cart = $purchase->cart()->withTrashed()->lockForUpdate()->first(); if ($cart === null) { return; } $cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get(); $cartItems->load([ 'catalogItem.inventory', 'catalogItem.bundleComponents.catalogItem.inventory', 'catalogItem.bundleComponents.variant.inventory', 'variant.inventory', 'variant.catalogItem', ]); foreach ($cartItems as $cartItem) { $selection = $cartItem->selectedItem(); if ($selection === null) { continue; } try { $this->reservations->release( $cartItem, $selection, (int) $cartItem->cantidad, $targetStatus === Purchase::STATUS_EXPIRED ? StockReservation::STATUS_EXPIRED : StockReservation::STATUS_RELEASED, ); } catch (\InvalidArgumentException) { throw ValidationException::withMessages([ 'items' => __('api.purchase.inconsistent_reservation'), ]); } } if (! $cart->trashed()) { $cart->update(['status' => 'converted']); $cart->delete(); } } private function isAlreadyReleased(Purchase $purchase): bool { return in_array($purchase->status, [ Purchase::STATUS_CANCELLED, Purchase::STATUS_REJECTED, Purchase::STATUS_EXPIRED, ], true); } private function lockPurchase(Purchase $purchase): Purchase { /** @var Purchase */ return Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey()); } private function loadPurchase(Purchase $purchase): Purchase { return $purchase->load([ 'items.imageAttachment', 'cart.items.catalogItem.inventory', 'cart.items.catalogItem.attachments', 'cart.items.variant.inventory', 'cart.items.variant.attachments', 'cart.items.variant.definitions.itemAttribute.attribute', 'cart.items.variant.eventDates', 'cart.items.variant.eventDate', ]); } }