feat(checkout): track current cart purchase

This commit is contained in:
2026-08-21 14:05:33 -03:00
parent a34d5cba1a
commit afd69b8393
17 changed files with 258 additions and 23 deletions

View File

@@ -27,6 +27,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
'guest_token',
'status',
'origin',
'current_purchase_id',
])]
class Cart extends Model
{
@@ -43,6 +44,7 @@ class Cart extends Model
{
return [
'user_id' => 'integer',
'current_purchase_id' => 'integer',
];
}
@@ -76,6 +78,12 @@ class Cart extends Model
return $this->hasMany(Purchase::class, 'cart_id');
}
/** @return BelongsTo<Purchase, $this> */
public function currentPurchase(): BelongsTo
{
return $this->belongsTo(Purchase::class, 'current_purchase_id');
}
public function getTotalAmount(): float
{
$items = $this->relationLoaded('items')

View File

@@ -37,16 +37,24 @@ class StockReservationService
});
}
public function commit(CartItem $cartItem, CatalogItem|Variant $selection): void
{
DB::transaction(function () use ($cartItem, $selection): void {
public function commit(
CartItem $cartItem,
CatalogItem|Variant $selection,
Purchase $purchase,
): void {
DB::transaction(function () use ($cartItem, $selection, $purchase): void {
$this->ensure($cartItem, $selection);
$this->inventory->commit($selection, (int) $cartItem->cantidad);
$requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad);
foreach ($requirements as $inventoryId => $quantity) {
$reservation = $this->lockReservation($cartItem, $inventoryId);
if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity !== $quantity) {
if (
$reservation === null
|| $reservation->status !== StockReservation::STATUS_ACTIVE
|| $reservation->purchase_id !== $purchase->getKey()
|| $reservation->quantity !== $quantity
) {
throw new \InvalidArgumentException('La reserva de stock no coincide con el item del carrito.');
}

View File

@@ -16,6 +16,7 @@ class SaleFormService
Purchase::STATUS_CANCELLED => 'Cancelada',
Purchase::STATUS_REJECTED => 'Rechazada',
Purchase::STATUS_EXPIRED => 'Vencida',
Purchase::STATUS_SUPERSEDED => 'Reemplazada',
];
return [

View File

@@ -114,6 +114,8 @@ class PurchaseController extends Controller
return false;
}
$purchaseState->lockCurrentCart($purchase);
$purchaseUpdate = [
'payment_method' => $method,
'status' => Purchase::STATUS_PENDING_PAYMENT,

View File

@@ -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,
];
}

View File

@@ -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);
}

View File

@@ -31,6 +31,8 @@ class EditCheckoutService
]);
}
$this->purchaseState->lockCurrentCart($purchase);
$purchase->update($customerData);
return $this->responses->load($purchase);

View File

@@ -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);
}

View File

@@ -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 */

View File

@@ -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;
}
}

View File

@@ -126,6 +126,7 @@ class AdminAppSaleService
return Purchase::query()
->where('tenant_codigo', $tenant->codigo)
->where('status', '!=', Purchase::STATUS_SUPERSEDED)
->when($filters['q'] ?? null, function (Builder $query, string $search): void {
$term = trim($search);