From 014b5bb012ede53aa15fea7a01c4608883335c18 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 19 Aug 2026 10:15:37 -0300 Subject: [PATCH 1/2] feat(cart): implement cart editing policies and update related functionality --- ShopIt_API_Postman_Collection.json | 6 +- .../Cart/Resources/CartItemResource.php | 32 ++++++-- app/Domains/Cart/Services/CartService.php | 47 +++++++++-- .../Tenant/Enums/CartEditingPolicy.php | 32 ++++++++ app/Domains/Tenant/Models/Tenant.php | 7 +- .../Tenant/Requests/StoreTenantRequest.php | 3 +- .../Tenant/Requests/UpdateTenantRequest.php | 3 +- .../Resources/CartEditingPolicyResource.php | 23 ++++++ .../Tenant/Resources/TenantResource.php | 2 +- ...place_cart_editing_enabled_with_policy.php | 56 +++++++++++++ .../seeders/DesfilePuraTendenciaSeeder.php | 2 +- database/seeders/TenantSeeder.php | 2 + lang/en/api.php | 2 + lang/es/api.php | 2 + postman/generate-shopit-collection.php | 7 +- tests/Feature/Cart/CartControllerTest.php | 78 +++++++++++++++++++ .../AddCartEditingEnabledToTenantsTest.php | 18 ++++- .../DesfilePuraTendenciaSeederTest.php | 2 +- tests/Feature/Seeders/TenantSeederTest.php | 2 + .../Tenant/BootstrapTenantControllerTest.php | 18 +++-- 20 files changed, 305 insertions(+), 39 deletions(-) create mode 100644 app/Domains/Tenant/Enums/CartEditingPolicy.php create mode 100644 app/Domains/Tenant/Resources/CartEditingPolicyResource.php create mode 100644 database/migrations/2026_08_19_000000_replace_cart_editing_enabled_with_policy.php diff --git a/ShopIt_API_Postman_Collection.json b/ShopIt_API_Postman_Collection.json index 7bb4ba5..50301a5 100644 --- a/ShopIt_API_Postman_Collection.json +++ b/ShopIt_API_Postman_Collection.json @@ -2160,7 +2160,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" @@ -2226,7 +2226,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" @@ -2266,7 +2266,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 954f784..8bb6268 100644 --- a/app/Domains/Cart/Services/CartService.php +++ b/app/Domains/Cart/Services/CartService.php @@ -8,6 +8,7 @@ use App\Domains\Tenant\Models\Tenant; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -27,7 +28,7 @@ class CartService return $this->makeEmptyCart($tenant); } - return $this->loadCart($cart); + return $this->loadCart($cart, $tenant); } /** @@ -46,7 +47,7 @@ class CartService $cart->addItem($catalogItemId, $variantId, $quantity); return [ - 'cart' => $this->loadCart($cart), + 'cart' => $this->loadCart($cart, $tenant), 'guest_token' => $resolvedIdentity['generated_guest_token'], ]; } @@ -59,20 +60,38 @@ 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 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 @@ -102,9 +121,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', @@ -113,7 +132,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/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 cd1dac1..7999ffb 100644 --- a/database/seeders/DesfilePuraTendenciaSeeder.php +++ b/database/seeders/DesfilePuraTendenciaSeeder.php @@ -72,7 +72,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 e632e90..89a12c0 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.', diff --git a/lang/es/api.php b/lang/es/api.php index c968c0d..53b2911 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.', diff --git a/postman/generate-shopit-collection.php b/postman/generate-shopit-collection.php index bee6476..1fc99f9 100644 --- a/postman/generate-shopit-collection.php +++ b/postman/generate-shopit-collection.php @@ -8,7 +8,6 @@ declare(strict_types=1); * Run from the backend root with: * php postman/generate-shopit-collection.php */ - $root = dirname(__DIR__); chdir($root); @@ -38,7 +37,7 @@ function jsonBody(array $payload): array } /** @param array $fields - * @return array + * @return array */ function formDataBody(array $fields, array $fileFields = []): array { @@ -137,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') { diff --git a/tests/Feature/Cart/CartControllerTest.php b/tests/Feature/Cart/CartControllerTest.php index 54398d2..22286dc 100644 --- a/tests/Feature/Cart/CartControllerTest.php +++ b/tests/Feature/Cart/CartControllerTest.php @@ -121,6 +121,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'); @@ -383,6 +384,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/Seeders/DesfilePuraTendenciaSeederTest.php b/tests/Feature/Seeders/DesfilePuraTendenciaSeederTest.php index e92b85c..cd1a80b 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, ]); } From 7cea8d349554d2371027b9aec3c22c14a024e949 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 19 Aug 2026 10:46:19 -0300 Subject: [PATCH 2/2] feat(cart): enhance item update functionality to support variant changes and improve cart editing policies --- ShopIt_API_Postman_Collection.json | 6 +- .../Controllers/PurchaseController.php | 23 ++- app/Domains/Purchase/Models/PurchaseItem.php | 14 ++ .../UpdatePurchaseItemQuantityRequest.php | 23 --- .../Requests/UpdatePurchaseItemRequest.php | 22 +++ .../Resources/PurchaseItemResource.php | 29 +++ .../Services/Checkout/EditCheckoutService.php | 140 ++++++++++++-- .../Checkout/PurchaseItemSnapshotFactory.php | 29 +++ .../Checkout/PurchaseResponseLoader.php | 29 +++ .../Services/Checkout/SourceCartService.php | 51 +++++ .../Checkout/StartCheckoutService.php | 3 +- .../Purchase/Services/CheckoutService.php | 14 +- app/Domains/Purchase/routes/api.php | 2 +- lang/en/api.php | 1 + lang/es/api.php | 1 + postman/generate-shopit-collection.php | 3 +- tests/Feature/Purchase/StorePurchaseTest.php | 176 ++++++++++++++++++ 17 files changed, 514 insertions(+), 52 deletions(-) delete mode 100644 app/Domains/Purchase/Requests/UpdatePurchaseItemQuantityRequest.php create mode 100644 app/Domains/Purchase/Requests/UpdatePurchaseItemRequest.php create mode 100644 app/Domains/Purchase/Services/Checkout/PurchaseResponseLoader.php 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');