feat(checkout): track current cart purchase
This commit is contained in:
@@ -114,6 +114,8 @@ class PurchaseController extends Controller
|
||||
return false;
|
||||
}
|
||||
|
||||
$purchaseState->lockCurrentCart($purchase);
|
||||
|
||||
$purchaseUpdate = [
|
||||
'payment_method' => $method,
|
||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||
|
||||
@@ -47,6 +47,8 @@ class Purchase extends Model
|
||||
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
|
||||
public const STATUS_SUPERSEDED = 'superseded';
|
||||
|
||||
/** @return list<string> */
|
||||
public static function statuses(): array
|
||||
{
|
||||
@@ -57,6 +59,7 @@ class Purchase extends Model
|
||||
self::STATUS_CANCELLED,
|
||||
self::STATUS_REJECTED,
|
||||
self::STATUS_EXPIRED,
|
||||
self::STATUS_SUPERSEDED,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ class CompleteCheckoutService
|
||||
return $this->loadPurchase($purchase);
|
||||
}
|
||||
|
||||
$this->purchaseState->lockCurrentCart($purchase);
|
||||
|
||||
$purchase->update([
|
||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||
'total' => $purchase->calculateCurrentTotalAmount(),
|
||||
@@ -53,6 +55,8 @@ class CompleteCheckoutService
|
||||
return $this->loadPurchase($purchase);
|
||||
}
|
||||
|
||||
$this->purchaseState->lockCurrentCart($purchase);
|
||||
|
||||
if (
|
||||
$purchase->status !== Purchase::STATUS_PENDING_PAYMENT
|
||||
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
||||
@@ -83,6 +87,7 @@ class CompleteCheckoutService
|
||||
Purchase::STATUS_CANCELLED,
|
||||
Purchase::STATUS_REJECTED,
|
||||
Purchase::STATUS_EXPIRED,
|
||||
Purchase::STATUS_SUPERSEDED,
|
||||
], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => __('api.purchase.cannot_confirm'),
|
||||
@@ -95,12 +100,13 @@ class CompleteCheckoutService
|
||||
]);
|
||||
}
|
||||
|
||||
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
|
||||
if ($cart?->status === 'converted' && $cart->trashed()) {
|
||||
$cart = $this->purchaseState->lockCurrentCart($purchase);
|
||||
|
||||
if ($cart->status === 'converted' && $cart->trashed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($cart === null || ! in_array($cart->status, ['active', 'checkout'], true)) {
|
||||
if (! in_array($cart->status, ['active', 'checkout'], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => __('api.purchase.inconsistent_reservation'),
|
||||
]);
|
||||
@@ -149,7 +155,7 @@ class CompleteCheckoutService
|
||||
}
|
||||
|
||||
try {
|
||||
$this->reservations->commit($cartItem, $selection);
|
||||
$this->reservations->commit($cartItem, $selection, $purchase);
|
||||
} catch (\InvalidArgumentException) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => __('api.purchase.inconsistent_reservation'),
|
||||
@@ -168,6 +174,7 @@ class CompleteCheckoutService
|
||||
Purchase::STATUS_CANCELLED,
|
||||
Purchase::STATUS_REJECTED,
|
||||
Purchase::STATUS_EXPIRED,
|
||||
Purchase::STATUS_SUPERSEDED,
|
||||
], true);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ class EditCheckoutService
|
||||
]);
|
||||
}
|
||||
|
||||
$this->purchaseState->lockCurrentCart($purchase);
|
||||
|
||||
$purchase->update($customerData);
|
||||
|
||||
return $this->responses->load($purchase);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Domains\Purchase\Services\Checkout;
|
||||
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Catalog\Services\StockReservationService;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
@@ -103,6 +104,10 @@ class ReleaseCheckoutService
|
||||
|
||||
if ($cart->status === 'active') {
|
||||
$this->reservations->detachFromPurchase($purchase);
|
||||
Cart::query()
|
||||
->whereKey($cart->getKey())
|
||||
->where('current_purchase_id', $purchase->getKey())
|
||||
->update(['current_purchase_id' => null]);
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -154,6 +159,7 @@ class ReleaseCheckoutService
|
||||
Purchase::STATUS_CANCELLED,
|
||||
Purchase::STATUS_REJECTED,
|
||||
Purchase::STATUS_EXPIRED,
|
||||
Purchase::STATUS_SUPERSEDED,
|
||||
], true);
|
||||
}
|
||||
|
||||
|
||||
@@ -193,6 +193,7 @@ class StartCheckoutService
|
||||
),
|
||||
$cart->getKey(),
|
||||
);
|
||||
$cart->update(['current_purchase_id' => $purchase->getKey()]);
|
||||
|
||||
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
|
||||
$this->loadCartItems($cartItems);
|
||||
@@ -264,6 +265,7 @@ class StartCheckoutService
|
||||
$cart->getTotalAmount(),
|
||||
$cart->getKey(),
|
||||
);
|
||||
$cart->update(['current_purchase_id' => $purchase->getKey()]);
|
||||
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
|
||||
|
||||
foreach ($cartItems as $cartItem) {
|
||||
@@ -279,9 +281,52 @@ class StartCheckoutService
|
||||
|
||||
private function resolveCart(Tenant $tenant, int $userId, int $cartId): Cart
|
||||
{
|
||||
/** @var Cart|null $candidate */
|
||||
$candidate = Cart::query()->find($cartId);
|
||||
|
||||
$this->assertCartCanCheckout($candidate, $tenant, $userId);
|
||||
|
||||
$candidatePurchaseId = $candidate->current_purchase_id;
|
||||
$currentPurchase = $candidatePurchaseId === null
|
||||
? null
|
||||
: Purchase::query()->lockForUpdate()->find($candidatePurchaseId);
|
||||
|
||||
/** @var Cart|null $cart */
|
||||
$cart = Cart::query()->lockForUpdate()->find($cartId);
|
||||
|
||||
$this->assertCartCanCheckout($cart, $tenant, $userId);
|
||||
|
||||
if ($cart->current_purchase_id !== $candidatePurchaseId) {
|
||||
if ($cart->current_purchase_id !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => __('api.purchase.checkout_in_progress'),
|
||||
]);
|
||||
}
|
||||
|
||||
$currentPurchase = null;
|
||||
}
|
||||
|
||||
if ($currentPurchase?->status === Purchase::STATUS_PAID) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => __('api.purchase.checkout_in_progress'),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($currentPurchase !== null && in_array($currentPurchase->status, [
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
], true)) {
|
||||
$currentPurchase->update([
|
||||
'status' => Purchase::STATUS_SUPERSEDED,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
private function assertCartCanCheckout(?Cart $cart, Tenant $tenant, int $userId): void
|
||||
{
|
||||
if ($cart === null || $cart->tenant_codigo !== $tenant->codigo || $cart->user_id !== $userId) {
|
||||
throw new NotFoundHttpException('Cart not found for tenant.');
|
||||
}
|
||||
@@ -291,16 +336,6 @@ class StartCheckoutService
|
||||
'cart_id' => __('api.purchase.inactive_cart'),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($cart->purchases()
|
||||
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
|
||||
->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => __('api.purchase.checkout_in_progress'),
|
||||
]);
|
||||
}
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
/** @param Collection<int, CartItem> $cartItems */
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Domains\Purchase\Services;
|
||||
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PurchaseStateGuard
|
||||
{
|
||||
@@ -21,4 +23,18 @@ class PurchaseStateGuard
|
||||
throw new PurchaseExpiredException;
|
||||
}
|
||||
}
|
||||
|
||||
public function lockCurrentCart(Purchase $purchase): Cart
|
||||
{
|
||||
/** @var Cart|null $cart */
|
||||
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
|
||||
|
||||
if ($cart === null || $cart->current_purchase_id !== $purchase->getKey()) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => __('api.purchase.not_current'),
|
||||
]);
|
||||
}
|
||||
|
||||
return $cart;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user