diff --git a/ShopIt_API_Postman_Collection.json b/ShopIt_API_Postman_Collection.json index f388e16..50e4b7b 100644 --- a/ShopIt_API_Postman_Collection.json +++ b/ShopIt_API_Postman_Collection.json @@ -1169,6 +1169,99 @@ }, "response": [] }, + { + "name": "Prepare Item Editing Purchase", + "request": { + "method": "POST", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `POST /api/tenants/{tenant:codigo}/compras/{compra}/edit-items`\n\nControlador: `App\\Domains\\Purchase\\Controllers\\PurchaseController@prepareItemEditing`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/tenants/{{tenant_code}}/compras/{{purchase_id}}/edit-items", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "tenants", + "{{tenant_code}}", + "compras", + "{{purchase_id}}", + "edit-items" + ] + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "Update Item Purchase", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Accept", + "value": "application/json", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "description": "Ruta Laravel: `PATCH /api/tenants/{tenant:codigo}/compras/{compra}/items/{item}`\n\nControlador: `App\\Domains\\Purchase\\Controllers\\PurchaseController@updateItem`\n\nRequiere autenticación Sanctum.", + "url": { + "raw": "{{base_url}}/api/tenants/{{tenant_code}}/compras/{{purchase_id}}/items/{{purchase_item_id}}", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "tenants", + "{{tenant_code}}", + "compras", + "{{purchase_id}}", + "items", + "{{purchase_item_id}}" + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"quantity\": 2,\n \"variant_id\": {{variant_id}}\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, { "name": "Create Payment Intent Purchase", "request": { @@ -2121,7 +2214,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"client_id\": {{client_id}},\n \"codigo\": \"{{tenant_code}}\",\n \"nombre\": \"Tenant Demo\",\n \"dominio\": \"{{tenant_domain}}\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"secondary_color\": \"#2563EB\",\n \"danger_color\": \"#DC2626\",\n \"success_color\": \"#16A34A\",\n \"header_bg_color\": \"#FFFFFF\",\n \"footer_bg_color\": \"#111827\",\n \"header_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"footer_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"search_product_layout\": \"column_with_image\",\n \"search_group_layout\": \"paginated\",\n \"search_items_per_page\": 12,\n \"display_categories\": true,\n \"display_seach_bar\": true,\n \"display_cart\": true,\n \"cart_editing_enabled\": true,\n \"website_type_code\": \"shopit\"\n}", + "raw": "{\n \"client_id\": {{client_id}},\n \"codigo\": \"{{tenant_code}}\",\n \"nombre\": \"Tenant Demo\",\n \"dominio\": \"{{tenant_domain}}\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"secondary_color\": \"#2563EB\",\n \"danger_color\": \"#DC2626\",\n \"success_color\": \"#16A34A\",\n \"header_bg_color\": \"#FFFFFF\",\n \"footer_bg_color\": \"#111827\",\n \"header_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"footer_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"search_product_layout\": \"column_with_image\",\n \"search_group_layout\": \"paginated\",\n \"search_items_per_page\": 12,\n \"display_categories\": true,\n \"display_seach_bar\": true,\n \"display_cart\": true,\n \"cart_editing_policy\": \"full\",\n \"website_type_code\": \"shopit\"\n}", "options": { "raw": { "language": "json" @@ -2187,7 +2280,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"nombre\": \"Tenant Demo Actualizado\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"cart_editing_enabled\": true\n}", + "raw": "{\n \"nombre\": \"Tenant Demo Actualizado\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"cart_editing_policy\": \"full\"\n}", "options": { "raw": { "language": "json" @@ -2227,7 +2320,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"nombre\": \"Tenant Demo Actualizado\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"cart_editing_enabled\": true\n}", + "raw": "{\n \"nombre\": \"Tenant Demo Actualizado\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"cart_editing_policy\": \"full\"\n}", "options": { "raw": { "language": "json" diff --git a/app/Domains/Cart/Resources/CartItemResource.php b/app/Domains/Cart/Resources/CartItemResource.php index 46a6719..4d960d6 100644 --- a/app/Domains/Cart/Resources/CartItemResource.php +++ b/app/Domains/Cart/Resources/CartItemResource.php @@ -4,6 +4,7 @@ namespace App\Domains\Cart\Resources; use App\Domains\Cart\Models\CartItem; use App\Domains\Catalog\Enums\InventoryPolicy; +use App\Domains\Catalog\Models\Variant; use App\Domains\Tenant\Models\Tenant; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -22,6 +23,8 @@ class CartItemResource extends JsonResource $imageUrl = null; $tenant = $request->route('tenant'); $displayImage = ! $tenant instanceof Tenant || $tenant->display_cart_item_images; + $includeVariants = $tenant instanceof Tenant + && $tenant->cart_editing_policy->allowsVariantChanges(); if ($displayImage && $selectedItem?->relationLoaded('attachments')) { $imageUrl = $selectedItem->attachments->first()?->getTemporaryUrl(1440); @@ -39,14 +42,27 @@ class CartItemResource extends JsonResource 'variant_id' => $this->variant_id, 'nombre' => $selectedItem?->getName(), 'imagen' => $imageUrl, - 'variant' => $this->variant === null ? null : [ - 'id' => $this->variant->id, - 'precio' => $this->formatMoney($this->variant->getPrice()), - 'stock_tecnico' => $this->catalogItem->inventory_policy === InventoryPolicy::Unlimited - ? null - : $this->variant->inventory->availableStock(), - 'values' => $this->variant->selectorOptions($this->catalogItem->itemAttributes), - ], + 'variant' => $this->variant === null ? null : $this->variantData($this->variant), + 'variants' => $this->when( + $includeVariants, + fn () => $this->catalogItem + ->visibleVariants($this->variant_id) + ->map(fn (Variant $variant): array => $this->variantData($variant)) + ->values(), + ), + ]; + } + + /** @return array */ + protected function variantData(Variant $variant): array + { + return [ + 'id' => $variant->id, + 'precio' => $this->formatMoney($variant->precio ?? $this->catalogItem->precio), + 'stock_tecnico' => $this->catalogItem->inventory_policy === InventoryPolicy::Unlimited + ? null + : $variant->inventory->availableStock(), + 'values' => $variant->selectorOptions($this->catalogItem->itemAttributes), ]; } diff --git a/app/Domains/Cart/Services/CartService.php b/app/Domains/Cart/Services/CartService.php index dc76a9c..7ac1fbb 100644 --- a/app/Domains/Cart/Services/CartService.php +++ b/app/Domains/Cart/Services/CartService.php @@ -36,7 +36,7 @@ class CartService return $this->makeEmptyCart($tenant); } - return $this->loadCart($cart); + return $this->loadCart($cart, $tenant); } /** @@ -55,7 +55,7 @@ class CartService $cart->addItem($catalogItemId, $variantId, $quantity); return [ - 'cart' => $this->loadCart($cart), + 'cart' => $this->loadCart($cart, $tenant), 'guest_token' => $resolvedIdentity['generated_guest_token'], ]; } @@ -68,11 +68,23 @@ class CartService ?int $variantId, bool $updateVariant, ): Cart { + if ($updateVariant && ! $tenant->cart_editing_policy->allowsVariantChanges()) { + throw ValidationException::withMessages([ + 'variant_id' => __('api.cart.variant_change_disabled'), + ]); + } + + if (! $updateVariant && ! $tenant->cart_editing_policy->allowsQuantityChanges()) { + throw ValidationException::withMessages([ + 'cantidad' => __('api.cart.editing_disabled'), + ]); + } + $identity = $this->requireIdentity($request); $cart = $this->findCartOrFail($tenant, $identity); $cart->updateItem($cartItemId, $quantity, $variantId, $updateVariant); - return $this->loadCart($cart); + return $this->loadCart($cart, $tenant); } public function updateCheckoutItem( @@ -178,11 +190,17 @@ class CartService public function removeItem(Tenant $tenant, Request $request, int $cartItemId): Cart { + if (! $tenant->cart_editing_policy->allowsRemoval()) { + throw ValidationException::withMessages([ + 'cart_item' => __('api.cart.editing_disabled'), + ]); + } + $identity = $this->requireIdentity($request); $cart = $this->findCartOrFail($tenant, $identity); $cart->removeItem($cartItemId); - return $this->loadCart($cart); + return $this->loadCart($cart, $tenant); } public function makeGuestTokenCookie(string $guestToken): Cookie @@ -212,9 +230,9 @@ class CartService return $cart; } - protected function loadCart(Cart $cart): Cart + protected function loadCart(Cart $cart, Tenant $tenant): Cart { - return $cart->fresh()->load([ + $relations = [ 'items.catalogItem.attachments', 'items.catalogItem.inventory', 'items.catalogItem.itemAttributes.attribute', @@ -223,7 +241,21 @@ class CartService 'items.variant.definitions.itemAttribute.attribute.options', 'items.variant.eventDates', 'items.variant.eventDate', - ]); + ]; + + if ($tenant->cart_editing_policy->allowsVariantChanges()) { + $relations = [ + ...$relations, + 'items.catalogItem.variants' => fn ($query) => $query->orderBy('id'), + 'items.catalogItem.variants.inventory', + 'items.catalogItem.variants.definitions' => fn ($query) => $query->orderBy('id'), + 'items.catalogItem.variants.definitions.itemAttribute.attribute.options', + 'items.catalogItem.variants.eventDates', + 'items.catalogItem.variants.eventDate', + ]; + } + + return $cart->fresh()->load($relations); } /** diff --git a/app/Domains/Purchase/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php index fcda49d..17b05a5 100644 --- a/app/Domains/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -7,7 +7,9 @@ use App\Domains\Purchase\Models\Purchase; 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; use App\Domains\Tenant\Models\Tenant; use App\Http\Controllers\Controller; @@ -50,21 +52,16 @@ class PurchaseController extends Controller return PurchaseResource::make($purchase)->response()->setStatusCode(201); } - public function show(Request $request, Tenant $tenant, Purchase $compra): PurchaseResource - { + public function show( + Request $request, + Tenant $tenant, + Purchase $compra, + PurchaseResponseLoader $responses, + ): PurchaseResource { $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); - $compra->loadMissing([ - 'items', - 'cart.items.catalogItem.inventory', - 'cart.items.catalogItem.attachments', - 'cart.items.variant.inventory', - 'cart.items.variant.attachments', - 'cart.items.variant.definitions.itemAttribute.attribute', - 'cart.items.variant.eventDates', - 'cart.items.variant.eventDate', - ])->loadCount('tickets'); - $compra->items->load('imageAttachment'); + $compra->loadMissing('items')->loadCount('tickets'); + $responses->load($compra); return PurchaseResource::make($compra); } @@ -82,8 +79,28 @@ class PurchaseController extends Controller ); } - public function paymentIntent( - PaymentIntentRequest $request, + 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, diff --git a/app/Domains/Purchase/Models/PurchaseItem.php b/app/Domains/Purchase/Models/PurchaseItem.php index 2894caa..484ef07 100644 --- a/app/Domains/Purchase/Models/PurchaseItem.php +++ b/app/Domains/Purchase/Models/PurchaseItem.php @@ -3,6 +3,8 @@ namespace App\Domains\Purchase\Models; use App\Domains\Attachable\Models\Attachment; +use App\Domains\Catalog\Models\CatalogItem; +use App\Domains\Catalog\Models\Variant; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -59,4 +61,16 @@ class PurchaseItem extends Model { return $this->belongsTo(Attachment::class, 'image_attachment_id'); } + + /** @return BelongsTo */ + public function sourceCatalogItem(): BelongsTo + { + return $this->belongsTo(CatalogItem::class, 'source_catalog_item_id')->withTrashed(); + } + + /** @return BelongsTo */ + public function sourceVariant(): BelongsTo + { + return $this->belongsTo(Variant::class, 'source_variant_id')->withTrashed(); + } } diff --git a/app/Domains/Purchase/Requests/UpdatePurchaseItemRequest.php b/app/Domains/Purchase/Requests/UpdatePurchaseItemRequest.php new file mode 100644 index 0000000..ba05e14 --- /dev/null +++ b/app/Domains/Purchase/Requests/UpdatePurchaseItemRequest.php @@ -0,0 +1,22 @@ +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/Resources/PurchaseItemResource.php b/app/Domains/Purchase/Resources/PurchaseItemResource.php index bd0d700..024483d 100644 --- a/app/Domains/Purchase/Resources/PurchaseItemResource.php +++ b/app/Domains/Purchase/Resources/PurchaseItemResource.php @@ -3,6 +3,7 @@ namespace App\Domains\Purchase\Resources; use App\Domains\Cart\Models\CartItem; +use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; use App\Domains\Purchase\Models\PurchaseItem; @@ -25,6 +26,14 @@ class PurchaseItemResource extends JsonResource : null; $attributes = $this->variant_attributes ?? []; + $catalogItem = $this->relationLoaded('sourceCatalogItem') + ? $this->sourceCatalogItem + : null; + $includeVariants = $tenant instanceof Tenant + && $tenant->cart_editing_policy->allowsVariantChanges() + && $catalogItem !== null + && $catalogItem->relationLoaded('variants'); + return [ 'id' => $this->id, 'quantity' => (int) $this->cantidad, @@ -39,6 +48,13 @@ class PurchaseItemResource extends JsonResource 'imagen' => $imageUrl, 'attributes' => $attributes, ], + 'variants' => $this->when( + $includeVariants, + fn () => $catalogItem + ->visibleVariants($this->source_variant_id) + ->map(fn (Variant $variant): array => $this->variantData($catalogItem, $variant)) + ->values(), + ), ]; } @@ -132,4 +148,17 @@ class PurchaseItemResource extends JsonResource { return number_format((float) ($amount ?? 0), 2, '.', ''); } + + /** @return array */ + private function variantData(CatalogItem $catalogItem, Variant $variant): array + { + return [ + 'id' => $variant->id, + 'precio' => $this->formatMoney($variant->precio ?? $catalogItem->precio), + 'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited + ? null + : $variant->inventory->availableStock(), + 'values' => $variant->selectorOptions($catalogItem->itemAttributes), + ]; + } } diff --git a/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php b/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php index f6f6d0d..e6e290b 100644 --- a/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php @@ -8,6 +8,15 @@ use Illuminate\Validation\ValidationException; 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, + ) {} + /** @param array $customerData */ public function updateCustomer(Purchase $purchase, array $customerData): Purchase { @@ -21,6 +30,229 @@ class EditCheckoutService }); } + 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); + + if ($purchase->status !== Purchase::STATUS_CREATED || $this->hasExpired($purchase)) { + throw ValidationException::withMessages([ + 'purchase' => __('api.purchase.not_editable'), + ]); + } + + $purchaseItem = $this->lockPurchaseItem($purchase, $purchaseItem); + $tenant = $purchase->tenant()->firstOrFail(); + $finalQuantity = $quantity ?? (int) $purchaseItem->cantidad; + + if ($quantity !== null && ! $tenant->cart_editing_policy->allowsQuantityChanges()) { + throw ValidationException::withMessages([ + 'quantity' => __('api.cart.editing_disabled'), + ]); + } + + if ($updateVariant && ! $tenant->cart_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); + }); + } + + 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->assertEditable($purchase); + + if (! $purchase->tenant()->firstOrFail()->cart_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); + }); + } + + 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 ( @@ -49,15 +281,6 @@ class EditCheckoutService private function loadPurchase(Purchase $purchase): Purchase { - return $purchase->load([ - 'items.imageAttachment', - 'cart.items.catalogItem.inventory', - 'cart.items.catalogItem.attachments', - 'cart.items.variant.inventory', - 'cart.items.variant.attachments', - 'cart.items.variant.definitions.itemAttribute.attribute', - 'cart.items.variant.eventDates', - 'cart.items.variant.eventDate', - ]); + return $this->responses->load($purchase); } } diff --git a/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php b/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php index 582dfc1..87c1bee 100644 --- a/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php +++ b/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php @@ -9,6 +9,35 @@ 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/PurchaseResponseLoader.php b/app/Domains/Purchase/Services/Checkout/PurchaseResponseLoader.php new file mode 100644 index 0000000..f9dd775 --- /dev/null +++ b/app/Domains/Purchase/Services/Checkout/PurchaseResponseLoader.php @@ -0,0 +1,29 @@ +load(['tenant', 'items.imageAttachment']); + + if (! $purchase->tenant->cart_editing_policy->allowsVariantChanges()) { + return $purchase; + } + + $purchase->load([ + 'items.sourceCatalogItem.itemAttributes.attribute', + 'items.sourceCatalogItem.variants' => fn ($query) => $query->orderBy('id'), + 'items.sourceCatalogItem.variants.inventory', + 'items.sourceCatalogItem.variants.definitions' => fn ($query) => $query->orderBy('id'), + 'items.sourceCatalogItem.variants.definitions.itemAttribute.attribute.options', + 'items.sourceCatalogItem.variants.eventDates', + 'items.sourceCatalogItem.variants.eventDate', + ]); + + return $purchase; + } +} diff --git a/app/Domains/Purchase/Services/Checkout/SourceCartService.php b/app/Domains/Purchase/Services/Checkout/SourceCartService.php index f21078d..9eeea13 100644 --- a/app/Domains/Purchase/Services/Checkout/SourceCartService.php +++ b/app/Domains/Purchase/Services/Checkout/SourceCartService.php @@ -62,6 +62,74 @@ 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 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 finalize(Purchase $purchase): void { $sourceCart = $this->findSourceCart($purchase); diff --git a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php index f59b812..9e75ce1 100644 --- a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php @@ -25,6 +25,7 @@ class StartCheckoutService private readonly UserPurchaseLimitService $purchaseLimits, private readonly CatalogSelectionResolver $selections, private readonly InsufficientStockMessageBuilder $stockMessages, + private readonly PurchaseResponseLoader $responses, ) {} /** @param array $purchaseData */ @@ -376,15 +377,6 @@ class StartCheckoutService private function loadPurchase(Purchase $purchase): Purchase { - return $purchase->load([ - 'items.imageAttachment', - 'cart.items.catalogItem.inventory', - 'cart.items.catalogItem.attachments', - 'cart.items.variant.inventory', - 'cart.items.variant.attachments', - 'cart.items.variant.definitions.itemAttribute.attribute', - 'cart.items.variant.eventDates', - 'cart.items.variant.eventDate', - ]); + return $this->responses->load($purchase); } } diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index 411f1b2..8c9f4e9 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -48,6 +48,27 @@ 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 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 f8e8d5d..2fb537e 100644 --- a/app/Domains/Purchase/routes/api.php +++ b/app/Domains/Purchase/routes/api.php @@ -7,6 +7,8 @@ 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::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/Enums/CartEditingPolicy.php b/app/Domains/Tenant/Enums/CartEditingPolicy.php new file mode 100644 index 0000000..c90fc05 --- /dev/null +++ b/app/Domains/Tenant/Enums/CartEditingPolicy.php @@ -0,0 +1,32 @@ +allowsRemoval() + || $this->allowsQuantityChanges() + || $this->allowsVariantChanges(); + } +} diff --git a/app/Domains/Tenant/Models/Tenant.php b/app/Domains/Tenant/Models/Tenant.php index 7016029..876ec6f 100644 --- a/app/Domains/Tenant/Models/Tenant.php +++ b/app/Domains/Tenant/Models/Tenant.php @@ -11,6 +11,7 @@ use App\Domains\Client\Models\Client; use App\Domains\Event\Models\EventDate; use App\Domains\Menu\Models\Menu; use App\Domains\Menu\Models\TenantMenu; +use App\Domains\Tenant\Enums\CartEditingPolicy; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -44,7 +45,7 @@ use Illuminate\Support\Facades\Schema; 'display_categories', 'display_seach_bar', 'display_cart', - 'cart_editing_enabled', + 'cart_editing_policy', 'display_cart_item_images', 'scanner_category_validation_enabled', 'event_title', @@ -63,7 +64,7 @@ class Tenant extends Model 'display_categories' => true, 'display_seach_bar' => true, 'display_cart' => true, - 'cart_editing_enabled' => true, + 'cart_editing_policy' => CartEditingPolicy::Full->value, 'display_cart_item_images' => true, 'scanner_category_validation_enabled' => true, ]; @@ -114,7 +115,7 @@ class Tenant extends Model 'display_categories' => 'boolean', 'display_seach_bar' => 'boolean', 'display_cart' => 'boolean', - 'cart_editing_enabled' => 'boolean', + 'cart_editing_policy' => CartEditingPolicy::class, 'display_cart_item_images' => 'boolean', 'scanner_category_validation_enabled' => 'boolean', ]; diff --git a/app/Domains/Tenant/Requests/StoreTenantRequest.php b/app/Domains/Tenant/Requests/StoreTenantRequest.php index 4743178..ad25958 100644 --- a/app/Domains/Tenant/Requests/StoreTenantRequest.php +++ b/app/Domains/Tenant/Requests/StoreTenantRequest.php @@ -5,6 +5,7 @@ namespace App\Domains\Tenant\Requests; use App\Domains\Catalog\Enums\GroupLayout; use App\Domains\Catalog\Enums\ProductLayout; use App\Domains\Shared\Rules\ImageOrBase64Rule; +use App\Domains\Tenant\Enums\CartEditingPolicy; use App\Domains\Tenant\Services\WebsiteExtraService; use App\Domains\Tenant\Support\TenantDomainNormalizer; use Closure; @@ -107,7 +108,7 @@ class StoreTenantRequest extends FormRequest 'display_categories' => ['sometimes', 'boolean'], 'display_seach_bar' => ['sometimes', 'boolean'], 'display_cart' => ['sometimes', 'boolean'], - 'cart_editing_enabled' => ['sometimes', 'boolean'], + 'cart_editing_policy' => ['sometimes', Rule::enum(CartEditingPolicy::class)], '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 6328743..cd45b58 100644 --- a/app/Domains/Tenant/Requests/UpdateTenantRequest.php +++ b/app/Domains/Tenant/Requests/UpdateTenantRequest.php @@ -5,6 +5,7 @@ namespace App\Domains\Tenant\Requests; use App\Domains\Catalog\Enums\GroupLayout; use App\Domains\Catalog\Enums\ProductLayout; use App\Domains\Shared\Rules\ImageOrBase64Rule; +use App\Domains\Tenant\Enums\CartEditingPolicy; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Support\TenantDomainNormalizer; use Closure; @@ -128,7 +129,7 @@ class UpdateTenantRequest extends FormRequest 'display_categories' => ['sometimes', 'boolean'], 'display_seach_bar' => ['sometimes', 'boolean'], 'display_cart' => ['sometimes', 'boolean'], - 'cart_editing_enabled' => ['sometimes', 'boolean'], + 'cart_editing_policy' => ['sometimes', Rule::enum(CartEditingPolicy::class)], 'display_cart_item_images' => ['sometimes', 'boolean'], 'scanner_category_validation_enabled' => ['sometimes', 'boolean'], ]; diff --git a/app/Domains/Tenant/Resources/CartEditingPolicyResource.php b/app/Domains/Tenant/Resources/CartEditingPolicyResource.php new file mode 100644 index 0000000..aba3b20 --- /dev/null +++ b/app/Domains/Tenant/Resources/CartEditingPolicyResource.php @@ -0,0 +1,23 @@ + */ + public function toArray(Request $request): array + { + return [ + 'code' => $this->resource->value, + 'allow_modify' => $this->resource->allowsModification(), + 'allow_delete' => $this->resource->allowsRemoval(), + 'allow_update_quantity' => $this->resource->allowsQuantityChanges(), + 'allow_update_variant' => $this->resource->allowsVariantChanges(), + ]; + } +} diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php index 5eab5cc..fd1b898 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -75,7 +75,7 @@ class TenantResource extends JsonResource 'display_categories' => $this->display_categories, 'display_seach_bar' => $this->display_seach_bar, 'display_cart' => $this->display_cart, - 'cart_editing_enabled' => $this->cart_editing_enabled, + 'cart_editing_policy' => CartEditingPolicyResource::make($this->cart_editing_policy), 'display_cart_item_images' => $this->display_cart_item_images, 'scanner_category_validation_enabled' => $this->scanner_category_validation_enabled, 'social_media' => $this->whenLoaded( diff --git a/database/migrations/2026_08_19_000000_replace_cart_editing_enabled_with_policy.php b/database/migrations/2026_08_19_000000_replace_cart_editing_enabled_with_policy.php new file mode 100644 index 0000000..163c5aa --- /dev/null +++ b/database/migrations/2026_08_19_000000_replace_cart_editing_enabled_with_policy.php @@ -0,0 +1,56 @@ + */ + private const TENANT_POLICIES = [ + 'sonder' => 'quantity_and_remove', + 'fiesta_futbol_infantil' => 'full', + 'desfile_pura_tendencia' => 'disabled', + ]; + + public function up(): void + { + Schema::table('tenants', function (Blueprint $table): void { + $table->string('cart_editing_policy') + ->default('full') + ->after('display_cart'); + }); + + DB::table('tenants') + ->where('cart_editing_enabled', false) + ->update(['cart_editing_policy' => 'disabled']); + + foreach (self::TENANT_POLICIES as $tenantCode => $policy) { + DB::table('tenants') + ->where('codigo', $tenantCode) + ->update(['cart_editing_policy' => $policy]); + } + + Schema::table('tenants', function (Blueprint $table): void { + $table->dropColumn('cart_editing_enabled'); + }); + } + + public function down(): void + { + Schema::table('tenants', function (Blueprint $table): void { + $table->boolean('cart_editing_enabled') + ->default(true) + ->after('display_cart'); + }); + + DB::table('tenants') + ->where('cart_editing_policy', 'disabled') + ->update(['cart_editing_enabled' => false]); + + Schema::table('tenants', function (Blueprint $table): void { + $table->dropColumn('cart_editing_policy'); + }); + } +}; diff --git a/database/seeders/DesfilePuraTendenciaSeeder.php b/database/seeders/DesfilePuraTendenciaSeeder.php index bfc1447..31b58ae 100644 --- a/database/seeders/DesfilePuraTendenciaSeeder.php +++ b/database/seeders/DesfilePuraTendenciaSeeder.php @@ -74,7 +74,7 @@ class DesfilePuraTendenciaSeeder extends Seeder 'display_categories' => false, 'display_seach_bar' => false, 'display_cart' => true, - 'cart_editing_enabled' => false, + 'cart_editing_policy' => 'disabled', 'display_cart_item_images' => false, 'scanner_category_validation_enabled' => false, 'website_type_code' => 'onticket', diff --git a/database/seeders/TenantSeeder.php b/database/seeders/TenantSeeder.php index 110a709..4225989 100644 --- a/database/seeders/TenantSeeder.php +++ b/database/seeders/TenantSeeder.php @@ -75,6 +75,7 @@ class TenantSeeder extends Seeder 'display_categories' => true, 'display_seach_bar' => true, 'display_cart' => true, + 'cart_editing_policy' => 'quantity_and_remove', 'header_logo' => $this->uploadedImage('images/tennants/sonder/sonder_header.png', 'sonder_header.png'), 'footer_logo' => $this->uploadedImage('images/tennants/sonder/sonder_footer.png', 'sonder_footer.png'), 'social_media' => self::SOCIAL_MEDIA, @@ -131,6 +132,7 @@ class TenantSeeder extends Seeder 'display_categories' => false, 'display_seach_bar' => false, 'display_cart' => true, + 'cart_editing_policy' => 'full', 'header_logo' => $this->uploadedImage( 'images/tennants/fiesta_futbol_infantil/futbol_infantil_header.png', 'futbol_infantil_header.png', diff --git a/lang/en/api.php b/lang/en/api.php index f047b0e..2061d37 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -22,6 +22,8 @@ return [ 'google_email_unverified' => 'The Google account must have a verified email.', ], 'cart' => [ + 'editing_disabled' => 'Cart editing is disabled.', + 'variant_change_disabled' => 'Variant changes are disabled for this cart.', 'item_added' => 'Product added to cart.', 'quantity_updated' => 'Product quantity updated.', 'item_updated' => 'Product updated.', @@ -34,6 +36,7 @@ return [ 'variant_required' => 'You must select a variant for this item.', ], 'purchase' => [ + 'variant_change_disabled' => 'Variant changes are disabled for this purchase.', '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.', diff --git a/lang/es/api.php b/lang/es/api.php index 52f6313..6aa1a73 100644 --- a/lang/es/api.php +++ b/lang/es/api.php @@ -22,6 +22,8 @@ return [ 'google_email_unverified' => 'La cuenta de Google debe tener el email verificado.', ], 'cart' => [ + 'editing_disabled' => 'La edición del carrito está deshabilitada.', + 'variant_change_disabled' => 'El cambio de variante está deshabilitado para este carrito.', 'item_added' => 'Producto agregado al carrito.', 'quantity_updated' => 'Cantidad de producto actualizada.', 'item_updated' => 'Producto actualizado.', @@ -34,6 +36,7 @@ return [ 'variant_required' => 'Debe seleccionar una variante para este ítem.', ], 'purchase' => [ + 'variant_change_disabled' => 'El cambio de variante está deshabilitado para esta compra.', '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.', diff --git a/postman/generate-shopit-collection.php b/postman/generate-shopit-collection.php index 043cc47..a63f36e 100644 --- a/postman/generate-shopit-collection.php +++ b/postman/generate-shopit-collection.php @@ -84,7 +84,7 @@ function bodyFor(string $method, string $uri): ?array 'POST api/tenants/{tenant:codigo}/catalog-items/{catalogItem}/variant-options' => ['selected_values' => ['color' => 'azul'], 'cart_item_id' => '{{cart_item_id}}'], 'POST api/tenants/{tenant:codigo}/compras/start-checkout' => ['cart_id' => '{{cart_id}}'], 'PATCH api/tenants/{tenant:codigo}/compras/{compra}/customer-data' => ['dni' => '30123456', 'telefono' => '+5491112345678', 'nombre_apellido' => 'Usuario Demo', 'email' => '{{user_email}}'], - 'PATCH api/tenants/{tenant:codigo}/checkout-carts/{cart}/items/{cartItem}' => ['cantidad' => 2], + 'PATCH api/tenants/{tenant:codigo}/compras/{compra}/items/{item}' => ['quantity' => 2, 'variant_id' => '{{variant_id}}'], 'POST api/tenants/{tenant:codigo}/compras/{compra}/payment-intent' => ['method' => 'transfer', 'transfer_payer_dni' => '30123456'], 'POST api/tenants/{tenant:codigo}/tickets/pdf' => ['ticket_ids' => [1]], 'POST api/v1/adminapp/login' => ['email' => '{{admin_email}}', 'password' => '{{admin_password}}'], @@ -136,13 +136,13 @@ function bodyFor(string $method, string $uri): ?array 'display_categories' => true, 'display_seach_bar' => true, 'display_cart' => true, - 'cart_editing_enabled' => true, + 'cart_editing_policy' => 'full', 'website_type_code' => 'shopit', ]); } if (in_array($key, ['PUT api/tenants/{tenant}', 'PATCH api/tenants/{tenant}'], true)) { - return jsonBody(['nombre' => 'Tenant Demo Actualizado', 'site_title' => 'ShopIt Demo', 'primary_color' => '#111827', 'cart_editing_enabled' => true]); + return jsonBody(['nombre' => 'Tenant Demo Actualizado', 'site_title' => 'ShopIt Demo', 'primary_color' => '#111827', 'cart_editing_policy' => 'full']); } if ($key === 'POST api/tenants/{tenant:codigo}/catalog-items') { @@ -244,7 +244,7 @@ function requestName(string $method, string $action, bool $multiMethod): string $verbs = [ 'index' => 'List', 'store' => 'Create', 'show' => 'Get', 'update' => 'Update', 'destroy' => 'Delete', 'addItem' => 'Add Item', 'updateItemQuantity' => 'Update Item Quantity', - 'updateCheckoutItem' => 'Update Checkout Cart Item', + 'updateItem' => 'Update Item', 'removeItem' => 'Remove Item', 'search' => 'Search', 'category' => 'Get Category', 'featuredGroupItems' => 'List Featured Group Items', 'variantOptions' => 'Get Variant Options', 'startCheckout' => 'Start Checkout', 'updateCustomerData' => 'Update Customer Data', diff --git a/tests/Feature/Cart/CartControllerTest.php b/tests/Feature/Cart/CartControllerTest.php index d69c1b7..9dedcfd 100644 --- a/tests/Feature/Cart/CartControllerTest.php +++ b/tests/Feature/Cart/CartControllerTest.php @@ -202,6 +202,7 @@ class CartControllerTest extends TestCase ->assertOk() ->assertJsonPath('data.items.0.catalog_item_id', $item->id) ->assertJsonPath('data.items.0.variant_id', $variant->id) + ->assertJsonPath('data.items.0.variants.0.id', $variant->id) ->assertJsonPath('data.items.0.cantidad', 5) ->assertJsonPath('data.subtotal', '125.00'); @@ -476,6 +477,83 @@ class CartControllerTest extends TestCase ]); } + public function test_quantity_and_remove_policy_hides_variants_and_rejects_variant_changes(): void + { + $tenant = $this->createTenant('acme'); + $tenant->update(['cart_editing_policy' => 'quantity_and_remove']); + [$item, $firstVariant] = $this->createVariantItem($tenant, 10, '15.00'); + $secondInventory = Inventory::query()->create(['real_stock' => 10]); + $secondVariant = $item->variants()->create(['inventory_id' => $secondInventory->id]); + $createResponse = $this->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'variant_id' => $firstVariant->id, + 'cantidad' => 1, + ])->assertJsonMissingPath('data.items.0.variants'); + $guestToken = $createResponse->getCookie('guest_token', false)?->getValue(); + $cartItemId = $createResponse->json('data.items.0.id'); + + $this->call( + 'PATCH', + "/api/tenants/acme/cart/items/{$cartItemId}", + [], + ['guest_token' => $guestToken], + [], + ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'], + json_encode(['cantidad' => 2]), + )->assertOk()->assertJsonPath('data.items.0.cantidad', 2); + + $this->call( + 'PATCH', + "/api/tenants/acme/cart/items/{$cartItemId}", + [], + ['guest_token' => $guestToken], + [], + ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'], + json_encode(['cantidad' => 2, 'variant_id' => $secondVariant->id]), + )->assertUnprocessable()->assertJsonValidationErrors('variant_id'); + + $this->call( + 'DELETE', + "/api/tenants/acme/cart/items/{$cartItemId}", + [], + ['guest_token' => $guestToken], + [], + ['HTTP_Accept' => 'application/json'], + )->assertOk()->assertJsonPath('data.items', []); + } + + public function test_disabled_policy_rejects_quantity_changes_and_removal(): void + { + $tenant = $this->createTenant('acme'); + $tenant->update(['cart_editing_policy' => 'disabled']); + $item = $this->createDirectItem($tenant, 10, '15.00'); + $createResponse = $this->postJson('/api/tenants/acme/cart/items', [ + 'catalog_item_id' => $item->id, + 'cantidad' => 1, + ])->assertOk(); + $guestToken = $createResponse->getCookie('guest_token', false)?->getValue(); + $cartItemId = $createResponse->json('data.items.0.id'); + + $this->call( + 'PATCH', + "/api/tenants/acme/cart/items/{$cartItemId}", + [], + ['guest_token' => $guestToken], + [], + ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'], + json_encode(['cantidad' => 2]), + )->assertUnprocessable()->assertJsonValidationErrors('cantidad'); + + $this->call( + 'DELETE', + "/api/tenants/acme/cart/items/{$cartItemId}", + [], + ['guest_token' => $guestToken], + [], + ['HTTP_Accept' => 'application/json'], + )->assertUnprocessable()->assertJsonValidationErrors('cart_item'); + } + public function test_it_requires_a_variant_when_the_item_has_variant_inventory(): void { $tenant = $this->createTenant('acme'); diff --git a/tests/Feature/Migrations/AddCartEditingEnabledToTenantsTest.php b/tests/Feature/Migrations/AddCartEditingEnabledToTenantsTest.php index eb10e23..896959c 100644 --- a/tests/Feature/Migrations/AddCartEditingEnabledToTenantsTest.php +++ b/tests/Feature/Migrations/AddCartEditingEnabledToTenantsTest.php @@ -10,10 +10,10 @@ class AddCartEditingEnabledToTenantsTest extends TestCase { use RefreshDatabase; - public function test_it_disables_cart_editing_only_for_desfile(): void + public function test_it_migrates_the_boolean_to_the_equivalent_policy(): void { $migration = require database_path( - 'migrations/2026_08_18_060000_add_cart_editing_enabled_to_tenants_table.php' + 'migrations/2026_08_19_000000_replace_cart_editing_enabled_with_policy.php' ); $migration->down(); @@ -21,7 +21,17 @@ class AddCartEditingEnabledToTenantsTest extends TestCase $this->assertDatabaseHas('tenants', [ 'codigo' => 'desfile_pura_tendencia', - 'cart_editing_enabled' => false, + 'cart_editing_policy' => 'disabled', + ]); + + $this->assertDatabaseHas('tenants', [ + 'codigo' => 'sonder', + 'cart_editing_policy' => 'quantity_and_remove', + ]); + + $this->assertDatabaseHas('tenants', [ + 'codigo' => 'fiesta_futbol_infantil', + 'cart_editing_policy' => 'full', ]); DB::table('tenants')->insert([ @@ -32,7 +42,7 @@ class AddCartEditingEnabledToTenantsTest extends TestCase $this->assertDatabaseHas('tenants', [ 'codigo' => 'cart-editing-default', - 'cart_editing_enabled' => true, + 'cart_editing_policy' => 'full', ]); } } diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index af4c3ad..dc6f92d 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -605,6 +605,182 @@ class StorePurchaseTest extends TestCase ]); } + public function test_it_changes_a_checkout_item_variant_and_moves_its_reservation(): void + { + $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $tenant->update(['cart_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(['cart_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(['cart_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(['cart_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 { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); diff --git a/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php b/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php index 6d3d231..76a4e09 100644 --- a/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php +++ b/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php @@ -44,7 +44,7 @@ class DesfilePuraTendenciaSeederTest extends TestCase 'footer_bg_color' => '#D4441C', 'display_categories' => false, 'display_seach_bar' => false, - 'cart_editing_enabled' => false, + 'cart_editing_policy' => 'disabled', 'display_cart_item_images' => false, 'site_title' => 'Desfile Pura Tendencia', ]); diff --git a/tests/Feature/Seeders/TenantSeederTest.php b/tests/Feature/Seeders/TenantSeederTest.php index f0495aa..54ed1a3 100644 --- a/tests/Feature/Seeders/TenantSeederTest.php +++ b/tests/Feature/Seeders/TenantSeederTest.php @@ -61,6 +61,7 @@ class TenantSeederTest extends TestCase ->sole(); $this->assertSame('shopit', $sonder->website_type_code); + $this->assertSame('quantity_and_remove', $sonder->cart_editing_policy->value); $carousel = $sonder->websiteExtras ->firstWhere('websiteTypeExtra.codigo', 'carousel'); @@ -81,6 +82,7 @@ class TenantSeederTest extends TestCase ->sole(); $this->assertSame('onticket', $fiesta->website_type_code); + $this->assertSame('full', $fiesta->cart_editing_policy->value); $extras = $fiesta->websiteExtras->keyBy('websiteTypeExtra.codigo'); $this->assertEqualsCanonicalizing( diff --git a/tests/Feature/Tenant/BootstrapTenantControllerTest.php b/tests/Feature/Tenant/BootstrapTenantControllerTest.php index 9526d32..9ceafd0 100644 --- a/tests/Feature/Tenant/BootstrapTenantControllerTest.php +++ b/tests/Feature/Tenant/BootstrapTenantControllerTest.php @@ -73,7 +73,7 @@ class BootstrapTenantControllerTest extends TestCase 'display_categories' => false, 'display_seach_bar' => false, 'display_cart' => false, - 'cart_editing_enabled' => false, + 'cart_editing_policy' => 'disabled', 'display_cart_item_images' => false, ]); @@ -91,7 +91,11 @@ class BootstrapTenantControllerTest extends TestCase ->assertJsonPath('data.display_categories', false) ->assertJsonPath('data.display_seach_bar', false) ->assertJsonPath('data.display_cart', false) - ->assertJsonPath('data.cart_editing_enabled', false) + ->assertJsonPath('data.cart_editing_policy.code', 'disabled') + ->assertJsonPath('data.cart_editing_policy.allow_modify', false) + ->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.display_cart_item_images', false) ->assertJsonPath('data.header_bg_color', '#ffffff')->assertJsonPath('data.footer_bg_color', '#ffffff'); @@ -758,7 +762,7 @@ class BootstrapTenantControllerTest extends TestCase $response = $this->putJson("/api/tenants/{$tenant->codigo}", [ 'primary_color' => '#000000', - 'cart_editing_enabled' => false, + 'cart_editing_policy' => 'quantity_and_remove', 'display_cart_item_images' => false, ]); @@ -767,7 +771,11 @@ class BootstrapTenantControllerTest extends TestCase ->assertJsonPath('data.codigo', 'acme') ->assertJsonPath('data.nombre', 'Acme') ->assertJsonPath('data.primary_color', '#000000') - ->assertJsonPath('data.cart_editing_enabled', false) + ->assertJsonPath('data.cart_editing_policy.code', 'quantity_and_remove') + ->assertJsonPath('data.cart_editing_policy.allow_modify', true) + ->assertJsonPath('data.cart_editing_policy.allow_delete', true) + ->assertJsonPath('data.cart_editing_policy.allow_update_quantity', true) + ->assertJsonPath('data.cart_editing_policy.allow_update_variant', false) ->assertJsonPath('data.display_cart_item_images', false); $this->assertDatabaseHas('tenants', [ @@ -775,7 +783,7 @@ class BootstrapTenantControllerTest extends TestCase 'codigo' => 'acme', 'nombre' => 'Acme', 'primary_color' => '#000000', - 'cart_editing_enabled' => false, + 'cart_editing_policy' => 'quantity_and_remove', 'display_cart_item_images' => false, ]); }