diff --git a/ShopIt_API_Postman_Collection.json b/ShopIt_API_Postman_Collection.json index 50301a5..8be9135 100644 --- a/ShopIt_API_Postman_Collection.json +++ b/ShopIt_API_Postman_Collection.json @@ -1155,7 +1155,7 @@ "response": [] }, { - "name": "Update Item Quantity Purchase", + "name": "Update Item Purchase", "request": { "method": "PATCH", "header": [ @@ -1170,7 +1170,7 @@ "type": "text" } ], - "description": "Ruta Laravel: `PATCH /api/tenants/{tenant:codigo}/compras/{compra}/items/{item}`\n\nControlador: `App\\Domains\\Purchase\\Controllers\\PurchaseController@updateItemQuantity`\n\nRequiere autenticación Sanctum.", + "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": [ @@ -1188,7 +1188,7 @@ }, "body": { "mode": "raw", - "raw": "{\n \"quantity\": 2\n}", + "raw": "{\n \"quantity\": 2,\n \"variant_id\": {{variant_id}}\n}", "options": { "raw": { "language": "json" diff --git a/app/Domains/Purchase/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php index 5439563..32e6830 100644 --- a/app/Domains/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -8,8 +8,9 @@ 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\UpdatePurchaseItemQuantityRequest; +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; @@ -52,12 +53,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')->loadCount('tickets'); - $compra->items->load('imageAttachment'); + $responses->load($compra); return PurchaseResource::make($compra); } @@ -75,8 +80,8 @@ class PurchaseController extends Controller ); } - public function updateItemQuantity( - UpdatePurchaseItemQuantityRequest $request, + public function updateItem( + UpdatePurchaseItemRequest $request, Tenant $tenant, Purchase $compra, PurchaseItem $item, @@ -85,10 +90,12 @@ class PurchaseController extends Controller $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); return PurchaseResource::make( - $checkoutService->updateItemQuantity( + $checkoutService->updateItem( $compra, $item, - (int) $request->validated('quantity'), + $request->exists('quantity') ? (int) $request->validated('quantity') : null, + $request->exists('variant_id') ? (int) $request->validated('variant_id') : null, + $request->exists('variant_id'), ), ); } diff --git a/app/Domains/Purchase/Models/PurchaseItem.php b/app/Domains/Purchase/Models/PurchaseItem.php index 7f82f2c..9a970ed 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; @@ -66,4 +68,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/UpdatePurchaseItemQuantityRequest.php b/app/Domains/Purchase/Requests/UpdatePurchaseItemQuantityRequest.php deleted file mode 100644 index 9810352..0000000 --- a/app/Domains/Purchase/Requests/UpdatePurchaseItemQuantityRequest.php +++ /dev/null @@ -1,23 +0,0 @@ -user() !== null; - } - - /** - * @return array - */ - public function rules(): array - { - return [ - 'quantity' => ['required', 'integer', 'min:1', 'max:100'], - ]; - } -} 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 5fb2b90..c91e62b 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(), + ), ]; } @@ -142,4 +158,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 9491b38..6178673 100644 --- a/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php @@ -18,6 +18,8 @@ class EditCheckoutService private readonly UserPurchaseLimitService $purchaseLimits, private readonly CatalogSelectionResolver $selections, private readonly SourceCartService $sourceCart, + private readonly PurchaseItemSnapshotFactory $snapshots, + private readonly PurchaseResponseLoader $responses, ) {} /** @param array $customerData */ @@ -33,12 +35,20 @@ class EditCheckoutService }); } - public function updateItemQuantity( + public function updateItem( Purchase $purchase, PurchaseItem $purchaseItem, - int $quantity, + ?int $quantity, + ?int $variantId, + bool $updateVariant, ): Purchase { - return DB::transaction(function () use ($purchase, $purchaseItem, $quantity): 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)) { @@ -48,16 +58,40 @@ class EditCheckoutService } $purchaseItem = $this->lockPurchaseItem($purchase, $purchaseItem); - $difference = $quantity - (int) $purchaseItem->cantidad; + $tenant = $purchase->tenant()->firstOrFail(); + $finalQuantity = $quantity ?? (int) $purchaseItem->cantidad; - if ($difference !== 0) { - $this->adjustReservation($purchase, $purchaseItem, $quantity, $difference); - - $purchaseItem->update([ - 'cantidad' => $quantity, - 'total' => (float) $purchaseItem->precio_unitario * $quantity, + if ($quantity !== null && ! $tenant->cart_editing_policy->allowsQuantityChanges()) { + throw ValidationException::withMessages([ + 'quantity' => __('api.cart.editing_disabled'), ]); - $this->sourceCart->syncItemQuantity($purchase, $purchaseItem, $quantity); + } + + 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([ @@ -68,12 +102,94 @@ class EditCheckoutService }); } + 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, @@ -170,6 +286,6 @@ class EditCheckoutService private function loadPurchase(Purchase $purchase): Purchase { - return $purchase->load(['items.imageAttachment']); + return $this->responses->load($purchase); } } diff --git a/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php b/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php index e1711a2..c791820 100644 --- a/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php +++ b/app/Domains/Purchase/Services/Checkout/PurchaseItemSnapshotFactory.php @@ -10,6 +10,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 45624cd..3184aad 100644 --- a/app/Domains/Purchase/Services/Checkout/SourceCartService.php +++ b/app/Domains/Purchase/Services/Checkout/SourceCartService.php @@ -71,6 +71,57 @@ class SourceCartService ->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 60a7ccb..6f0bff9 100644 --- a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php @@ -24,6 +24,7 @@ class StartCheckoutService private readonly CatalogSelectionResolver $selections, private readonly PurchaseItemSnapshotFactory $snapshots, private readonly InsufficientStockMessageBuilder $stockMessages, + private readonly PurchaseResponseLoader $responses, ) {} /** @param array $purchaseData */ @@ -382,6 +383,6 @@ class StartCheckoutService private function loadPurchase(Purchase $purchase): Purchase { - return $purchase->load(['items.imageAttachment']); + return $this->responses->load($purchase); } } diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index b575acd..c87ca99 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -47,12 +47,20 @@ class CheckoutService return $this->editor->updateCustomer($purchase, $customerData); } - public function updateItemQuantity( + public function updateItem( Purchase $purchase, PurchaseItem $purchaseItem, - int $quantity, + ?int $quantity, + ?int $variantId, + bool $updateVariant, ): Purchase { - return $this->editor->updateItemQuantity($purchase, $purchaseItem, $quantity); + return $this->editor->updateItem( + $purchase, + $purchaseItem, + $quantity, + $variantId, + $updateVariant, + ); } public function prepareItemEditing(Purchase $purchase): Purchase diff --git a/app/Domains/Purchase/routes/api.php b/app/Domains/Purchase/routes/api.php index 41cc919..2fb537e 100644 --- a/app/Domains/Purchase/routes/api.php +++ b/app/Domains/Purchase/routes/api.php @@ -8,7 +8,7 @@ Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(func 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, 'updateItemQuantity']); + 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/lang/en/api.php b/lang/en/api.php index 89a12c0..4a9ebf0 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -36,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 53b2911..0008179 100644 --- a/lang/es/api.php +++ b/lang/es/api.php @@ -36,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 1fc99f9..2e7e31b 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}/compras/{compra}/items/{item}' => ['quantity' => 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}}'], @@ -244,6 +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', + '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/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 601567a..ecb83d5 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -580,6 +580,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');