refactor(checkout): remove legacy purchase item reservations

This commit is contained in:
2026-08-19 12:14:57 -03:00
parent f1649e0e4b
commit aed99bd05e
13 changed files with 67 additions and 217 deletions

View File

@@ -23,18 +23,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
'discount_total', 'discount_total',
'tax_total', 'tax_total',
'total', 'total',
'reservation_status',
])] ])]
class PurchaseItem extends Model class PurchaseItem extends Model
{ {
use HasFactory; use HasFactory;
public const RESERVATION_ACTIVE = 'active';
public const RESERVATION_COMMITTED = 'committed';
public const RESERVATION_RELEASED = 'released';
protected $table = 'compra_items'; protected $table = 'compra_items';
protected function casts(): array protected function casts(): array

View File

@@ -5,7 +5,6 @@ namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -79,13 +78,4 @@ class CatalogSelectionResolver
return $variant; return $variant;
} }
public function resolvePurchaseItem(Tenant $tenant, PurchaseItem $item): CatalogItem|Variant
{
return $this->resolve(
$tenant,
(int) $item->source_catalog_item_id,
$item->source_variant_id === null ? null : (int) $item->source_variant_id,
);
}
} }

View File

@@ -3,10 +3,8 @@
namespace App\Domains\Purchase\Services\Checkout; namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Cart\Models\CartItem; use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -14,9 +12,7 @@ use Illuminate\Validation\ValidationException;
class CompleteCheckoutService class CompleteCheckoutService
{ {
public function __construct( public function __construct(
private readonly CatalogInventoryService $inventory,
private readonly StockReservationService $reservations, private readonly StockReservationService $reservations,
private readonly CatalogSelectionResolver $selections,
private readonly SourceCartService $sourceCart, private readonly SourceCartService $sourceCart,
private readonly PurchaseItemSnapshotFactory $snapshots, private readonly PurchaseItemSnapshotFactory $snapshots,
) {} ) {}
@@ -89,70 +85,51 @@ class CompleteCheckoutService
]); ]);
} }
$items = $purchase->items() if ($purchase->items()->exists()) {
->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE) $cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
->lockForUpdate() if ($cart?->status === 'converted' && $cart->trashed()) {
->get(); return;
if ($items->isEmpty() && ! $purchase->items()->exists()) {
$cart = $purchase->cart()->lockForUpdate()->first();
if ($cart === null || $cart->status !== 'checkout') {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
} }
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get(); throw ValidationException::withMessages([
if ($cartItems->isEmpty()) { 'items' => __('api.purchase.inconsistent_reservation'),
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
$this->loadCartItems($cartItems);
$items = $purchase->items()->createMany(
$this->snapshots->fromCartItems($cartItems),
);
foreach ($cartItems as $cartItem) {
$selection = $cartItem->selectedItem();
if ($selection === null) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
try {
$this->reservations->commit($cartItem, $selection);
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
}
$purchase->items()->update([
'reservation_status' => PurchaseItem::RESERVATION_COMMITTED,
]); ]);
$this->sourceCart->finalize($purchase);
return;
} }
foreach ($items as $item) { $cart = $purchase->cart()->lockForUpdate()->first();
$selection = $this->selections->resolvePurchaseItem($purchase->tenant, $item); if ($cart === null || $cart->status !== 'checkout') {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
if ($cartItems->isEmpty()) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
$this->loadCartItems($cartItems);
$purchase->items()->createMany(
$this->snapshots->fromCartItems($cartItems),
);
foreach ($cartItems as $cartItem) {
$selection = $cartItem->selectedItem();
if ($selection === null) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
try { try {
$this->inventory->commit($selection, (int) $item->cantidad); $this->reservations->commit($cartItem, $selection);
} catch (\InvalidArgumentException) { } catch (\InvalidArgumentException) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'), 'items' => __('api.purchase.inconsistent_reservation'),
]); ]);
} }
$item->update([
'reservation_status' => PurchaseItem::RESERVATION_COMMITTED,
]);
} }
$this->sourceCart->finalize($purchase); $this->sourceCart->finalize($purchase);

View File

@@ -4,10 +4,8 @@ namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Cart\Models\CartItem; use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Purchase\Services\UserPurchaseLimitService; use App\Domains\Purchase\Services\UserPurchaseLimitService;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -16,11 +14,9 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class EditCheckoutService class EditCheckoutService
{ {
public function __construct( public function __construct(
private readonly CatalogInventoryService $inventory,
private readonly StockReservationService $reservations, private readonly StockReservationService $reservations,
private readonly UserPurchaseLimitService $purchaseLimits, private readonly UserPurchaseLimitService $purchaseLimits,
private readonly CatalogSelectionResolver $selections, private readonly CatalogSelectionResolver $selections,
private readonly SourceCartService $sourceCart,
) {} ) {}
/** @param array<string, string> $customerData */ /** @param array<string, string> $customerData */
@@ -50,28 +46,7 @@ class EditCheckoutService
]); ]);
} }
if (! $purchase->items()->exists()) { return $this->updateCartItemQuantity($purchase, $itemId, $quantity);
return $this->updateCartItemQuantity($purchase, $itemId, $quantity);
}
$purchaseItem = $this->lockPurchaseItem($purchase, $itemId);
$difference = $quantity - (int) $purchaseItem->cantidad;
if ($difference !== 0) {
$this->adjustReservation($purchase, $purchaseItem, $quantity, $difference);
$purchaseItem->update([
'cantidad' => $quantity,
'total' => (float) $purchaseItem->precio_unitario * $quantity,
]);
$this->sourceCart->syncItemQuantity($purchase, $purchaseItem, $quantity);
}
$purchase->update([
'total' => $purchase->calculateCurrentTotalAmount(),
]);
return $this->loadPurchase($purchase);
}); });
} }
@@ -91,9 +66,7 @@ class EditCheckoutService
), ),
]); ]);
if (! $purchase->items()->exists()) { $this->attachCartReservations($purchase);
$this->attachCartReservations($purchase);
}
return $this->loadPurchase($purchase); return $this->loadPurchase($purchase);
}); });
@@ -151,60 +124,6 @@ class EditCheckoutService
return $this->loadPurchase($purchase); return $this->loadPurchase($purchase);
} }
private function adjustReservation(
Purchase $purchase,
PurchaseItem $purchaseItem,
int $quantity,
int $difference,
): void {
$selection = $this->selections->resolvePurchaseItem($purchase->tenant, $purchaseItem);
try {
if ($difference > 0) {
$otherItemQuantity = (int) $purchase->items()
->where('source_catalog_item_id', $purchaseItem->source_catalog_item_id)
->whereKeyNot($purchaseItem->getKey())
->sum('cantidad');
$catalogItem = $selection instanceof Variant ? $selection->catalogItem : $selection;
$this->purchaseLimits->assertCanPurchase(
$catalogItem,
(int) $purchase->user_id,
$otherItemQuantity + $quantity,
$purchase->getKey(),
);
$this->inventory->reserve($selection, $difference);
} else {
$this->inventory->release($selection, abs($difference));
}
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'quantity' => __('api.purchase.insufficient_stock'),
]);
}
}
private function lockPurchaseItem(Purchase $purchase, int $itemId): PurchaseItem
{
/** @var PurchaseItem|null $lockedItem */
$lockedItem = $purchase->items()
->whereKey($itemId)
->lockForUpdate()
->first();
if ($lockedItem === null) {
throw new NotFoundHttpException('Purchase item not found.');
}
if ($lockedItem->reservation_status !== PurchaseItem::RESERVATION_ACTIVE) {
throw ValidationException::withMessages([
'item' => __('api.purchase.item_not_editable'),
]);
}
return $lockedItem;
}
private function attachCartReservations(Purchase $purchase): void private function attachCartReservations(Purchase $purchase): void
{ {
$cartItems = $purchase->cart?->items()->lockForUpdate()->get() ?? collect(); $cartItems = $purchase->cart?->items()->lockForUpdate()->get() ?? collect();

View File

@@ -5,7 +5,6 @@ namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Models\Attachment;
use App\Domains\Cart\Models\CartItem; use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Purchase\Models\PurchaseItem;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
class PurchaseItemSnapshotFactory class PurchaseItemSnapshotFactory
@@ -38,7 +37,6 @@ class PurchaseItemSnapshotFactory
'discount_total' => null, 'discount_total' => null,
'tax_total' => null, 'tax_total' => null,
'total' => $unitPrice * $quantity, 'total' => $unitPrice * $quantity,
'reservation_status' => PurchaseItem::RESERVATION_ACTIVE,
]; ];
}) })
->all(); ->all();

View File

@@ -3,19 +3,15 @@
namespace App\Domains\Purchase\Services\Checkout; namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class ReleaseCheckoutService class ReleaseCheckoutService
{ {
public function __construct( public function __construct(
private readonly CatalogInventoryService $inventory,
private readonly StockReservationService $reservations, private readonly StockReservationService $reservations,
private readonly CatalogSelectionResolver $selections,
private readonly SourceCartService $sourceCart, private readonly SourceCartService $sourceCart,
) {} ) {}
@@ -80,29 +76,15 @@ class ReleaseCheckoutService
return $this->loadPurchase($purchase); return $this->loadPurchase($purchase);
} }
$items = $purchase->items() if ($purchase->items()->exists()) {
->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE) throw ValidationException::withMessages([
->lockForUpdate() 'items' => __('api.purchase.inconsistent_reservation'),
->get();
$reservationReturnedToCart = $restoreCart && $this->sourceCart->restore($purchase);
if ($items->isEmpty() && ! $purchase->items()->exists()) {
$this->releaseCartReservations($purchase, $reservationReturnedToCart, $targetStatus);
$purchase->update(['status' => $targetStatus]);
return $this->loadPurchase($purchase);
}
foreach ($items as $item) {
if (! $reservationReturnedToCart) {
$this->releaseInventory($purchase, $item);
}
$item->update([
'reservation_status' => PurchaseItem::RESERVATION_RELEASED,
]); ]);
} }
$reservationReturnedToCart = $restoreCart && $this->sourceCart->restore($purchase);
$this->releaseCartReservations($purchase, $reservationReturnedToCart, $targetStatus);
$purchase->update(['status' => $targetStatus]); $purchase->update(['status' => $targetStatus]);
return $this->loadPurchase($purchase); return $this->loadPurchase($purchase);
@@ -162,19 +144,6 @@ class ReleaseCheckoutService
} }
} }
private function releaseInventory(Purchase $purchase, PurchaseItem $item): void
{
$selection = $this->selections->resolvePurchaseItem($purchase->tenant, $item);
try {
$this->inventory->release($selection, (int) $item->cantidad);
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
}
private function isAlreadyReleased(Purchase $purchase): bool private function isAlreadyReleased(Purchase $purchase): bool
{ {
return in_array($purchase->status, [ return in_array($purchase->status, [

View File

@@ -6,7 +6,6 @@ use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem; use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
class SourceCartService class SourceCartService
{ {
@@ -63,23 +62,6 @@ class SourceCartService
return true; return true;
} }
public function syncItemQuantity(
Purchase $purchase,
PurchaseItem $purchaseItem,
int $quantity,
): void {
$sourceCart = $this->findSourceCart($purchase);
if ($sourceCart === null) {
return;
}
$sourceCart->items()
->where('catalog_item_id', $purchaseItem->source_catalog_item_id)
->where('variant_id', $purchaseItem->source_variant_id)
->update(['cantidad' => $quantity]);
}
public function finalize(Purchase $purchase): void public function finalize(Purchase $purchase): void
{ {
$sourceCart = $this->findSourceCart($purchase); $sourceCart = $this->findSourceCart($purchase);

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('compra_items', function (Blueprint $table): void {
$table->dropColumn('reservation_status');
});
}
public function down(): void
{
Schema::table('compra_items', function (Blueprint $table): void {
$table->string('reservation_status')
->default('committed')
->after('total');
});
}
};

View File

@@ -37,7 +37,6 @@ return [
'source_required' => 'A cart or direct item is required.', 'source_required' => 'A cart or direct item is required.',
'payment_method_required' => 'The purchase payment method must be selected before finalizing.', 'payment_method_required' => 'The purchase payment method must be selected before finalizing.',
'not_editable' => 'The purchase is no longer editable.', 'not_editable' => 'The purchase is no longer editable.',
'item_not_editable' => 'The purchase item is no longer editable.',
'insufficient_stock' => 'There is not enough stock available.', 'insufficient_stock' => 'There is not enough stock available.',
'cannot_confirm' => 'A cancelled, rejected, or expired purchase cannot be confirmed.', 'cannot_confirm' => 'A cancelled, rejected, or expired purchase cannot be confirmed.',
'inconsistent_reservation' => 'The purchase has an inconsistent stock reservation.', 'inconsistent_reservation' => 'The purchase has an inconsistent stock reservation.',

View File

@@ -37,7 +37,6 @@ return [
'source_required' => 'Se requiere un carrito o un producto directo.', 'source_required' => 'Se requiere un carrito o un producto directo.',
'payment_method_required' => 'Debes seleccionar el método de pago antes de finalizar la compra.', 'payment_method_required' => 'Debes seleccionar el método de pago antes de finalizar la compra.',
'not_editable' => 'La compra ya no se puede modificar.', 'not_editable' => 'La compra ya no se puede modificar.',
'item_not_editable' => 'El producto de la compra ya no se puede modificar.',
'insufficient_stock' => 'No hay suficiente stock disponible.', 'insufficient_stock' => 'No hay suficiente stock disponible.',
'cannot_confirm' => 'Una compra cancelada, rechazada o vencida no se puede confirmar.', 'cannot_confirm' => 'Una compra cancelada, rechazada o vencida no se puede confirmar.',
'inconsistent_reservation' => 'La compra tiene una reserva de stock inconsistente.', 'inconsistent_reservation' => 'La compra tiene una reserva de stock inconsistente.',

View File

@@ -523,7 +523,6 @@ class CartControllerTest extends TestCase
'cantidad' => $quantity, 'cantidad' => $quantity,
'precio_unitario' => $item->precio, 'precio_unitario' => $item->precio,
'total' => (float) $item->precio * $quantity, 'total' => (float) $item->precio * $quantity,
'reservation_status' => PurchaseItem::RESERVATION_COMMITTED,
]); ]);
} }

View File

@@ -71,6 +71,7 @@ class PurchaseCatalogItemTest extends TestCase
$this->assertFalse(Schema::hasColumn('compra_items', 'variant_id')); $this->assertFalse(Schema::hasColumn('compra_items', 'variant_id'));
$this->assertFalse(Schema::hasColumn('compra_items', 'buyable_type')); $this->assertFalse(Schema::hasColumn('compra_items', 'buyable_type'));
$this->assertFalse(Schema::hasColumn('compra_items', 'buyable_id')); $this->assertFalse(Schema::hasColumn('compra_items', 'buyable_id'));
$this->assertFalse(Schema::hasColumn('compra_items', 'reservation_status'));
$this->assertDatabaseHas('compra_items', [ $this->assertDatabaseHas('compra_items', [
'compra_id' => $purchase->id, 'compra_id' => $purchase->id,
'source_catalog_item_id' => $catalogItem->id, 'source_catalog_item_id' => $catalogItem->id,

View File

@@ -559,7 +559,7 @@ class StorePurchaseTest extends TestCase
]); ]);
} }
public function test_it_updates_a_created_purchase_item_quantity_and_its_stock_reservation(): void public function test_it_updates_a_checkout_cart_item_quantity_and_its_stock_reservation(): void
{ {
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create(); $user = User::factory()->create();