diff --git a/app/Domains/Purchase/Models/PurchaseItem.php b/app/Domains/Purchase/Models/PurchaseItem.php index 7f82f2c..2894caa 100644 --- a/app/Domains/Purchase/Models/PurchaseItem.php +++ b/app/Domains/Purchase/Models/PurchaseItem.php @@ -23,18 +23,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; 'discount_total', 'tax_total', 'total', - 'reservation_status', ])] class PurchaseItem extends Model { use HasFactory; - public const RESERVATION_ACTIVE = 'active'; - - public const RESERVATION_COMMITTED = 'committed'; - - public const RESERVATION_RELEASED = 'released'; - protected $table = 'compra_items'; protected function casts(): array diff --git a/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php b/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php index db246ed..cfa9233 100644 --- a/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php +++ b/app/Domains/Purchase/Services/Checkout/CatalogSelectionResolver.php @@ -5,7 +5,6 @@ namespace App\Domains\Purchase\Services\Checkout; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Variant; -use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Tenant\Models\Tenant; use Illuminate\Validation\ValidationException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -79,13 +78,4 @@ class CatalogSelectionResolver 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, - ); - } } diff --git a/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php b/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php index 09be4ef..adbfd58 100644 --- a/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php @@ -3,10 +3,8 @@ namespace App\Domains\Purchase\Services\Checkout; use App\Domains\Cart\Models\CartItem; -use App\Domains\Catalog\Services\CatalogInventoryService; use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Purchase\Models\Purchase; -use App\Domains\Purchase\Models\PurchaseItem; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -14,9 +12,7 @@ use Illuminate\Validation\ValidationException; class CompleteCheckoutService { public function __construct( - private readonly CatalogInventoryService $inventory, private readonly StockReservationService $reservations, - private readonly CatalogSelectionResolver $selections, private readonly SourceCartService $sourceCart, private readonly PurchaseItemSnapshotFactory $snapshots, ) {} @@ -89,70 +85,51 @@ class CompleteCheckoutService ]); } - $items = $purchase->items() - ->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE) - ->lockForUpdate() - ->get(); - - 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'), - ]); + if ($purchase->items()->exists()) { + $cart = $purchase->cart()->withTrashed()->lockForUpdate()->first(); + if ($cart?->status === 'converted' && $cart->trashed()) { + return; } - $cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get(); - if ($cartItems->isEmpty()) { - 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, + throw ValidationException::withMessages([ + 'items' => __('api.purchase.inconsistent_reservation'), ]); - $this->sourceCart->finalize($purchase); - - return; } - foreach ($items as $item) { - $selection = $this->selections->resolvePurchaseItem($purchase->tenant, $item); + $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(); + 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 { - $this->inventory->commit($selection, (int) $item->cantidad); + $this->reservations->commit($cartItem, $selection); } catch (\InvalidArgumentException) { throw ValidationException::withMessages([ 'items' => __('api.purchase.inconsistent_reservation'), ]); } - - $item->update([ - 'reservation_status' => PurchaseItem::RESERVATION_COMMITTED, - ]); } $this->sourceCart->finalize($purchase); diff --git a/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php b/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php index 6bee44a..0151a2b 100644 --- a/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php @@ -4,10 +4,8 @@ namespace App\Domains\Purchase\Services\Checkout; use App\Domains\Cart\Models\CartItem; use App\Domains\Catalog\Models\Variant; -use App\Domains\Catalog\Services\CatalogInventoryService; use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Purchase\Models\Purchase; -use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Purchase\Services\UserPurchaseLimitService; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -16,11 +14,9 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class EditCheckoutService { public function __construct( - private readonly CatalogInventoryService $inventory, private readonly StockReservationService $reservations, private readonly UserPurchaseLimitService $purchaseLimits, private readonly CatalogSelectionResolver $selections, - private readonly SourceCartService $sourceCart, ) {} /** @param array $customerData */ @@ -50,28 +46,7 @@ class EditCheckoutService ]); } - if (! $purchase->items()->exists()) { - 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); + return $this->updateCartItemQuantity($purchase, $itemId, $quantity); }); } @@ -91,9 +66,7 @@ class EditCheckoutService ), ]); - if (! $purchase->items()->exists()) { - $this->attachCartReservations($purchase); - } + $this->attachCartReservations($purchase); return $this->loadPurchase($purchase); }); @@ -151,60 +124,6 @@ class EditCheckoutService 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 { $cartItems = $purchase->cart?->items()->lockForUpdate()->get() ?? collect(); diff --git a/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php b/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php index e1711a2..582dfc1 100644 --- a/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php +++ b/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php @@ -5,7 +5,6 @@ namespace App\Domains\Purchase\Services\Checkout; use App\Domains\Attachable\Models\Attachment; use App\Domains\Cart\Models\CartItem; use App\Domains\Catalog\Models\Variant; -use App\Domains\Purchase\Models\PurchaseItem; use Illuminate\Support\Collection; class PurchaseItemSnapshotFactory @@ -38,7 +37,6 @@ class PurchaseItemSnapshotFactory 'discount_total' => null, 'tax_total' => null, 'total' => $unitPrice * $quantity, - 'reservation_status' => PurchaseItem::RESERVATION_ACTIVE, ]; }) ->all(); diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php index 8dc3ab9..dcf7c68 100644 --- a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -3,19 +3,15 @@ namespace App\Domains\Purchase\Services\Checkout; use App\Domains\Catalog\Models\StockReservation; -use App\Domains\Catalog\Services\CatalogInventoryService; use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Purchase\Models\Purchase; -use App\Domains\Purchase\Models\PurchaseItem; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; class ReleaseCheckoutService { public function __construct( - private readonly CatalogInventoryService $inventory, private readonly StockReservationService $reservations, - private readonly CatalogSelectionResolver $selections, private readonly SourceCartService $sourceCart, ) {} @@ -80,29 +76,15 @@ class ReleaseCheckoutService return $this->loadPurchase($purchase); } - $items = $purchase->items() - ->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE) - ->lockForUpdate() - ->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, + if ($purchase->items()->exists()) { + throw ValidationException::withMessages([ + 'items' => __('api.purchase.inconsistent_reservation'), ]); } + $reservationReturnedToCart = $restoreCart && $this->sourceCart->restore($purchase); + $this->releaseCartReservations($purchase, $reservationReturnedToCart, $targetStatus); + $purchase->update(['status' => $targetStatus]); 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 { return in_array($purchase->status, [ diff --git a/app/Domains/Purchase/Services/Checkout/SourceCartService.php b/app/Domains/Purchase/Services/Checkout/SourceCartService.php index d81a843..f21078d 100644 --- a/app/Domains/Purchase/Services/Checkout/SourceCartService.php +++ b/app/Domains/Purchase/Services/Checkout/SourceCartService.php @@ -6,7 +6,6 @@ use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\CartItem; use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Purchase\Models\Purchase; -use App\Domains\Purchase\Models\PurchaseItem; class SourceCartService { @@ -63,23 +62,6 @@ class SourceCartService 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 { $sourceCart = $this->findSourceCart($purchase); diff --git a/database/migrations/2026_08_19_020000_drop_reservation_status_from_compra_items_table.php b/database/migrations/2026_08_19_020000_drop_reservation_status_from_compra_items_table.php new file mode 100644 index 0000000..4ea3563 --- /dev/null +++ b/database/migrations/2026_08_19_020000_drop_reservation_status_from_compra_items_table.php @@ -0,0 +1,24 @@ +dropColumn('reservation_status'); + }); + } + + public function down(): void + { + Schema::table('compra_items', function (Blueprint $table): void { + $table->string('reservation_status') + ->default('committed') + ->after('total'); + }); + } +}; diff --git a/lang/en/api.php b/lang/en/api.php index fb1dbcf..f047b0e 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -37,7 +37,6 @@ return [ 'source_required' => 'A cart or direct item is required.', 'payment_method_required' => 'The purchase payment method must be selected before finalizing.', '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.', 'cannot_confirm' => 'A cancelled, rejected, or expired purchase cannot be confirmed.', 'inconsistent_reservation' => 'The purchase has an inconsistent stock reservation.', diff --git a/lang/es/api.php b/lang/es/api.php index f6ecbe7..52f6313 100644 --- a/lang/es/api.php +++ b/lang/es/api.php @@ -37,7 +37,6 @@ return [ '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.', '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.', 'cannot_confirm' => 'Una compra cancelada, rechazada o vencida no se puede confirmar.', 'inconsistent_reservation' => 'La compra tiene una reserva de stock inconsistente.', diff --git a/tests/Feature/Cart/CartControllerTest.php b/tests/Feature/Cart/CartControllerTest.php index 0238693..c31214a 100644 --- a/tests/Feature/Cart/CartControllerTest.php +++ b/tests/Feature/Cart/CartControllerTest.php @@ -523,7 +523,6 @@ class CartControllerTest extends TestCase 'cantidad' => $quantity, 'precio_unitario' => $item->precio, 'total' => (float) $item->precio * $quantity, - 'reservation_status' => PurchaseItem::RESERVATION_COMMITTED, ]); } diff --git a/tests/Feature/Purchase/PurchaseCatalogItemTest.php b/tests/Feature/Purchase/PurchaseCatalogItemTest.php index 79627bd..8d1ac8e 100644 --- a/tests/Feature/Purchase/PurchaseCatalogItemTest.php +++ b/tests/Feature/Purchase/PurchaseCatalogItemTest.php @@ -71,6 +71,7 @@ class PurchaseCatalogItemTest extends TestCase $this->assertFalse(Schema::hasColumn('compra_items', 'variant_id')); $this->assertFalse(Schema::hasColumn('compra_items', 'buyable_type')); $this->assertFalse(Schema::hasColumn('compra_items', 'buyable_id')); + $this->assertFalse(Schema::hasColumn('compra_items', 'reservation_status')); $this->assertDatabaseHas('compra_items', [ 'compra_id' => $purchase->id, 'source_catalog_item_id' => $catalogItem->id, diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index b4fb977..7cee351 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -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'); $user = User::factory()->create();