diff --git a/app/Domains/Cart/Controllers/CartController.php b/app/Domains/Cart/Controllers/CartController.php index d532a93..368a77c 100644 --- a/app/Domains/Cart/Controllers/CartController.php +++ b/app/Domains/Cart/Controllers/CartController.php @@ -2,7 +2,6 @@ namespace App\Domains\Cart\Controllers; -use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\CartItem; use App\Domains\Cart\Requests\AddCartItemRequest; use App\Domains\Cart\Requests\UpdateCartItemQuantityRequest; @@ -78,36 +77,6 @@ class CartController extends Controller ]); } - public function updateCheckoutItem( - UpdateCartItemQuantityRequest $request, - Tenant $tenant, - Cart $cart, - CartItem $cartItem, - ): CartResource { - $updatesVariant = $request->exists('variant_id'); - - return CartResource::make( - $this->cartService->updateCheckoutItem( - $tenant, - $request, - $cart, - $cartItem->getKey(), - (int) $request->validated('cantidad'), - $updatesVariant - ? ($request->validated('variant_id') !== null - ? (int) $request->validated('variant_id') - : null) - : $cartItem->variant_id, - $updatesVariant, - ), - )->additional([ - 'code' => $updatesVariant ? 'cart.item_updated' : 'cart.quantity_updated', - 'message' => $updatesVariant - ? __('api.cart.item_updated') - : __('api.cart.quantity_updated'), - ]); - } - public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource { return CartResource::make( @@ -117,23 +86,4 @@ class CartController extends Controller 'message' => __('api.cart.item_removed'), ]); } - - public function removeCheckoutItem( - Request $request, - Tenant $tenant, - Cart $cart, - CartItem $cartItem, - ): CartResource { - return CartResource::make( - $this->cartService->removeCheckoutItem( - $tenant, - $request, - $cart, - $cartItem->getKey(), - ), - )->additional([ - 'code' => 'cart.item_removed', - 'message' => __('api.cart.item_removed'), - ]); - } } diff --git a/app/Domains/Cart/Resources/CartItemResource.php b/app/Domains/Cart/Resources/CartItemResource.php index 47992f3..4d960d6 100644 --- a/app/Domains/Cart/Resources/CartItemResource.php +++ b/app/Domains/Cart/Resources/CartItemResource.php @@ -2,7 +2,6 @@ namespace App\Domains\Cart\Resources; -use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\CartItem; use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Models\Variant; @@ -23,13 +22,9 @@ class CartItemResource extends JsonResource $selectedItem = $this->selectedItem(); $imageUrl = null; $tenant = $request->route('tenant'); - $checkoutCart = $request->route('cart'); $displayImage = ! $tenant instanceof Tenant || $tenant->display_cart_item_images; $includeVariants = $tenant instanceof Tenant - && ($checkoutCart instanceof Cart - ? $tenant->checkout_editing_policy - : $tenant->cart_editing_policy) - ->allowsVariantChanges(); + && $tenant->cart_editing_policy->allowsVariantChanges(); if ($displayImage && $selectedItem?->relationLoaded('attachments')) { $imageUrl = $selectedItem->attachments->first()?->getTemporaryUrl(1440); diff --git a/app/Domains/Cart/Services/CartService.php b/app/Domains/Cart/Services/CartService.php index c6b1e79..7974306 100644 --- a/app/Domains/Cart/Services/CartService.php +++ b/app/Domains/Cart/Services/CartService.php @@ -4,14 +4,9 @@ namespace App\Domains\Cart\Services; use App\Domains\Auth\Models\User; 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\Services\PurchaseStateGuard; use App\Domains\Tenant\Models\Tenant; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Symfony\Component\HttpFoundation\Cookie; @@ -19,11 +14,6 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class CartService { - public function __construct( - private readonly StockReservationService $reservations, - private readonly PurchaseStateGuard $purchaseState, - ) {} - public function show(Tenant $tenant, Request $request): Cart { $resolvedIdentity = $this->resolveIdentity($request); @@ -89,125 +79,6 @@ class CartService return $this->loadCart($cart, $tenant); } - public function updateCheckoutItem( - Tenant $tenant, - Request $request, - Cart $cart, - int $cartItemId, - int $quantity, - ?int $variantId, - bool $updateVariant, - ): Cart { - $user = $request->user() ?? Auth::guard('sanctum')->user(); - - if (! $user instanceof User) { - throw new NotFoundHttpException('Checkout cart not found.'); - } - - return DB::transaction(function () use ( - $tenant, - $user, - $cart, - $cartItemId, - $quantity, - $variantId, - $updateVariant, - ): Cart { - /** @var Purchase|null $purchase */ - $purchase = Purchase::query() - ->where('cart_id', $cart->getKey()) - ->where('tenant_codigo', $tenant->codigo) - ->where('user_id', $user->getKey()) - ->whereDoesntHave('items') - ->lockForUpdate() - ->first(); - - if ($purchase === null) { - throw new NotFoundHttpException('Checkout cart not found.'); - } - - $this->purchaseState->assertNotExpired($purchase); - - if (! in_array($purchase->status, [ - Purchase::STATUS_CREATED, - Purchase::STATUS_PENDING_PAYMENT, - ], true)) { - throw new NotFoundHttpException('Checkout cart not found.'); - } - - /** @var Cart|null $checkoutCart */ - $checkoutCart = Cart::query() - ->whereKey($cart->getKey()) - ->where('tenant_codigo', $tenant->codigo) - ->where('user_id', $user->getKey()) - ->where('status', 'checkout') - ->lockForUpdate() - ->first(); - - if ($checkoutCart === null) { - throw new NotFoundHttpException('Checkout cart not found.'); - } - - /** @var CartItem|null $cartItem */ - $cartItem = $checkoutCart->items() - ->whereKey($cartItemId) - ->lockForUpdate() - ->first(); - - if ($cartItem === null) { - throw new NotFoundHttpException('Checkout item not found.'); - } - - $hasChanges = (int) $cartItem->cantidad !== $quantity - || ($updateVariant && $cartItem->variant_id !== $variantId); - - if (! $hasChanges) { - return $this->loadCart($checkoutCart, $tenant, true); - } - - if ( - (int) $cartItem->cantidad !== $quantity - && ! $tenant->checkout_editing_policy->allowsQuantityChanges() - ) { - throw ValidationException::withMessages([ - 'cantidad' => __('api.cart.editing_disabled'), - ]); - } - - if ( - $updateVariant - && $cartItem->variant_id !== $variantId - && ! $tenant->checkout_editing_policy->allowsVariantChanges() - ) { - throw ValidationException::withMessages([ - 'variant_id' => __('api.cart.variant_change_disabled'), - ]); - } - - $checkoutCart->updateItem( - $cartItemId, - $quantity, - $variantId, - $updateVariant, - $purchase->getKey(), - ); - - $purchase->telepagosQr()->delete(); - $purchase->update([ - 'status' => Purchase::STATUS_CREATED, - 'payment_method' => null, - 'transfer_payer_dni' => null, - 'total' => $checkoutCart->getTotalAmount(), - 'expires_at' => now()->addMinutes( - max(1, (int) config('purchase.checkout_expiration_minutes', 30)), - ), - ]); - $this->reservations->syncPurchaseExpiration($purchase); - - return $this->loadCart($checkoutCart, $tenant, true); - }); - } - public function removeItem(Tenant $tenant, Request $request, int $cartItemId): Cart { if (! $tenant->cart_editing_policy->allowsRemoval()) { @@ -223,77 +94,6 @@ class CartService return $this->loadCart($cart, $tenant); } - public function removeCheckoutItem( - Tenant $tenant, - Request $request, - Cart $cart, - int $cartItemId, - ): Cart { - if (! $tenant->checkout_editing_policy->allowsRemoval()) { - throw ValidationException::withMessages([ - 'cart_item' => __('api.cart.editing_disabled'), - ]); - } - - $user = $request->user() ?? Auth::guard('sanctum')->user(); - - if (! $user instanceof User) { - throw new NotFoundHttpException('Checkout cart not found.'); - } - - return DB::transaction(function () use ($tenant, $user, $cart, $cartItemId): Cart { - /** @var Purchase|null $purchase */ - $purchase = Purchase::query() - ->where('cart_id', $cart->getKey()) - ->where('tenant_codigo', $tenant->codigo) - ->where('user_id', $user->getKey()) - ->whereDoesntHave('items') - ->lockForUpdate() - ->first(); - - if ($purchase === null) { - throw new NotFoundHttpException('Checkout cart not found.'); - } - - $this->purchaseState->assertNotExpired($purchase); - - if (! in_array($purchase->status, [ - Purchase::STATUS_CREATED, - Purchase::STATUS_PENDING_PAYMENT, - ], true)) { - throw new NotFoundHttpException('Checkout cart not found.'); - } - - /** @var Cart|null $checkoutCart */ - $checkoutCart = Cart::query() - ->whereKey($cart->getKey()) - ->where('tenant_codigo', $tenant->codigo) - ->where('user_id', $user->getKey()) - ->where('status', 'checkout') - ->lockForUpdate() - ->first(); - - if ($checkoutCart === null) { - throw new NotFoundHttpException('Checkout cart not found.'); - } - - $checkoutCart->removeItem($cartItemId); - $purchase->telepagosQr()->delete(); - $purchase->update([ - 'status' => Purchase::STATUS_CREATED, - 'payment_method' => null, - 'transfer_payer_dni' => null, - 'total' => $checkoutCart->getTotalAmount(), - 'expires_at' => now()->addMinutes( - max(1, (int) config('purchase.checkout_expiration_minutes', 30)), - ), - ]); - $this->reservations->syncPurchaseExpiration($purchase); - - return $this->loadCart($checkoutCart, $tenant, true); - }); - } - public function makeGuestTokenCookie(string $guestToken): Cookie { $secure = (bool) config('session.secure'); @@ -327,7 +127,7 @@ class CartService return $cart; } - protected function loadCart(Cart $cart, Tenant $tenant, bool $isCheckout = false): Cart + protected function loadCart(Cart $cart, Tenant $tenant): Cart { $relations = [ 'items.catalogItem.attachments', @@ -340,11 +140,7 @@ class CartService 'items.variant.eventDate', ]; - $editingPolicy = $isCheckout - ? $tenant->checkout_editing_policy - : $tenant->cart_editing_policy; - - if ($editingPolicy->allowsVariantChanges()) { + if ($tenant->cart_editing_policy->allowsVariantChanges()) { $relations = [ ...$relations, 'items.catalogItem.variants' => fn ($query) => $query->orderBy('id'), diff --git a/app/Domains/Cart/routes/api.php b/app/Domains/Cart/routes/api.php index 07e73f0..28488a7 100644 --- a/app/Domains/Cart/routes/api.php +++ b/app/Domains/Cart/routes/api.php @@ -10,10 +10,3 @@ Route::prefix('tenants/{tenant:codigo}') Route::patch('cart/items/{cartItem}', [CartController::class, 'updateItemQuantity']); Route::delete('cart/items/{cartItem}', [CartController::class, 'removeItem']); }); - -Route::prefix('tenants/{tenant:codigo}') - ->middleware('auth:sanctum') - ->group(function (): void { - Route::patch('checkout-carts/{cart}/items/{cartItem}', [CartController::class, 'updateCheckoutItem'])->withTrashed(); - Route::delete('checkout-carts/{cart}/items/{cartItem}', [CartController::class, 'removeCheckoutItem'])->withTrashed(); - }); diff --git a/app/Domains/Purchase/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php index 4d25a08..7d6a1b3 100644 --- a/app/Domains/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -4,11 +4,9 @@ namespace App\Domains\Purchase\Controllers; use App\Domains\Integration\Services\TelepagosIntegrationService; use App\Domains\Purchase\Models\Purchase; -use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Purchase\Requests\PaymentIntentRequest; use App\Domains\Purchase\Requests\StartCheckoutRequest; use App\Domains\Purchase\Requests\UpdatePurchaseCustomerRequest; -use App\Domains\Purchase\Requests\UpdatePurchaseItemRequest; use App\Domains\Purchase\Resources\PurchaseResource; use App\Domains\Purchase\Services\Checkout\PurchaseResponseLoader; use App\Domains\Purchase\Services\CheckoutService; @@ -81,53 +79,6 @@ class PurchaseController extends Controller ); } - public function updateItem( - UpdatePurchaseItemRequest $request, - Tenant $tenant, - Purchase $compra, - PurchaseItem $item, - CheckoutService $checkoutService, - ): PurchaseResource { - $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); - - return PurchaseResource::make( - $checkoutService->updateItem( - $compra, - $item, - $request->exists('quantity') ? (int) $request->validated('quantity') : null, - $request->exists('variant_id') ? (int) $request->validated('variant_id') : null, - $request->exists('variant_id'), - ), - ); - } - - public function prepareItemEditing( - Request $request, - Tenant $tenant, - Purchase $compra, - CheckoutService $checkoutService, - ): PurchaseResource { - $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); - - return PurchaseResource::make( - $checkoutService->prepareItemEditing($compra), - ); - } - - public function removeItem( - Request $request, - Tenant $tenant, - Purchase $compra, - PurchaseItem $item, - CheckoutService $checkoutService, - ): PurchaseResource { - $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); - - return PurchaseResource::make( - $checkoutService->removeItem($compra, $item), - ); - } - public function paymentIntent( PaymentIntentRequest $request, Tenant $tenant, diff --git a/app/Domains/Purchase/Requests/UpdatePurchaseItemRequest.php b/app/Domains/Purchase/Requests/UpdatePurchaseItemRequest.php deleted file mode 100644 index ba05e14..0000000 --- a/app/Domains/Purchase/Requests/UpdatePurchaseItemRequest.php +++ /dev/null @@ -1,22 +0,0 @@ -user() !== null; - } - - /** @return array */ - public function rules(): array - { - return [ - 'quantity' => ['sometimes', 'required_without:variant_id', 'integer', 'min:1', 'max:100'], - 'variant_id' => ['sometimes', 'required_without:quantity', 'integer', 'min:1'], - ]; - } -} diff --git a/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php b/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php index 58e7ec9..b1bb011 100644 --- a/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php @@ -2,24 +2,14 @@ namespace App\Domains\Purchase\Services\Checkout; -use App\Domains\Catalog\Models\Variant; -use App\Domains\Catalog\Services\CatalogInventoryService; use App\Domains\Purchase\Models\Purchase; -use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Purchase\Services\PurchaseStateGuard; -use App\Domains\Purchase\Services\UserPurchaseLimitService; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class EditCheckoutService { public function __construct( - private readonly CatalogInventoryService $inventory, - private readonly UserPurchaseLimitService $purchaseLimits, - private readonly CatalogSelectionResolver $selections, - private readonly SourceCartService $sourceCart, - private readonly PurchaseItemSnapshotFactory $snapshots, private readonly PurchaseResponseLoader $responses, private readonly PurchaseStateGuard $purchaseState, ) {} @@ -28,295 +18,22 @@ class EditCheckoutService public function updateCustomer(Purchase $purchase, array $customerData): Purchase { return DB::transaction(function () use ($purchase, $customerData): Purchase { - $purchase = $this->lockPurchase($purchase); - $this->purchaseState->assertNotExpired($purchase); - $this->assertEditable($purchase); - - $purchase->update($customerData); - - return $this->loadPurchase($purchase); - }); - } - - public function updateItem( - Purchase $purchase, - PurchaseItem $purchaseItem, - ?int $quantity, - ?int $variantId, - bool $updateVariant, - ): Purchase { - return DB::transaction(function () use ( - $purchase, - $purchaseItem, - $quantity, - $variantId, - $updateVariant, - ): Purchase { - $purchase = $this->lockPurchase($purchase); + /** @var Purchase $purchase */ + $purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey()); $this->purchaseState->assertNotExpired($purchase); - if ($purchase->status !== Purchase::STATUS_CREATED || $this->hasExpired($purchase)) { + if (! in_array($purchase->status, [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + ], true)) { throw ValidationException::withMessages([ 'purchase' => __('api.purchase.not_editable'), ]); } - $purchaseItem = $this->lockPurchaseItem($purchase, $purchaseItem); - $tenant = $purchase->tenant()->firstOrFail(); - $finalQuantity = $quantity ?? (int) $purchaseItem->cantidad; + $purchase->update($customerData); - if ($quantity !== null && ! $tenant->checkout_editing_policy->allowsQuantityChanges()) { - throw ValidationException::withMessages([ - 'quantity' => __('api.cart.editing_disabled'), - ]); - } - - if ($updateVariant && ! $tenant->checkout_editing_policy->allowsVariantChanges()) { - throw ValidationException::withMessages([ - 'variant_id' => __('api.purchase.variant_change_disabled'), - ]); - } - - if ($updateVariant && $variantId !== $purchaseItem->source_variant_id) { - $this->changeItemVariant( - $purchase, - $purchaseItem, - (int) $variantId, - $finalQuantity, - ); - } else { - $difference = $finalQuantity - (int) $purchaseItem->cantidad; - - if ($difference !== 0) { - $this->adjustReservation($purchase, $purchaseItem, $finalQuantity, $difference); - - $purchaseItem->update([ - 'cantidad' => $finalQuantity, - 'total' => (float) $purchaseItem->precio_unitario * $finalQuantity, - ]); - $this->sourceCart->syncItemQuantity($purchase, $purchaseItem, $finalQuantity); - } - } - - $purchase->update([ - 'total' => $purchase->calculateCurrentTotalAmount(), - ]); - - return $this->loadPurchase($purchase); + return $this->responses->load($purchase); }); } - - private function changeItemVariant( - Purchase $purchase, - PurchaseItem $sourceItem, - int $variantId, - int $quantity, - ): void { - $tenant = $purchase->tenant()->firstOrFail(); - $currentSelection = $this->selections->resolvePurchaseItem($tenant, $sourceItem); - $targetSelection = $this->selections->resolve( - $tenant, - (int) $sourceItem->source_catalog_item_id, - $variantId, - 'item', - ); - - if (! $targetSelection instanceof Variant) { - throw ValidationException::withMessages([ - 'variant_id' => __('api.cart.variant_required'), - ]); - } - - /** @var PurchaseItem|null $targetItem */ - $targetItem = $purchase->items() - ->where('source_catalog_item_id', $sourceItem->source_catalog_item_id) - ->where('source_variant_id', $targetSelection->id) - ->whereKeyNot($sourceItem->getKey()) - ->lockForUpdate() - ->first(); - - if ($targetItem !== null && $targetItem->reservation_status !== PurchaseItem::RESERVATION_ACTIVE) { - throw ValidationException::withMessages([ - 'variant_id' => __('api.purchase.item_not_editable'), - ]); - } - - $otherItemQuantity = (int) $purchase->items() - ->where('source_catalog_item_id', $sourceItem->source_catalog_item_id) - ->whereKeyNot($sourceItem->getKey()) - ->sum('cantidad'); - $this->purchaseLimits->assertCanPurchase( - $targetSelection->catalogItem, - (int) $purchase->user_id, - $otherItemQuantity + $quantity, - $purchase->getKey(), - 'variant_id', - ); - - try { - $this->inventory->release($currentSelection, (int) $sourceItem->cantidad); - $this->inventory->reserve($targetSelection, $quantity); - } catch (\InvalidArgumentException) { - throw ValidationException::withMessages([ - 'variant_id' => __('api.purchase.insufficient_stock'), - ]); - } - - $previousVariantId = $sourceItem->source_variant_id; - $finalQuantity = $quantity; - - if ($targetItem !== null) { - $finalQuantity += (int) $targetItem->cantidad; - $targetItem->update($this->snapshots->fromVariant($targetSelection, $finalQuantity)); - $sourceItem->delete(); - } else { - $sourceItem->update($this->snapshots->fromVariant($targetSelection, $finalQuantity)); - } - - $this->sourceCart->syncItemSelection( - $purchase, - (int) $sourceItem->source_catalog_item_id, - $previousVariantId, - $targetSelection->id, - $finalQuantity, - ); - } - - public function prepareItemEditing(Purchase $purchase): Purchase - { - return DB::transaction(function () use ($purchase): Purchase { - $purchase = $this->lockPurchase($purchase); - $this->purchaseState->assertNotExpired($purchase); - $this->assertEditable($purchase); - - if (! $purchase->tenant()->firstOrFail()->checkout_editing_policy->allowsModification()) { - throw ValidationException::withMessages([ - 'purchase' => __('api.cart.editing_disabled'), - ]); - } - - $purchase->telepagosQr()->delete(); - $purchase->update([ - 'status' => Purchase::STATUS_CREATED, - 'payment_method' => null, - 'transfer_payer_dni' => null, - 'expires_at' => now()->addMinutes( - max(1, (int) config('purchase.checkout_expiration_minutes', 30)), - ), - ]); - - return $this->loadPurchase($purchase); - }); - } - - public function removeItem(Purchase $purchase, PurchaseItem $purchaseItem): Purchase - { - return DB::transaction(function () use ($purchase, $purchaseItem): Purchase { - $purchase = $this->lockPurchase($purchase); - $this->purchaseState->assertNotExpired($purchase); - $this->assertEditable($purchase); - - if (! $purchase->tenant()->firstOrFail()->checkout_editing_policy->allowsRemoval()) { - throw ValidationException::withMessages([ - 'item' => __('api.cart.editing_disabled'), - ]); - } - - $purchaseItem = $this->lockPurchaseItem($purchase, $purchaseItem); - $selection = $this->selections->resolvePurchaseItem($purchase->tenant, $purchaseItem); - $this->inventory->release($selection, (int) $purchaseItem->cantidad); - $this->sourceCart->removeItem($purchase, $purchaseItem); - $purchaseItem->delete(); - $purchase->update([ - 'total' => $purchase->calculateCurrentTotalAmount(), - ]); - - 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, PurchaseItem $item): PurchaseItem - { - /** @var PurchaseItem|null $lockedItem */ - $lockedItem = $purchase->items() - ->whereKey($item->getKey()) - ->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 assertEditable(Purchase $purchase): void - { - if ( - ! in_array($purchase->status, [ - Purchase::STATUS_CREATED, - Purchase::STATUS_PENDING_PAYMENT, - ], true) - || $this->hasExpired($purchase) - ) { - throw ValidationException::withMessages([ - 'purchase' => __('api.purchase.not_editable'), - ]); - } - } - - private function hasExpired(Purchase $purchase): bool - { - return $purchase->expires_at !== null && $purchase->expires_at->isPast(); - } - - private function lockPurchase(Purchase $purchase): Purchase - { - /** @var Purchase */ - return Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey()); - } - - private function loadPurchase(Purchase $purchase): Purchase - { - return $this->responses->load($purchase); - } } diff --git a/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php b/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php index 87c1bee..582dfc1 100644 --- a/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php +++ b/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php @@ -9,35 +9,6 @@ use Illuminate\Support\Collection; class PurchaseItemSnapshotFactory { - /** @return array */ - public function fromVariant(Variant $variant, int $quantity): array - { - $variant->loadMissing([ - 'attachments', - 'catalogItem.attachments', - 'definitions.itemAttribute.attribute.options', - 'eventDates', - 'eventDate', - ]); - $unitPrice = $variant->getPrice(); - - return [ - 'source_variant_id' => $variant->id, - 'image_attachment_id' => $variant->attachments->first()?->id - ?? $variant->catalogItem->attachments->first()?->id, - 'nombre' => $variant->catalogItem->nombre, - 'descripcion' => $variant->getDescription(), - 'slug' => $variant->catalogItem->slug, - 'item_nombre' => $variant->getName(), - 'variant_attributes' => $this->snapshotAttributes($variant), - 'cantidad' => $quantity, - 'precio_unitario' => $unitPrice, - 'discount_total' => null, - 'tax_total' => null, - 'total' => $unitPrice * $quantity, - ]; - } - /** * @param Collection $cartItems * @return array> diff --git a/app/Domains/Purchase/Services/Checkout/SourceCartService.php b/app/Domains/Purchase/Services/Checkout/SourceCartService.php index 875378e..6c75efd 100644 --- a/app/Domains/Purchase/Services/Checkout/SourceCartService.php +++ b/app/Domains/Purchase/Services/Checkout/SourceCartService.php @@ -83,88 +83,6 @@ class SourceCartService } } - 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 syncItemSelection( - Purchase $purchase, - int $catalogItemId, - ?int $previousVariantId, - int $newVariantId, - int $finalQuantity, - ): void { - $sourceCart = $this->findSourceCart($purchase); - - if ($sourceCart === null) { - return; - } - - /** @var CartItem|null $previousItem */ - $previousItem = $sourceCart->items() - ->where('catalog_item_id', $catalogItemId) - ->where('variant_id', $previousVariantId) - ->lockForUpdate() - ->first(); - - /** @var CartItem|null $targetItem */ - $targetItem = $sourceCart->items() - ->where('catalog_item_id', $catalogItemId) - ->where('variant_id', $newVariantId) - ->when($previousItem !== null, fn ($query) => $query->whereKeyNot($previousItem->getKey())) - ->lockForUpdate() - ->first(); - - if ($targetItem !== null) { - $targetItem->update(['cantidad' => $finalQuantity]); - $previousItem?->delete(); - - return; - } - - if ($previousItem !== null) { - $previousItem->update([ - 'variant_id' => $newVariantId, - 'cantidad' => $finalQuantity, - ]); - - return; - } - - $sourceCart->items()->create([ - 'catalog_item_id' => $catalogItemId, - 'variant_id' => $newVariantId, - 'cantidad' => $finalQuantity, - ]); - } - - public function removeItem(Purchase $purchase, PurchaseItem $purchaseItem): 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) - ->delete(); - } - public function finalize(Purchase $purchase): void { $sourceCart = $this->findSourceCart($purchase); diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index fb55849..411f1b2 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -4,7 +4,6 @@ namespace App\Domains\Purchase\Services; use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Purchase\Models\Purchase; -use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService; use App\Domains\Purchase\Services\Checkout\EditCheckoutService; use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService; @@ -49,32 +48,6 @@ class CheckoutService return $this->editor->updateCustomer($purchase, $customerData); } - public function updateItem( - Purchase $purchase, - PurchaseItem $purchaseItem, - ?int $quantity, - ?int $variantId, - bool $updateVariant, - ): Purchase { - return $this->editor->updateItem( - $purchase, - $purchaseItem, - $quantity, - $variantId, - $updateVariant, - ); - } - - public function prepareItemEditing(Purchase $purchase): Purchase - { - return $this->editor->prepareItemEditing($purchase); - } - - public function removeItem(Purchase $purchase, PurchaseItem $purchaseItem): Purchase - { - return $this->editor->removeItem($purchase, $purchaseItem); - } - public function confirmPurchase(Purchase $purchase): void { $this->completer->confirm($purchase); diff --git a/app/Domains/Purchase/routes/api.php b/app/Domains/Purchase/routes/api.php index cd6ce4d..f8e8d5d 100644 --- a/app/Domains/Purchase/routes/api.php +++ b/app/Domains/Purchase/routes/api.php @@ -7,9 +7,6 @@ Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(func Route::get('compras', [PurchaseController::class, 'index']); Route::post('compras/start-checkout', [PurchaseController::class, 'startCheckout']); Route::get('compras/{compra}', [PurchaseController::class, 'show']); - Route::post('compras/{compra}/edit-items', [PurchaseController::class, 'prepareItemEditing']); - Route::patch('compras/{compra}/items/{item}', [PurchaseController::class, 'updateItem']); - Route::delete('compras/{compra}/items/{item}', [PurchaseController::class, 'removeItem']); Route::patch('compras/{compra}/customer-data', [PurchaseController::class, 'updateCustomerData']); Route::post('compras/{compra}/payment-intent', [PurchaseController::class, 'paymentIntent']); Route::post('compras/{compra}/complete', [PurchaseController::class, 'complete']); diff --git a/app/Domains/Tenant/Requests/StoreTenantRequest.php b/app/Domains/Tenant/Requests/StoreTenantRequest.php index fa2480e..ee37d92 100644 --- a/app/Domains/Tenant/Requests/StoreTenantRequest.php +++ b/app/Domains/Tenant/Requests/StoreTenantRequest.php @@ -111,7 +111,10 @@ class StoreTenantRequest extends FormRequest 'display_seach_bar' => ['sometimes', 'boolean'], 'display_cart' => ['sometimes', 'boolean'], 'cart_editing_policy' => ['sometimes', Rule::enum(CartEditingPolicy::class)], - 'checkout_editing_policy' => ['sometimes', Rule::enum(CartEditingPolicy::class)], + 'checkout_editing_policy' => [ + 'sometimes', + Rule::in([CartEditingPolicy::Disabled->value]), + ], 'display_cart_item_images' => ['sometimes', 'boolean'], 'scanner_category_validation_enabled' => ['sometimes', 'boolean'], 'website_type_code' => [ diff --git a/app/Domains/Tenant/Requests/UpdateTenantRequest.php b/app/Domains/Tenant/Requests/UpdateTenantRequest.php index b0ca850..e7a06bc 100644 --- a/app/Domains/Tenant/Requests/UpdateTenantRequest.php +++ b/app/Domains/Tenant/Requests/UpdateTenantRequest.php @@ -132,7 +132,10 @@ class UpdateTenantRequest extends FormRequest 'display_seach_bar' => ['sometimes', 'boolean'], 'display_cart' => ['sometimes', 'boolean'], 'cart_editing_policy' => ['sometimes', Rule::enum(CartEditingPolicy::class)], - 'checkout_editing_policy' => ['sometimes', Rule::enum(CartEditingPolicy::class)], + 'checkout_editing_policy' => [ + 'sometimes', + Rule::in([CartEditingPolicy::Disabled->value]), + ], 'display_cart_item_images' => ['sometimes', 'boolean'], 'scanner_category_validation_enabled' => ['sometimes', 'boolean'], ]; diff --git a/database/migrations/2026_08_21_000000_disable_checkout_item_editing.php b/database/migrations/2026_08_21_000000_disable_checkout_item_editing.php new file mode 100644 index 0000000..2e2df73 --- /dev/null +++ b/database/migrations/2026_08_21_000000_disable_checkout_item_editing.php @@ -0,0 +1,20 @@ +update([ + 'checkout_editing_policy' => 'disabled', + ]); + } + + public function down(): void + { + // Checkout editing stays disabled because previous tenant values cannot + // be reconstructed after normalization. + } +}; diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php index a28a448..3260e38 100644 --- a/tests/Feature/Integration/TelepagosWebhookTest.php +++ b/tests/Feature/Integration/TelepagosWebhookTest.php @@ -194,8 +194,9 @@ class TelepagosWebhookTest extends TestCase 'sold_units' => 1, ]); - $this->assertDatabaseMissing('compra_items', [ + $this->assertDatabaseHas('compra_items', [ 'compra_id' => $newerPurchase->id, + 'cantidad' => 2, ]); $this->assertDatabaseHas('stock_reservations', [ 'purchase_id' => $newerPurchase->id, diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index d236352..12901e4 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -30,7 +30,7 @@ class StorePurchaseTest extends TestCase Queue::fake(); } - public function test_it_starts_checkout_from_cart_without_materializing_purchase_items(): void + public function test_it_starts_checkout_from_cart_with_purchase_item_snapshots(): void { $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $tenant->update([ @@ -90,7 +90,7 @@ class StorePurchaseTest extends TestCase $response->assertJsonPath('data.email', null); $response->assertJsonPath('data.tenant_codigo', 'sonder'); $response->assertJsonPath('data.status', Purchase::STATUS_CREATED); - $response->assertJsonPath('data.items_source', 'cart'); + $response->assertJsonPath('data.items_source', 'purchase'); $response->assertJsonCount(1, 'data.items'); $response->assertJsonPath('data.subtotal', '100.00'); $response->assertJsonPath('data.total', '100.00'); @@ -109,7 +109,14 @@ class StorePurchaseTest extends TestCase 'status' => Purchase::STATUS_CREATED, 'total' => 100, ]); - $this->assertDatabaseMissing('compra_items', ['compra_id' => $purchaseId]); + $this->assertDatabaseHas('compra_items', [ + 'compra_id' => $purchaseId, + 'source_catalog_item_id' => $catalogItem->id, + 'source_variant_id' => $variant->id, + 'cantidad' => 2, + 'precio_unitario' => '50.00', + 'total' => '100.00', + ]); $this->assertDatabaseHas('stock_reservations', [ 'inventory_id' => $inventory->id, 'purchase_id' => $purchaseId, @@ -142,10 +149,10 @@ class StorePurchaseTest extends TestCase $this->actingAs($user, 'sanctum') ->getJson("/api/tenants/sonder/compras/{$purchaseId}") ->assertOk() - ->assertJsonPath('data.items_source', 'cart') - ->assertJsonPath('data.items.0.item_details.nombre', 'Updated Product') - ->assertJsonPath('data.items.0.unit_price', '75.00') - ->assertJsonPath('data.total', '150.00'); + ->assertJsonPath('data.items_source', 'purchase') + ->assertJsonPath('data.items.0.item_details.nombre', 'Test Product') + ->assertJsonPath('data.items.0.unit_price', '50.00') + ->assertJsonPath('data.total', '100.00'); } public function test_it_creates_a_direct_purchase_with_a_technical_checkout_cart(): void @@ -165,7 +172,7 @@ class StorePurchaseTest extends TestCase ], ]) ->assertCreated() - ->assertJsonPath('data.items_source', 'cart') + ->assertJsonPath('data.items_source', 'purchase') ->assertJsonPath('data.items.0.quantity', 3) ->assertJsonPath('data.total', '150.00'); @@ -174,8 +181,12 @@ class StorePurchaseTest extends TestCase 'status' => 'checkout', 'origin' => Cart::ORIGIN_DIRECT_CHECKOUT, ]); - $this->assertDatabaseMissing('compra_items', [ + $this->assertDatabaseHas('compra_items', [ 'compra_id' => $response->json('data.id'), + 'source_variant_id' => $variant->id, + 'cantidad' => 3, + 'precio_unitario' => '50.00', + 'total' => '150.00', ]); $this->assertDatabaseHas('stock_reservations', [ 'inventory_id' => $variant->inventory_id, @@ -233,7 +244,7 @@ class StorePurchaseTest extends TestCase ], ]) ->assertCreated() - ->assertJsonPath('data.items_source', 'cart') + ->assertJsonPath('data.items_source', 'purchase') ->assertJsonCount(2, 'data.items') ->assertJsonPath('data.items.0.source_variant_id', $firstVariant->id) ->assertJsonPath('data.items.1.source_variant_id', $secondVariant->id) @@ -245,7 +256,7 @@ class StorePurchaseTest extends TestCase 'id' => $response->json('data.cart_id'), 'origin' => Cart::ORIGIN_DIRECT_CHECKOUT, ]); - $this->assertDatabaseMissing('compra_items', ['compra_id' => $purchaseId]); + $this->assertDatabaseCount('compra_items', 2); $this->assertDatabaseHas('stock_reservations', [ 'inventory_id' => $firstVariant->inventory_id, 'purchase_id' => $purchaseId, @@ -541,7 +552,7 @@ class StorePurchaseTest extends TestCase ], ]) ->assertCreated() - ->assertJsonPath('data.items_source', 'cart') + ->assertJsonPath('data.items_source', 'purchase') ->assertJsonCount(1, 'data.items') ->assertJsonPath('data.items.0.quantity', 2) ->assertJsonPath('data.dni', null) @@ -599,373 +610,40 @@ class StorePurchaseTest extends TestCase ]); } - public function test_it_updates_a_checkout_cart_item_quantity_and_its_stock_reservation(): void - { - $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); - $tenant->update(['checkout_editing_policy' => 'full']); - $user = User::factory()->create(); - $variant = $this->createVariantForTenant('sonder', 10, '50.00'); - $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); - $itemId = $purchase->cart->items->firstOrFail()->id; - - $this->actingAs($user, 'sanctum') - ->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [ - 'cantidad' => 4, - ]) - ->assertOk() - ->assertJsonPath('data.items.0.cantidad', 4) - ->assertJsonPath('data.subtotal', '200.00'); - - $this->assertDatabaseHas('inventories', [ - 'id' => $variant->inventory_id, - 'reserved_stock' => 4, - ]); - $this->assertDatabaseHas('carrito_items', [ - 'cart_id' => $purchase->cart_id, - 'catalog_item_id' => $variant->catalog_item_id, - 'variant_id' => $variant->id, - 'cantidad' => 4, - ]); - - $this->actingAs($user, 'sanctum') - ->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [ - 'cantidad' => 1, - ]) - ->assertOk() - ->assertJsonPath('data.items.0.cantidad', 1) - ->assertJsonPath('data.subtotal', '50.00'); - - $this->assertDatabaseHas('compras', [ - 'id' => $purchase->id, - 'total' => '50.00', - ]); - - $this->assertDatabaseHas('inventories', [ - 'id' => $variant->inventory_id, - 'reserved_stock' => 1, - ]); - } - - public function test_checkout_cart_editing_uses_checkout_policy_independently_from_cart_policy(): void - { - $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); - $tenant->update([ - 'cart_editing_policy' => 'full', - 'checkout_editing_policy' => 'disabled', - ]); - $user = User::factory()->create(); - $variant = $this->createVariantForTenant('sonder', 10, '50.00'); - $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); - $itemId = $purchase->cart->items->firstOrFail()->id; - - $this->actingAs($user, 'sanctum') - ->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [ - 'cantidad' => 4, - ]) - ->assertUnprocessable() - ->assertJsonValidationErrors('cantidad'); - - $this->assertDatabaseHas('carrito_items', [ - 'id' => $itemId, - 'cantidad' => 2, - ]); - } - - public function test_it_removes_an_item_from_the_checkout_cart_when_the_policy_allows_it(): void - { - $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); - $tenant->update(['checkout_editing_policy' => 'quantity_and_remove']); - $user = User::factory()->create(); - $variant = $this->createVariantForTenant('sonder', 10, '50.00'); - $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); - $itemId = $purchase->cart->items->firstOrFail()->id; - - $this->actingAs($user, 'sanctum') - ->deleteJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}") - ->assertOk() - ->assertJsonPath('data.items', []) - ->assertJsonPath('data.subtotal', '0.00'); - - $this->assertDatabaseMissing('carrito_items', ['id' => $itemId]); - $this->assertDatabaseHas('compras', [ - 'id' => $purchase->id, - 'total' => '0.00', - ]); - $this->assertDatabaseHas('inventories', [ - 'id' => $variant->inventory_id, - 'reserved_stock' => 0, - ]); - } - - public function test_it_changes_a_checkout_item_variant_and_moves_its_reservation(): void - { - $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); - $tenant->update(['checkout_editing_policy' => 'full']); - $user = User::factory()->create(); - $firstVariant = $this->createVariantForTenant('sonder', 10, '50.00'); - $secondInventory = Inventory::query()->create(['real_stock' => 10]); - $secondVariant = $firstVariant->catalogItem->variants()->create([ - 'inventory_id' => $secondInventory->id, - 'precio' => '70.00', - ]); - $purchase = $this->createCheckoutPurchase($user, 'sonder', $firstVariant, 2); - $itemId = $purchase->items->firstOrFail()->id; - - $this->actingAs($user, 'sanctum') - ->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [ - 'variant_id' => $secondVariant->id, - ]) - ->assertOk() - ->assertJsonPath('data.items.0.source_variant_id', $secondVariant->id) - ->assertJsonPath('data.items.0.quantity', 2) - ->assertJsonPath('data.items.0.unit_price', '70.00') - ->assertJsonPath('data.items.0.line_total', '140.00') - ->assertJsonPath('data.total', '140.00') - ->assertJsonPath('data.items.0.variants.0.id', $firstVariant->id) - ->assertJsonPath('data.items.0.variants.1.id', $secondVariant->id); - - $this->assertDatabaseHas('inventories', [ - 'id' => $firstVariant->inventory_id, - 'reserved_stock' => 0, - ]); - $this->assertDatabaseHas('inventories', [ - 'id' => $secondInventory->id, - 'reserved_stock' => 2, - ]); - $this->assertDatabaseHas('carrito_items', [ - 'cart_id' => $purchase->cart_id, - 'catalog_item_id' => $firstVariant->catalog_item_id, - 'variant_id' => $secondVariant->id, - 'cantidad' => 2, - ]); - $this->assertDatabaseMissing('carrito_items', [ - 'cart_id' => $purchase->cart_id, - 'variant_id' => $firstVariant->id, - ]); - } - - public function test_it_rejects_checkout_variant_changes_when_the_policy_does_not_allow_them(): void - { - $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); - $tenant->update(['checkout_editing_policy' => 'quantity_and_remove']); - $user = User::factory()->create(); - $firstVariant = $this->createVariantForTenant('sonder', 10, '50.00'); - $secondInventory = Inventory::query()->create(['real_stock' => 10]); - $secondVariant = $firstVariant->catalogItem->variants()->create([ - 'inventory_id' => $secondInventory->id, - ]); - $purchase = $this->createCheckoutPurchase($user, 'sonder', $firstVariant, 2); - $itemId = $purchase->items->firstOrFail()->id; - - $this->actingAs($user, 'sanctum') - ->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [ - 'variant_id' => $secondVariant->id, - ]) - ->assertUnprocessable() - ->assertJsonValidationErrors('variant_id'); - - $this->assertDatabaseHas('compra_items', [ - 'id' => $itemId, - 'source_variant_id' => $firstVariant->id, - 'cantidad' => 2, - ]); - $this->assertDatabaseHas('inventories', [ - 'id' => $firstVariant->inventory_id, - 'reserved_stock' => 2, - ]); - $this->assertDatabaseHas('inventories', [ - 'id' => $secondInventory->id, - 'reserved_stock' => 0, - ]); - } - - public function test_checkout_variant_change_rolls_back_when_the_target_has_insufficient_stock(): void - { - $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); - $tenant->update(['checkout_editing_policy' => 'full']); - $user = User::factory()->create(); - $firstVariant = $this->createVariantForTenant('sonder', 10, '50.00'); - $secondInventory = Inventory::query()->create(['real_stock' => 1]); - $secondVariant = $firstVariant->catalogItem->variants()->create([ - 'inventory_id' => $secondInventory->id, - 'precio' => '70.00', - ]); - $purchase = $this->createCheckoutPurchase($user, 'sonder', $firstVariant, 2); - $itemId = $purchase->items->firstOrFail()->id; - - $this->actingAs($user, 'sanctum') - ->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [ - 'variant_id' => $secondVariant->id, - ]) - ->assertUnprocessable() - ->assertJsonValidationErrors('variant_id'); - - $this->assertDatabaseHas('compra_items', [ - 'id' => $itemId, - 'source_variant_id' => $firstVariant->id, - 'cantidad' => 2, - 'precio_unitario' => '50.00', - ]); - $this->assertDatabaseHas('inventories', [ - 'id' => $firstVariant->inventory_id, - 'reserved_stock' => 2, - ]); - $this->assertDatabaseHas('inventories', [ - 'id' => $secondInventory->id, - 'reserved_stock' => 0, - ]); - $this->assertDatabaseHas('carrito_items', [ - 'cart_id' => $purchase->cart_id, - 'variant_id' => $firstVariant->id, - 'cantidad' => 2, - ]); - } - - public function test_changing_to_an_existing_checkout_variant_merges_purchase_and_cart_rows(): void - { - $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); - $tenant->update(['checkout_editing_policy' => 'full']); - $user = User::factory()->create(); - $firstVariant = $this->createVariantForTenant('sonder', 10, '50.00'); - $secondInventory = Inventory::query()->create(['real_stock' => 10]); - $secondVariant = $firstVariant->catalogItem->variants()->create([ - 'inventory_id' => $secondInventory->id, - 'precio' => '70.00', - ]); - $cart = Cart::query()->create([ - 'tenant_codigo' => 'sonder', - 'user_id' => $user->id, - 'status' => 'active', - ]); - $cart->addItem($firstVariant->catalog_item_id, $firstVariant->id, 2); - $cart->addItem($secondVariant->catalog_item_id, $secondVariant->id, 3); - $purchase = app(CheckoutService::class)->startCheckout($tenant, $user->id, [ - 'cart_id' => $cart->id, - ]); - $sourceItem = $purchase->items->firstWhere('source_variant_id', $firstVariant->id); - $this->assertNotNull($sourceItem); - - $this->actingAs($user, 'sanctum') - ->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$sourceItem->id}", [ - 'variant_id' => $secondVariant->id, - ]) - ->assertOk() - ->assertJsonCount(1, 'data.items') - ->assertJsonPath('data.items.0.source_variant_id', $secondVariant->id) - ->assertJsonPath('data.items.0.quantity', 5) - ->assertJsonPath('data.items.0.line_total', '350.00') - ->assertJsonPath('data.total', '350.00'); - - $this->assertDatabaseCount('compra_items', 1); - $this->assertDatabaseHas('inventories', [ - 'id' => $firstVariant->inventory_id, - 'reserved_stock' => 0, - ]); - $this->assertDatabaseHas('inventories', [ - 'id' => $secondInventory->id, - 'reserved_stock' => 5, - ]); - $this->assertDatabaseCount('carrito_items', 1); - $this->assertDatabaseHas('carrito_items', [ - 'cart_id' => $cart->id, - 'variant_id' => $secondVariant->id, - 'cantidad' => 5, - ]); - } - - public function test_it_rejects_a_quantity_update_above_the_user_purchase_limit(): void - { - $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); - $tenant->update(['checkout_editing_policy' => 'full']); - $user = User::factory()->create(); - $variant = $this->createVariantForTenant('sonder', 10, '50.00'); - $variant->catalogItem->update(['max_units_per_user' => 3]); - $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); - $itemId = $purchase->cart->items->firstOrFail()->id; - - $this->actingAs($user, 'sanctum') - ->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [ - 'cantidad' => 4, - ]) - ->assertUnprocessable() - ->assertJsonValidationErrors('cantidad'); - - $this->assertDatabaseHas('carrito_items', [ - 'id' => $itemId, - 'cantidad' => 2, - ]); - $this->assertDatabaseHas('inventories', [ - 'id' => $variant->inventory_id, - 'reserved_stock' => 2, - ]); - } - - public function test_it_invalidates_a_pending_payment_when_the_checkout_cart_changes(): void - { - $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); - $tenant->update(['checkout_editing_policy' => 'full']); - $user = User::factory()->create(); - $variant = $this->createVariantForTenant('sonder', 10, '50.00'); - $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); - $purchase->update([ - 'status' => Purchase::STATUS_PENDING_PAYMENT, - 'payment_method' => 'qr', - ]); - $purchase->telepagosQr()->create([ - 'qr_order_id' => 'stale-order', - 'qr_code' => 'stale-qr', - ]); - $itemId = $purchase->cart->items->firstOrFail()->id; - - $this->actingAs($user, 'sanctum') - ->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [ - 'cantidad' => 3, - ]) - ->assertOk() - ->assertJsonPath('data.items.0.cantidad', 3); - - $this->assertDatabaseHas('compras', [ - 'id' => $purchase->id, - 'status' => Purchase::STATUS_CREATED, - 'payment_method' => null, - 'total' => '150.00', - ]); - $this->assertDatabaseMissing('telepagos_qr', [ - 'compra_id' => $purchase->id, - 'qr_order_id' => 'stale-order', - ]); - } - - public function test_a_no_op_checkout_cart_update_keeps_the_pending_payment_intact(): void + public function test_checkout_items_are_immutable_and_editing_routes_are_unavailable(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create(); $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); - $purchase->update([ - 'status' => Purchase::STATUS_PENDING_PAYMENT, - 'payment_method' => 'qr', - ]); - $purchase->telepagosQr()->create([ - 'qr_order_id' => 'current-order', - 'qr_code' => 'current-qr', - ]); - $itemId = $purchase->cart->items->firstOrFail()->id; + $cartItemId = $purchase->cart->items->firstOrFail()->id; + $purchaseItemId = $purchase->items->firstOrFail()->id; $this->actingAs($user, 'sanctum') - ->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [ - 'cantidad' => 2, + ->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$cartItemId}", [ + 'cantidad' => 3, ]) - ->assertOk(); + ->assertNotFound(); - $this->assertDatabaseHas('compras', [ - 'id' => $purchase->id, - 'status' => Purchase::STATUS_PENDING_PAYMENT, - 'payment_method' => 'qr', + $this->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$purchaseItemId}", [ + 'quantity' => 3, + ])->assertNotFound(); + + $this->deleteJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$purchaseItemId}") + ->assertNotFound(); + + $this->postJson("/api/tenants/sonder/compras/{$purchase->id}/edit-items") + ->assertNotFound(); + + $this->assertDatabaseHas('compra_items', [ + 'id' => $purchaseItemId, + 'cantidad' => 2, + 'precio_unitario' => '50.00', + 'total' => '100.00', ]); - $this->assertDatabaseHas('telepagos_qr', [ - 'compra_id' => $purchase->id, - 'qr_order_id' => 'current-order', + $this->assertDatabaseHas('inventories', [ + 'id' => $variant->inventory_id, + 'reserved_stock' => 2, ]); } @@ -1129,7 +807,10 @@ class StorePurchaseTest extends TestCase 'id' => $purchase->id, 'status' => Purchase::STATUS_EXPIRED, ]); - $this->assertDatabaseMissing('compra_items', ['compra_id' => $purchase->id]); + $this->assertDatabaseHas('compra_items', [ + 'compra_id' => $purchase->id, + 'cantidad' => 3, + ]); $this->assertDatabaseHas('stock_reservations', [ 'purchase_id' => $purchase->id, 'quantity' => 0, @@ -1153,7 +834,7 @@ class StorePurchaseTest extends TestCase ->assertSuccessful(); } - public function test_it_continues_expiring_purchases_after_an_inconsistent_reservation(): void + public function test_it_expires_multiple_purchases_that_already_have_item_snapshots(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create(); @@ -1176,13 +857,13 @@ class StorePurchaseTest extends TestCase $this->travel(31)->minutes(); $this->artisan('reservations:expire') - ->expectsOutput('Expired purchases: 1') + ->expectsOutput('Expired purchases: 2') ->expectsOutput('Expired cart items: 0') ->assertSuccessful(); $this->assertDatabaseHas('compras', [ 'id' => $inconsistentPurchase->id, - 'status' => Purchase::STATUS_CREATED, + 'status' => Purchase::STATUS_EXPIRED, ]); $this->assertDatabaseHas('compras', [ 'id' => $validPurchase->id, @@ -1190,7 +871,7 @@ class StorePurchaseTest extends TestCase ]); } - public function test_purchase_detail_uses_cart_items_for_created_purchase(): void + public function test_purchase_detail_uses_purchase_item_snapshot_for_created_purchase(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create([ @@ -1203,7 +884,7 @@ class StorePurchaseTest extends TestCase ->getJson("/api/tenants/sonder/compras/{$purchase->id}") ->assertOk() ->assertJsonPath('data.status', Purchase::STATUS_CREATED) - ->assertJsonPath('data.items_source', 'cart') + ->assertJsonPath('data.items_source', 'purchase') ->assertJsonCount(1, 'data.items') ->assertJsonPath('data.items.0.quantity', 2) ->assertJsonPath('data.items.0.unit_price', '50.00') @@ -1242,7 +923,7 @@ class StorePurchaseTest extends TestCase ->assertJsonPath('data.items.0.item_details.imagen', null); } - public function test_purchase_detail_uses_cart_items_for_pending_payment_purchase(): void + public function test_purchase_detail_uses_purchase_item_snapshot_for_pending_payment_purchase(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create([ @@ -1261,7 +942,7 @@ class StorePurchaseTest extends TestCase ->getJson("/api/tenants/sonder/compras/{$purchase->id}") ->assertOk() ->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT) - ->assertJsonPath('data.items_source', 'cart') + ->assertJsonPath('data.items_source', 'purchase') ->assertJsonCount(1, 'data.items') ->assertJsonPath('data.items.0.quantity', 2) ->assertJsonPath('data.items.0.unit_price', '50.00') @@ -1272,7 +953,7 @@ class StorePurchaseTest extends TestCase ->assertJsonPath('data.total', '100.00'); } - public function test_created_and_pending_payment_purchase_details_use_the_current_cart_quantity(): void + public function test_created_and_pending_payment_purchase_details_ignore_later_cart_changes(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create([ @@ -1282,17 +963,6 @@ class StorePurchaseTest extends TestCase foreach ([Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT] as $status) { $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); - $purchase->items()->create([ - 'source_catalog_item_id' => $variant->catalog_item_id, - 'source_variant_id' => $variant->id, - 'item_nombre' => $variant->catalogItem->nombre, - 'descripcion' => $variant->catalogItem->descripcion, - 'slug' => $variant->catalogItem->slug, - 'variant_attributes' => [], - 'cantidad' => 1, - 'precio_unitario' => '50.00', - 'total' => '50.00', - ]); $purchase->cart->items()->update(['cantidad' => 3]); $purchase->update(['status' => $status]); @@ -1300,11 +970,11 @@ class StorePurchaseTest extends TestCase ->getJson("/api/tenants/sonder/compras/{$purchase->id}") ->assertOk() ->assertJsonPath('data.status', $status) - ->assertJsonPath('data.items_source', 'cart') - ->assertJsonPath('data.items.0.quantity', 3) - ->assertJsonPath('data.items.0.line_total', '150.00') - ->assertJsonPath('data.subtotal', '150.00') - ->assertJsonPath('data.total', '150.00'); + ->assertJsonPath('data.items_source', 'purchase') + ->assertJsonPath('data.items.0.quantity', 2) + ->assertJsonPath('data.items.0.line_total', '100.00') + ->assertJsonPath('data.subtotal', '100.00') + ->assertJsonPath('data.total', '100.00'); } } diff --git a/tests/Feature/Sale/AdminAppSaleControllerTest.php b/tests/Feature/Sale/AdminAppSaleControllerTest.php index 5d3214c..246c989 100644 --- a/tests/Feature/Sale/AdminAppSaleControllerTest.php +++ b/tests/Feature/Sale/AdminAppSaleControllerTest.php @@ -7,9 +7,12 @@ use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\CartItem; use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Catalog\Models\Inventory; +use App\Domains\Catalog\Models\Variant; use App\Domains\Purchase\Events\PurchasePaid; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\PurchaseItem; +use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Ticket\Models\Ticket; @@ -31,7 +34,7 @@ class AdminAppSaleControllerTest extends TestCase WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']); } - public function test_sales_list_uses_cart_quantity_for_created_and_pending_payment_sales(): void + public function test_sales_list_uses_purchase_item_snapshots_for_every_status(): void { $tenant = $this->createTenant('acme'); Sanctum::actingAs($this->createAdminAppUser($tenant)); @@ -62,9 +65,9 @@ class AdminAppSaleControllerTest extends TestCase 'compra_id' => $createdPurchase->id, 'source_catalog_item_id' => $catalogItem->id, 'item_nombre' => $catalogItem->nombre, - 'cantidad' => 1, + 'cantidad' => 3, 'precio_unitario' => '10000.00', - 'total' => '10000.00', + 'total' => '30000.00', ]); $pendingCart = Cart::query()->create([ @@ -82,6 +85,14 @@ class AdminAppSaleControllerTest extends TestCase 'status' => Purchase::STATUS_PENDING_PAYMENT, 'total' => '40000.00', ]); + PurchaseItem::query()->create([ + 'compra_id' => $pendingPurchase->id, + 'source_catalog_item_id' => $catalogItem->id, + 'item_nombre' => $catalogItem->nombre, + 'cantidad' => 4, + 'precio_unitario' => '10000.00', + 'total' => '40000.00', + ]); $paidPurchase = Purchase::query()->create([ 'tenant_codigo' => $tenant->codigo, @@ -149,7 +160,7 @@ class AdminAppSaleControllerTest extends TestCase ->assertJsonPath('data.total', '40000.00'); } - public function test_an_adminapp_user_can_read_cart_items_from_an_unconfirmed_sale(): void + public function test_an_adminapp_user_can_read_purchase_item_snapshots_from_an_unconfirmed_sale(): void { $tenant = $this->createTenant('acme'); Sanctum::actingAs($this->createAdminAppUser($tenant)); @@ -176,10 +187,18 @@ class AdminAppSaleControllerTest extends TestCase 'status' => Purchase::STATUS_PENDING_PAYMENT, 'total' => '25000.00', ]); + $purchaseItem = PurchaseItem::query()->create([ + 'compra_id' => $purchase->id, + 'source_catalog_item_id' => $catalogItem->id, + 'item_nombre' => $catalogItem->nombre, + 'cantidad' => 2, + 'precio_unitario' => '12500.00', + 'total' => '25000.00', + ]); $this->getJson("/api/v1/adminapp/tenant/sales/{$purchase->id}") ->assertOk() - ->assertJsonPath('data.items.0.id', $cartItem->id) + ->assertJsonPath('data.items.0.id', $purchaseItem->id) ->assertJsonPath('data.items.0.product', 'Entrada general') ->assertJsonPath('data.items.0.event_dates', []) ->assertJsonPath('data.items.0.quantity', 2) @@ -187,7 +206,7 @@ class AdminAppSaleControllerTest extends TestCase ->assertJsonPath('data.items.0.total', '25000.00'); } - public function test_sales_list_uses_cart_quantity_until_purchase_items_exist(): void + public function test_sales_list_quantity_always_comes_from_purchase_items(): void { $tenant = $this->createTenant('acme'); Sanctum::actingAs($this->createAdminAppUser($tenant)); @@ -214,13 +233,7 @@ class AdminAppSaleControllerTest extends TestCase 'status' => Purchase::STATUS_PENDING_PAYMENT, 'total' => '37500.00', ]); - - $this->getJson('/api/v1/adminapp/tenant/sales') - ->assertOk() - ->assertJsonPath('data.0.id', $purchase->id) - ->assertJsonPath('data.0.quantity', 3); - - PurchaseItem::query()->create([ + $purchaseItem = PurchaseItem::query()->create([ 'compra_id' => $purchase->id, 'source_catalog_item_id' => $catalogItem->id, 'nombre' => 'Entrada general', @@ -232,7 +245,17 @@ class AdminAppSaleControllerTest extends TestCase $this->getJson('/api/v1/adminapp/tenant/sales') ->assertOk() + ->assertJsonPath('data.0.id', $purchase->id) ->assertJsonPath('data.0.quantity', 2); + + $purchaseItem->update([ + 'cantidad' => 4, + 'total' => '50000.00', + ]); + + $this->getJson('/api/v1/adminapp/tenant/sales') + ->assertOk() + ->assertJsonPath('data.0.quantity', 4); } public function test_an_adminapp_user_cannot_read_a_sale_from_another_tenant(): void @@ -329,11 +352,25 @@ class AdminAppSaleControllerTest extends TestCase $admin = $this->createAdminAppUser($tenant); Sanctum::actingAs($admin); - $purchase = Purchase::query()->create([ - 'tenant_codigo' => $tenant->codigo, - 'status' => Purchase::STATUS_PENDING_PAYMENT, - 'total' => '10000.00', + $inventory = Inventory::query()->create(['real_stock' => 10]); + $catalogItem = CatalogItem::query()->create([ + 'tenant_code' => $tenant->codigo, + 'slug' => 'confirmable-item', + 'nombre' => 'Confirmable item', + 'precio' => '10000.00', ]); + $variant = Variant::query()->create([ + 'catalog_item_id' => $catalogItem->id, + 'inventory_id' => $inventory->id, + ]); + $purchase = app(CheckoutService::class)->startCheckout($tenant, $admin->id, [ + 'direct_items' => [[ + 'catalog_item_id' => $catalogItem->id, + 'variant_id' => $variant->id, + 'cantidad' => 1, + ]], + ]); + $purchase->update(['status' => Purchase::STATUS_PENDING_PAYMENT]); $this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/confirm") ->assertOk() @@ -385,6 +422,14 @@ class AdminAppSaleControllerTest extends TestCase 'status' => Purchase::STATUS_PENDING_PAYMENT, 'total' => '10000.00', ]); + PurchaseItem::query()->create([ + 'compra_id' => $purchase->id, + 'source_catalog_item_id' => $catalogItem->id, + 'item_nombre' => $catalogItem->nombre, + 'cantidad' => 2, + 'precio_unitario' => '10000.00', + 'total' => '20000.00', + ]); $this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/cancel") ->assertOk() diff --git a/tests/Feature/Tenant/BootstrapTenantControllerTest.php b/tests/Feature/Tenant/BootstrapTenantControllerTest.php index f4734e4..80325a9 100644 --- a/tests/Feature/Tenant/BootstrapTenantControllerTest.php +++ b/tests/Feature/Tenant/BootstrapTenantControllerTest.php @@ -76,7 +76,7 @@ class BootstrapTenantControllerTest extends TestCase 'display_seach_bar' => false, 'display_cart' => false, 'cart_editing_policy' => 'disabled', - 'checkout_editing_policy' => 'quantity_and_remove', + 'checkout_editing_policy' => 'disabled', 'display_cart_item_images' => false, ]); @@ -101,10 +101,10 @@ class BootstrapTenantControllerTest extends TestCase ->assertJsonPath('data.cart_editing_policy.allow_delete', false) ->assertJsonPath('data.cart_editing_policy.allow_update_quantity', false) ->assertJsonPath('data.cart_editing_policy.allow_update_variant', false) - ->assertJsonPath('data.checkout_editing_policy.code', 'quantity_and_remove') - ->assertJsonPath('data.checkout_editing_policy.allow_modify', true) - ->assertJsonPath('data.checkout_editing_policy.allow_delete', true) - ->assertJsonPath('data.checkout_editing_policy.allow_update_quantity', true) + ->assertJsonPath('data.checkout_editing_policy.code', 'disabled') + ->assertJsonPath('data.checkout_editing_policy.allow_modify', false) + ->assertJsonPath('data.checkout_editing_policy.allow_delete', false) + ->assertJsonPath('data.checkout_editing_policy.allow_update_quantity', false) ->assertJsonPath('data.checkout_editing_policy.allow_update_variant', false) ->assertJsonPath('data.display_cart_item_images', false) ->assertJsonPath('data.header_bg_color', '#ffffff')->assertJsonPath('data.footer_bg_color', '#ffffff');