diff --git a/app/Domains/Cart/Models/Cart.php b/app/Domains/Cart/Models/Cart.php index 6d15a49..b726e22 100644 --- a/app/Domains/Cart/Models/Cart.php +++ b/app/Domains/Cart/Models/Cart.php @@ -82,9 +82,9 @@ class Cart extends Model return DB::transaction(function () use ($productVariantId, $quantity): CartItem { $variant = $this->resolveScopedVariant($productVariantId, true); - if ($variant->stock_tecnico < $quantity) { + if ($variant->tracksInventory() && $variant->availableQuantity() < $quantity) { throw ValidationException::withMessages([ - 'cantidad' => "Stock insuficiente para la variante solicitada. Maximo disponible: {$variant->stock_tecnico}.", + 'cantidad' => "Stock insuficiente para la variante solicitada. Maximo disponible: {$variant->availableQuantity()}.", ]); } @@ -104,7 +104,7 @@ class Cart extends Model $item->save(); } - $variant->incrementReservedStock($quantity); + $variant->reserveStock($quantity); return $item->fresh(); }); @@ -128,8 +128,8 @@ class Cart extends Model $variant = $this->resolveScopedVariant($productVariantId, true); $delta = $quantity - $item->cantidad; - if ($delta > 0 && $variant->stock_tecnico < $delta) { - $maxAvailable = $variant->stock_tecnico + $item->cantidad; + if ($delta > 0 && $variant->tracksInventory() && $variant->availableQuantity() < $delta) { + $maxAvailable = $variant->availableQuantity() + $item->cantidad; throw ValidationException::withMessages([ 'cantidad' => "El máximo que se puede agregar es {$maxAvailable}.", ]); @@ -139,7 +139,7 @@ class Cart extends Model $item->save(); if ($delta > 0) { - $variant->incrementReservedStock($delta); + $variant->reserveStock($delta); } if ($delta < 0) { diff --git a/app/Domains/Catalog/Enums/InventoryPolicy.php b/app/Domains/Catalog/Enums/InventoryPolicy.php new file mode 100644 index 0000000..970edb8 --- /dev/null +++ b/app/Domains/Catalog/Enums/InventoryPolicy.php @@ -0,0 +1,9 @@ + 'tracked', + 'stock_real' => 0, + 'stock_reservado' => 0, + 'cantidad_vendida' => 0, + ]; + protected static function booted(): void { static::saving(function (ProductVariant $variant) { + if ($variant->exists && $variant->isDirty('inventory_policy')) { + throw new \InvalidArgumentException('La politica de inventario no puede modificarse.'); + } + $variant->validateStock(); }); } @@ -47,34 +59,44 @@ class ProductVariant extends Model throw new \InvalidArgumentException('El stock reservado no puede ser negativo.'); } - if ($this->stock_reservado > $this->stock_real) { + if ($this->cantidad_vendida < 0) { + throw new \InvalidArgumentException('La cantidad vendida no puede ser negativa.'); + } + + if ($this->tracksInventory() && $this->stock_reservado > $this->stock_real) { throw new \InvalidArgumentException('El stock reservado no puede ser mayor que el stock real.'); } } - public function incrementRealStock(int $amount): void + public function tracksInventory(): bool + { + return $this->inventory_policy === InventoryPolicy::Tracked; + } + + public function availableQuantity(): ?int + { + if (! $this->tracksInventory()) { + return null; + } + + return $this->stock_real - $this->stock_reservado; + } + + public function isAvailableForSale(): bool + { + return ! $this->tracksInventory() || $this->availableQuantity() > 0; + } + + public function reserveStock(int $amount): void { if ($amount < 0) { throw new \InvalidArgumentException('El monto a incrementar debe ser positivo.'); } - $this->stock_real += $amount; - $this->save(); - } - public function decrementRealStock(int $amount): void - { - if ($amount < 0) { - throw new \InvalidArgumentException('El monto a decrementar debe ser positivo.'); + if ($this->tracksInventory() && $this->availableQuantity() < $amount) { + throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.'); } - $this->stock_real -= $amount; - $this->save(); - } - public function incrementReservedStock(int $amount): void - { - if ($amount < 0) { - throw new \InvalidArgumentException('El monto a incrementar debe ser positivo.'); - } $this->stock_reservado += $amount; $this->save(); } @@ -88,13 +110,13 @@ class ProductVariant extends Model $this->save(); } - public function confirmReservedStock(int $amount): void + public function buy(int $amount): void { if ($amount < 0) { throw new \InvalidArgumentException('El monto a confirmar debe ser positivo.'); } - if ($this->stock_real < $amount) { + if ($this->tracksInventory() && $this->stock_real < $amount) { throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la reserva.'); } @@ -102,14 +124,18 @@ class ProductVariant extends Model throw new \InvalidArgumentException('No hay suficiente stock reservado para confirmar la reserva.'); } - $this->stock_real -= $amount; + if ($this->tracksInventory()) { + $this->stock_real -= $amount; + } + $this->stock_reservado -= $amount; + $this->cantidad_vendida += $amount; $this->save(); } protected function stockTecnico(): Attribute { - return Attribute::get(fn () => $this->stock_real - $this->stock_reservado); + return Attribute::get(fn (): ?int => $this->availableQuantity()); } protected function stock(): Attribute @@ -126,8 +152,10 @@ class ProductVariant extends Model { return [ 'producto_id' => 'integer', + 'inventory_policy' => InventoryPolicy::class, 'stock_real' => 'integer', 'stock_reservado' => 'integer', + 'cantidad_vendida' => 'integer', 'is_placeholder' => 'boolean', 'has_tickets' => 'boolean', 'minimum_use_date' => 'datetime', diff --git a/app/Domains/Catalog/Requests/StoreProductRequest.php b/app/Domains/Catalog/Requests/StoreProductRequest.php index e043328..ec48acd 100644 --- a/app/Domains/Catalog/Requests/StoreProductRequest.php +++ b/app/Domains/Catalog/Requests/StoreProductRequest.php @@ -2,6 +2,7 @@ namespace App\Domains\Catalog\Requests; +use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Shared\Rules\ImageOrBase64Rule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -32,6 +33,8 @@ class StoreProductRequest extends FormRequest 'descripcion' => ['nullable', 'string'], 'precio' => ['required', 'numeric', 'min:0'], 'stock' => ['sometimes', 'integer', 'min:0'], + 'inventory_policy' => ['sometimes', Rule::enum(InventoryPolicy::class)], + 'cantidad_vendida' => ['prohibited'], 'attribute_ids' => ['sometimes', 'array'], 'attribute_ids.*' => [ 'required', @@ -41,7 +44,7 @@ class StoreProductRequest extends FormRequest ), ], 'images' => ['sometimes', 'nullable', 'array'], - 'images.*' => ['required', new ImageOrBase64Rule()], + 'images.*' => ['required', new ImageOrBase64Rule], ]; } } diff --git a/app/Domains/Catalog/Requests/StoreProductVariantRequest.php b/app/Domains/Catalog/Requests/StoreProductVariantRequest.php index 3561ec1..5d280b9 100644 --- a/app/Domains/Catalog/Requests/StoreProductVariantRequest.php +++ b/app/Domains/Catalog/Requests/StoreProductVariantRequest.php @@ -2,6 +2,7 @@ namespace App\Domains\Catalog\Requests; +use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Shared\Rules\ImageOrBase64Rule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -20,6 +21,8 @@ class StoreProductVariantRequest extends FormRequest { return [ 'stock' => ['sometimes', 'integer', 'min:0'], + 'inventory_policy' => ['sometimes', Rule::enum(InventoryPolicy::class)], + 'cantidad_vendida' => ['prohibited'], 'definitions' => ['sometimes', 'array'], 'definitions.*.products_attribute_id' => [ 'required', @@ -31,7 +34,7 @@ class StoreProductVariantRequest extends FormRequest ], 'definitions.*.value' => ['nullable', 'string'], 'images' => ['sometimes', 'nullable', 'array'], - 'images.*' => ['required', new ImageOrBase64Rule()], + 'images.*' => ['required', new ImageOrBase64Rule], 'has_tickets' => ['boolean'], 'minimum_use_date' => ['nullable', 'date'], 'maximum_use_date' => ['nullable', 'date', 'after_or_equal:minimum_use_date'], diff --git a/app/Domains/Catalog/Requests/UpdateProductVariantRequest.php b/app/Domains/Catalog/Requests/UpdateProductVariantRequest.php index d8dc26b..e31ea1c 100644 --- a/app/Domains/Catalog/Requests/UpdateProductVariantRequest.php +++ b/app/Domains/Catalog/Requests/UpdateProductVariantRequest.php @@ -20,6 +20,8 @@ class UpdateProductVariantRequest extends FormRequest { return [ 'stock' => ['sometimes', 'integer', 'min:0'], + 'inventory_policy' => ['prohibited'], + 'cantidad_vendida' => ['prohibited'], 'definitions' => ['sometimes', 'array'], 'definitions.*.products_attribute_id' => [ 'required', @@ -31,7 +33,7 @@ class UpdateProductVariantRequest extends FormRequest ], 'definitions.*.value' => ['nullable', 'string'], 'images' => ['sometimes', 'nullable', 'array'], - 'images.*' => ['required', new ImageOrBase64Rule()], + 'images.*' => ['required', new ImageOrBase64Rule], 'has_tickets' => ['boolean'], 'minimum_use_date' => ['nullable', 'date'], 'maximum_use_date' => ['nullable', 'date', 'after_or_equal:minimum_use_date'], diff --git a/app/Domains/Catalog/Resources/ProductResource.php b/app/Domains/Catalog/Resources/ProductResource.php index 1aafa61..c48a615 100644 --- a/app/Domains/Catalog/Resources/ProductResource.php +++ b/app/Domains/Catalog/Resources/ProductResource.php @@ -35,7 +35,9 @@ class ProductResource extends JsonResource 'variants_map' => $this->whenLoaded('variants', fn () => $this->variants ->map(fn ($variant) => [ 'variant_id' => $variant->id, + 'inventory_policy' => $variant->inventory_policy->value, 'cantidad_maxima' => $variant->stock_tecnico, + 'cantidad_vendida' => $variant->cantidad_vendida, 'attributes' => $variant->definitions ->mapWithKeys(fn ($definition) => [ $definition->productAttribute?->attribute?->codigo => $definition->value, diff --git a/app/Domains/Catalog/Resources/ProductVariantResource.php b/app/Domains/Catalog/Resources/ProductVariantResource.php index 08baa85..9c5619f 100644 --- a/app/Domains/Catalog/Resources/ProductVariantResource.php +++ b/app/Domains/Catalog/Resources/ProductVariantResource.php @@ -18,7 +18,9 @@ class ProductVariantResource extends JsonResource { return [ 'id' => $this->id, + 'inventory_policy' => $this->inventory_policy->value, 'cantidad_maxima' => $this->stock_tecnico, + 'cantidad_vendida' => $this->cantidad_vendida, 'has_tickets' => $this->has_tickets, 'minimum_use_date' => $this->minimum_use_date, 'maximum_use_date' => $this->maximum_use_date, diff --git a/app/Domains/Catalog/Services/ProductService.php b/app/Domains/Catalog/Services/ProductService.php index 9599805..4f3461d 100644 --- a/app/Domains/Catalog/Services/ProductService.php +++ b/app/Domains/Catalog/Services/ProductService.php @@ -3,6 +3,7 @@ namespace App\Domains\Catalog\Services; use App\Domains\Attachable\Services\AttachmentService; +use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\Product; use App\Domains\Catalog\Models\ProductVariant; @@ -28,7 +29,8 @@ class ProductService $attributeIds = $data['attribute_ids'] ?? []; $images = $data['images'] ?? []; $stock = $data['stock'] ?? 0; - unset($data['attribute_ids'], $data['images'], $data['stock']); + $inventoryPolicy = $data['inventory_policy'] ?? InventoryPolicy::Tracked->value; + unset($data['attribute_ids'], $data['images'], $data['stock'], $data['inventory_policy']); /** @var Product $product */ $product = Product::query()->create([ @@ -45,6 +47,7 @@ class ProductService // Create default variant with stock $this->createVariant($product, [ 'stock' => $stock, + 'inventory_policy' => $inventoryPolicy, 'is_placeholder' => true, 'definitions' => [], ]); @@ -179,6 +182,7 @@ class ProductService if ($product->variants()->count() === 0) { $product->createVariant([ 'stock' => 0, + 'inventory_policy' => InventoryPolicy::Tracked->value, 'is_placeholder' => true, 'definitions' => [], ]); @@ -327,13 +331,13 @@ class ProductService $selectedVariant = $variantId !== null ? $product->variants->firstWhere('id', $variantId) - : $product->variants->first(fn (ProductVariant $variant) => $variant->stock_tecnico > 0); + : $product->variants->first(fn (ProductVariant $variant) => $variant->isAvailableForSale()); if ($variantId !== null && $selectedVariant === null) { throw new NotFoundHttpException('Product variant not found for product.'); } - if ($variantId !== null && $selectedVariant->stock_tecnico <= 0) { + if ($variantId !== null && ! $selectedVariant->isAvailableForSale()) { throw ValidationException::withMessages([ 'variant_id' => 'La variante seleccionada no tiene stock.', ]); diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index 3aa509f..3dca897 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -97,6 +97,15 @@ class CheckoutService public function confirmPurchase(Purchase $purchase): void { DB::transaction(function () use ($purchase): void { + /** @var Purchase $purchase */ + $purchase = Purchase::query() + ->lockForUpdate() + ->findOrFail($purchase->getKey()); + + if ($purchase->items()->exists()) { + return; + } + /** @var Cart|null $cart */ $cart = $purchase->cart()->lockForUpdate()->first(); @@ -114,10 +123,6 @@ class CheckoutService ]); } - if ($purchase->items()->exists()) { - return; - } - $cartItems->load('variant.product'); $variants = $this->resolveTenantVariants($purchase->tenant, $cartItems); $purchaseItemsPayload = $this->buildPurchaseItemsPayload($cartItems, $variants); @@ -158,7 +163,7 @@ class CheckoutService /** * @param Collection $cartItems - * @return \Illuminate\Support\Collection + * @return Collection */ protected function resolveCheckoutCart(Tenant $tenant, int $userId, int $cartId): Cart { @@ -218,7 +223,7 @@ class CheckoutService $quantity = (int) $item->cantidad; try { - $variant->confirmReservedStock($quantity); + $variant->buy($quantity); } catch (\InvalidArgumentException $exception) { throw ValidationException::withMessages([ 'cart_id' => 'The selected cart has inconsistent stock state.', diff --git a/database/migrations/2026_07_15_000000_add_inventory_policy_and_cantidad_vendida_to_productos_variantes_table.php b/database/migrations/2026_07_15_000000_add_inventory_policy_and_cantidad_vendida_to_productos_variantes_table.php new file mode 100644 index 0000000..cad7b33 --- /dev/null +++ b/database/migrations/2026_07_15_000000_add_inventory_policy_and_cantidad_vendida_to_productos_variantes_table.php @@ -0,0 +1,29 @@ +string('inventory_policy')->default('tracked')->after('producto_id'); + $table->unsignedBigInteger('cantidad_vendida')->default(0)->after('stock_reservado'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('productos_variantes', function (Blueprint $table) { + $table->dropColumn(['inventory_policy', 'cantidad_vendida']); + }); + } +}; diff --git a/tests/Feature/Cart/CartControllerTest.php b/tests/Feature/Cart/CartControllerTest.php index fc10d69..6f68ca7 100644 --- a/tests/Feature/Cart/CartControllerTest.php +++ b/tests/Feature/Cart/CartControllerTest.php @@ -2,14 +2,19 @@ namespace Tests\Feature\Cart; +use App\Domains\Attachable\Enums\AttachmentType; +use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; -use App\Domains\Catalog\Models\Product; +use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Models\Attribute; -use App\Domains\Catalog\Models\ProductVariant; +use App\Domains\Catalog\Models\Category; +use App\Domains\Catalog\Models\Product; use App\Domains\Catalog\Models\ProductAttribute; +use App\Domains\Catalog\Models\ProductVariant; use App\Domains\Catalog\Models\ProductVariantDefinition; use App\Domains\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Str; use Tests\TestCase; class CartControllerTest extends TestCase @@ -29,7 +34,7 @@ class CartControllerTest extends TestCase 'status' => 'active', 'items' => [], 'subtotal' => '0.00', - ] + ], ]); } @@ -315,18 +320,73 @@ class CartControllerTest extends TestCase ->assertJsonValidationErrors(['cantidad' => 'El máximo que se puede agregar es 2.']); } + public function test_unlimited_inventory_can_be_reserved_updated_and_released_without_real_stock(): void + { + $variant = $this->createVariantForTenant( + 'acme', + 0, + '10.00', + 'unlimited', + InventoryPolicy::Unlimited, + ); + + $response = $this->postJson('/api/tenants/acme/cart/items', [ + 'product_variant_id' => $variant->id, + 'cantidad' => 100, + ])->assertOk(); + + $guestToken = $response->getCookie('guest_token', false)?->getValue(); + $this->assertDatabaseHas('productos_variantes', [ + 'id' => $variant->id, + 'stock_real' => 0, + 'stock_reservado' => 100, + ]); + + $this->call( + 'PATCH', + "/api/tenants/acme/cart/items/{$variant->id}", + [], + ['guest_token' => $guestToken], + [], + ['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'], + json_encode(['cantidad' => 150]), + )->assertOk(); + + $this->assertDatabaseHas('productos_variantes', [ + 'id' => $variant->id, + 'stock_real' => 0, + 'stock_reservado' => 150, + ]); + + $this->call( + 'DELETE', + "/api/tenants/acme/cart/items/{$variant->id}", + [], + ['guest_token' => $guestToken], + [], + ['HTTP_Accept' => 'application/json'], + )->assertOk(); + + $this->assertDatabaseHas('productos_variantes', [ + 'id' => $variant->id, + 'stock_real' => 0, + 'stock_reservado' => 0, + ]); + } + protected function createVariantForTenant( string $tenantCode, int $stock, string $price, string $slugPrefix = 'shirt', + InventoryPolicy $inventoryPolicy = InventoryPolicy::Tracked, ): ProductVariant { $tenant = Tenant::query()->where('codigo', $tenantCode)->first(); if (! $tenant) { $this->createTenant($tenantCode, ucfirst($tenantCode), "{$tenantCode}.com"); } - $category = \App\Domains\Catalog\Models\Category::query()->create([ + $category = Category::query()->create([ 'tenant_code' => $tenantCode, 'nombre' => "{$slugPrefix} category {$tenantCode}", ]); @@ -342,6 +402,7 @@ class CartControllerTest extends TestCase return ProductVariant::query()->create([ 'producto_id' => $product->id, + 'inventory_policy' => $inventoryPolicy->value, 'slug' => "{$slugPrefix}-variant-".ProductVariant::query()->count(), 'nombre' => ucfirst($slugPrefix).' Variant', 'stock' => $stock, @@ -352,21 +413,21 @@ class CartControllerTest extends TestCase protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant { - $hdrKey = (string) \Illuminate\Support\Str::uuid(); - $ftrKey = (string) \Illuminate\Support\Str::uuid(); + $hdrKey = (string) Str::uuid(); + $ftrKey = (string) Str::uuid(); - $headerAttachment = \App\Domains\Attachable\Models\Attachment::create([ + $headerAttachment = Attachment::create([ 'key' => $hdrKey, - 'path' => 'tenants/' . $hdrKey . '.png', + 'path' => 'tenants/'.$hdrKey.'.png', 'filename' => 'logo_header.png', - 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, + 'type' => AttachmentType::Image, 'mime_type' => 'image/png', ]); - $footerAttachment = \App\Domains\Attachable\Models\Attachment::create([ + $footerAttachment = Attachment::create([ 'key' => $ftrKey, - 'path' => 'tenants/' . $ftrKey . '.png', + 'path' => 'tenants/'.$ftrKey.'.png', 'filename' => 'logo_footer.png', - 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, + 'type' => AttachmentType::Image, 'mime_type' => 'image/png', ]); diff --git a/tests/Feature/Catalog/ProductControllerTest.php b/tests/Feature/Catalog/ProductControllerTest.php index 1d32724..810ea0f 100644 --- a/tests/Feature/Catalog/ProductControllerTest.php +++ b/tests/Feature/Catalog/ProductControllerTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature\Catalog; use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Models\Attachment; +use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Models\Attribute; use App\Domains\Catalog\Models\Brand; use App\Domains\Catalog\Models\Product; @@ -768,6 +769,67 @@ class ProductControllerTest extends TestCase $response->assertJsonValidationErrors(['variant_id']); } + public function test_it_creates_an_unlimited_default_variant_and_exposes_inventory_fields(): void + { + $response = $this->postJson("/api/tenants/{$this->tenant->codigo}/productos", [ + 'categoria_id' => 1, + 'brand_id' => $this->brand->id, + 'slug' => 'unlimited-product', + 'nombre' => 'Unlimited Product', + 'precio' => 100, + 'stock' => 0, + 'inventory_policy' => InventoryPolicy::Unlimited->value, + ]); + + $response->assertCreated(); + + $product = Product::query()->where('slug', 'unlimited-product')->firstOrFail(); + $variant = $product->variants()->firstOrFail(); + $this->assertSame(InventoryPolicy::Unlimited, $variant->inventory_policy); + + $this->getJson("/api/tenants/{$this->tenant->codigo}/productos/{$product->id}") + ->assertOk() + ->assertJsonPath('data.variant.id', $variant->id) + ->assertJsonPath('data.variant.inventory_policy', InventoryPolicy::Unlimited->value) + ->assertJsonPath('data.variant.cantidad_maxima', null) + ->assertJsonPath('data.variant.cantidad_vendida', 0) + ->assertJsonPath('data.variants_map.0.inventory_policy', InventoryPolicy::Unlimited->value) + ->assertJsonPath('data.variants_map.0.cantidad_maxima', null) + ->assertJsonPath('data.variants_map.0.cantidad_vendida', 0); + } + + public function test_it_rejects_invalid_or_updated_inventory_policies(): void + { + $this->postJson("/api/tenants/{$this->tenant->codigo}/productos", [ + 'categoria_id' => 1, + 'brand_id' => $this->brand->id, + 'slug' => 'invalid-policy', + 'nombre' => 'Invalid Policy', + 'precio' => 100, + 'inventory_policy' => 'sometimes', + ])->assertUnprocessable()->assertJsonValidationErrors(['inventory_policy']); + + $product = Product::query()->create([ + 'tenant_codigo' => $this->tenant->codigo, + 'categoria_id' => 1, + 'brand_id' => $this->brand->id, + 'slug' => 'immutable-policy', + 'nombre' => 'Immutable Policy', + 'precio' => 100, + ]); + $variant = $product->variants()->create([ + 'stock' => 0, + 'inventory_policy' => InventoryPolicy::Unlimited->value, + ]); + + $this->putJson( + "/api/tenants/{$this->tenant->codigo}/productos/{$product->id}/variants/{$variant->id}", + ['inventory_policy' => InventoryPolicy::Tracked->value], + )->assertUnprocessable()->assertJsonValidationErrors(['inventory_policy']); + + $this->assertSame(InventoryPolicy::Unlimited, $variant->fresh()->inventory_policy); + } + private function createAttachment(string $path): Attachment { return Attachment::create([ diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php index 0a2cf34..af75602 100644 --- a/tests/Feature/Integration/TelepagosWebhookTest.php +++ b/tests/Feature/Integration/TelepagosWebhookTest.php @@ -2,8 +2,12 @@ namespace Tests\Feature\Integration; +use App\Domains\Attachable\Enums\AttachmentType; +use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; use App\Domains\Cart\Models\Cart; +use App\Domains\Catalog\Enums\InventoryPolicy; +use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Product; use App\Domains\Catalog\Models\ProductVariant; use App\Domains\Integration\Models\Integration; @@ -14,6 +18,7 @@ use App\Domains\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Str; use Tests\TestCase; class TelepagosWebhookTest extends TestCase @@ -24,7 +29,7 @@ class TelepagosWebhookTest extends TestCase { parent::setUp(); - config(['services.integrations.secret' => 'base64:' . base64_encode(random_bytes(32))]); + config(['services.integrations.secret' => 'base64:'.base64_encode(random_bytes(32))]); Cache::flush(); } @@ -113,6 +118,13 @@ class TelepagosWebhookTest extends TestCase 'total' => 50, ]); + $this->assertDatabaseHas('productos_variantes', [ + 'id' => $variant->id, + 'stock_real' => 9, + 'stock_reservado' => 2, + 'cantidad_vendida' => 1, + ]); + $this->assertDatabaseMissing('compra_items', [ 'compra_id' => $newerPurchase->id, ]); @@ -122,6 +134,60 @@ class TelepagosWebhookTest extends TestCase ]); } + public function test_transfer_webhook_buys_unlimited_inventory_without_reducing_real_stock(): void + { + $tenant = $this->createTenant('unlimited', 'Unlimited', 'unlimited.com.ar'); + $this->configureTelepagosIntegration($tenant); + $user = User::factory()->create(); + $variant = $this->createVariantForTenant( + 'unlimited', + 0, + '50.00', + 'service', + InventoryPolicy::Unlimited, + ); + $purchase = $this->createPendingTransferPurchase( + $tenant, + $user->id, + $variant->id, + 3, + '87654321', + ); + + Http::fake([ + 'https://api.telepagos.com.ar/v2/auth/token' => Http::response([ + 'status' => 'ok', + 'token' => 'test-token', + 'expires_at' => now()->addHour()->toIso8601String(), + ]), + 'https://api.telepagos.com.ar/v2/payment/cashin/7000' => Http::response([ + 'status' => 'ok', + 'data' => [ + 'amount' => 150, + 'operation_id' => 1, + 'transaction_id' => 'tx-unlimited', + 'buyer' => ['cuit' => '20876543219'], + ], + ]), + ]); + + $this->postJson('/api/webhooks/telepagos/unlimited', ['id' => '7000']) + ->assertOk() + ->assertJsonPath('status', 'success'); + + $this->assertDatabaseHas('compras', [ + 'id' => $purchase->id, + 'status' => Purchase::STATUS_PAID, + ]); + $this->assertDatabaseHas('productos_variantes', [ + 'id' => $variant->id, + 'inventory_policy' => InventoryPolicy::Unlimited->value, + 'stock_real' => 0, + 'stock_reservado' => 0, + 'cantidad_vendida' => 3, + ]); + } + private function createPendingTransferPurchase( Tenant $tenant, int $userId, @@ -184,8 +250,9 @@ class TelepagosWebhookTest extends TestCase int $stock, string $price, string $slugPrefix = 'shirt', + InventoryPolicy $inventoryPolicy = InventoryPolicy::Tracked, ): ProductVariant { - $category = \App\Domains\Catalog\Models\Category::query()->create([ + $category = Category::query()->create([ 'tenant_code' => $tenantCode, 'nombre' => "{$slugPrefix} category {$tenantCode}", ]); @@ -201,6 +268,7 @@ class TelepagosWebhookTest extends TestCase return ProductVariant::query()->create([ 'producto_id' => $product->id, + 'inventory_policy' => $inventoryPolicy->value, 'slug' => "{$slugPrefix}-variant-".ProductVariant::query()->count(), 'nombre' => ucfirst($slugPrefix).' Variant', 'stock' => $stock, @@ -211,21 +279,21 @@ class TelepagosWebhookTest extends TestCase private function createTenant(string $codigo, string $nombre, string $dominio): Tenant { - $hdrKey = (string) \Illuminate\Support\Str::uuid(); - $ftrKey = (string) \Illuminate\Support\Str::uuid(); + $hdrKey = (string) Str::uuid(); + $ftrKey = (string) Str::uuid(); - $headerAttachment = \App\Domains\Attachable\Models\Attachment::create([ + $headerAttachment = Attachment::create([ 'key' => $hdrKey, - 'path' => 'tenants/' . $hdrKey . '.png', + 'path' => 'tenants/'.$hdrKey.'.png', 'filename' => 'logo_header.png', - 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, + 'type' => AttachmentType::Image, 'mime_type' => 'image/png', ]); - $footerAttachment = \App\Domains\Attachable\Models\Attachment::create([ + $footerAttachment = Attachment::create([ 'key' => $ftrKey, - 'path' => 'tenants/' . $ftrKey . '.png', + 'path' => 'tenants/'.$ftrKey.'.png', 'filename' => 'logo_footer.png', - 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, + 'type' => AttachmentType::Image, 'mime_type' => 'image/png', ]); diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index f830af0..bd9549a 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -2,14 +2,19 @@ namespace Tests\Feature\Purchase; +use App\Domains\Attachable\Enums\AttachmentType; +use App\Domains\Attachable\Models\Attachment; use App\Domains\Auth\Models\User; use App\Domains\Cart\Models\Cart; +use App\Domains\Catalog\Enums\InventoryPolicy; +use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Product; use App\Domains\Catalog\Models\ProductVariant; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Str; use Tests\TestCase; class StorePurchaseTest extends TestCase @@ -22,7 +27,7 @@ class StorePurchaseTest extends TestCase $user = User::factory()->create([ 'email' => 'buyer@example.com', ]); - $category = \App\Domains\Catalog\Models\Category::query()->create([ + $category = Category::query()->create([ 'tenant_code' => 'sonder', 'nombre' => 'Test Category', ]); @@ -237,6 +242,13 @@ class StorePurchaseTest extends TestCase $checkoutService->confirmPurchase($purchase); $purchase->refresh()->markAsPaid(); + $this->assertDatabaseHas('productos_variantes', [ + 'id' => $variant->id, + 'stock_real' => 8, + 'stock_reservado' => 0, + 'cantidad_vendida' => 2, + ]); + $this->assertSoftDeleted('carritos', [ 'id' => $purchase->cart_id, ]); @@ -401,18 +413,48 @@ class StorePurchaseTest extends TestCase ->assertJsonValidationErrors(['cart_id']); } + public function test_it_confirms_unlimited_inventory_without_reducing_real_stock_and_is_idempotent(): void + { + $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); + $user = User::factory()->create(); + $variant = $this->createVariantForTenant( + 'sonder', + 0, + '50.00', + 'unlimited', + InventoryPolicy::Unlimited, + ); + $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 25); + $purchase->update(['payment_method' => 'transfer']); + + $checkoutService = app(CheckoutService::class); + $purchase = $checkoutService->completePurchase($purchase); + $checkoutService->confirmPurchase($purchase); + $checkoutService->confirmPurchase($purchase); + + $this->assertDatabaseHas('productos_variantes', [ + 'id' => $variant->id, + 'inventory_policy' => InventoryPolicy::Unlimited->value, + 'stock_real' => 0, + 'stock_reservado' => 0, + 'cantidad_vendida' => 25, + ]); + $this->assertDatabaseCount('compra_items', 1); + } + protected function createVariantForTenant( string $tenantCode, int $stock, string $price, string $slugPrefix = 'shirt', + InventoryPolicy $inventoryPolicy = InventoryPolicy::Tracked, ): ProductVariant { $tenant = Tenant::query()->where('codigo', $tenantCode)->first(); if (! $tenant) { $this->createTenant($tenantCode, ucfirst($tenantCode), "{$tenantCode}.com"); } - $category = \App\Domains\Catalog\Models\Category::query()->create([ + $category = Category::query()->create([ 'tenant_code' => $tenantCode, 'nombre' => "{$slugPrefix} category {$tenantCode}", ]); @@ -428,6 +470,7 @@ class StorePurchaseTest extends TestCase return ProductVariant::query()->create([ 'producto_id' => $product->id, + 'inventory_policy' => $inventoryPolicy->value, 'slug' => "{$slugPrefix}-variant-".ProductVariant::query()->count(), 'nombre' => ucfirst($slugPrefix).' Variant', 'stock' => $stock, @@ -462,21 +505,21 @@ class StorePurchaseTest extends TestCase protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant { - $hdrKey = (string) \Illuminate\Support\Str::uuid(); - $ftrKey = (string) \Illuminate\Support\Str::uuid(); + $hdrKey = (string) Str::uuid(); + $ftrKey = (string) Str::uuid(); - $headerAttachment = \App\Domains\Attachable\Models\Attachment::create([ + $headerAttachment = Attachment::create([ 'key' => $hdrKey, - 'path' => 'tenants/' . $hdrKey . '.png', + 'path' => 'tenants/'.$hdrKey.'.png', 'filename' => 'logo_header.png', - 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, + 'type' => AttachmentType::Image, 'mime_type' => 'image/png', ]); - $footerAttachment = \App\Domains\Attachable\Models\Attachment::create([ + $footerAttachment = Attachment::create([ 'key' => $ftrKey, - 'path' => 'tenants/' . $ftrKey . '.png', + 'path' => 'tenants/'.$ftrKey.'.png', 'filename' => 'logo_footer.png', - 'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, + 'type' => AttachmentType::Image, 'mime_type' => 'image/png', ]); diff --git a/tests/Unit/Catalog/ProductVariantInventoryTest.php b/tests/Unit/Catalog/ProductVariantInventoryTest.php new file mode 100644 index 0000000..d2002dc --- /dev/null +++ b/tests/Unit/Catalog/ProductVariantInventoryTest.php @@ -0,0 +1,153 @@ +createAttachment('header.png'); + $footerAttachment = $this->createAttachment('footer.png'); + + $tenant = Tenant::query()->create([ + 'codigo' => 'inventory-test', + 'nombre' => 'Inventory Test', + 'dominio' => 'inventory.test', + 'primary_color' => '#111111', + 'secondary_color' => '#222222', + 'danger_color' => '#333333', + 'success_color' => '#28a745', + 'header_bg_color' => '#444444', + 'footer_bg_color' => '#444444', + 'header_logo_id' => $headerAttachment->id, + 'footer_logo_id' => $footerAttachment->id, + ]); + + $category = Category::query()->create([ + 'tenant_code' => $tenant->codigo, + 'nombre' => 'Inventory', + ]); + + $this->product = Product::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'categoria_id' => $category->id, + 'slug' => 'inventory-product', + 'nombre' => 'Inventory Product', + 'precio' => 100, + ]); + } + + public function test_it_defaults_to_tracked_inventory_with_no_sales(): void + { + $variant = $this->createVariant(5); + + $this->assertSame(InventoryPolicy::Tracked, $variant->inventory_policy); + $this->assertSame(5, $variant->availableQuantity()); + $this->assertSame(0, $variant->cantidad_vendida); + $this->assertTrue($variant->isAvailableForSale()); + } + + public function test_tracked_inventory_cannot_reserve_more_than_available_stock(): void + { + $variant = $this->createVariant(5); + $variant->reserveStock(3); + + $this->assertSame(2, $variant->fresh()->availableQuantity()); + + $this->expectException(\InvalidArgumentException::class); + $variant->reserveStock(3); + } + + public function test_unlimited_inventory_can_reserve_more_than_real_stock(): void + { + $variant = $this->createVariant(0, InventoryPolicy::Unlimited); + $variant->reserveStock(50); + + $variant->refresh(); + $this->assertNull($variant->availableQuantity()); + $this->assertSame(50, $variant->stock_reservado); + $this->assertTrue($variant->isAvailableForSale()); + } + + public function test_buying_tracked_inventory_consumes_stock_and_records_the_sale(): void + { + $variant = $this->createVariant(10); + $variant->reserveStock(4); + $variant->buy(3); + + $variant->refresh(); + $this->assertSame(7, $variant->stock_real); + $this->assertSame(1, $variant->stock_reservado); + $this->assertSame(3, $variant->cantidad_vendida); + } + + public function test_buying_unlimited_inventory_preserves_real_stock_and_records_the_sale(): void + { + $variant = $this->createVariant(0, InventoryPolicy::Unlimited); + $variant->reserveStock(4); + $variant->buy(3); + + $variant->refresh(); + $this->assertSame(0, $variant->stock_real); + $this->assertSame(1, $variant->stock_reservado); + $this->assertSame(3, $variant->cantidad_vendida); + } + + public function test_buy_requires_enough_reserved_stock(): void + { + $variant = $this->createVariant(10); + $variant->reserveStock(1); + + $this->expectException(\InvalidArgumentException::class); + $variant->buy(2); + } + + public function test_inventory_policy_cannot_change_after_creation(): void + { + $variant = $this->createVariant(10); + $variant->inventory_policy = InventoryPolicy::Unlimited; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('La politica de inventario no puede modificarse.'); + $variant->save(); + } + + private function createVariant( + int $stock, + InventoryPolicy $inventoryPolicy = InventoryPolicy::Tracked, + ): ProductVariant { + return ProductVariant::query()->create([ + 'producto_id' => $this->product->id, + 'stock' => $stock, + 'inventory_policy' => $inventoryPolicy->value, + ]); + } + + private function createAttachment(string $filename): Attachment + { + return Attachment::query()->create([ + 'key' => (string) Str::uuid(), + 'path' => 'tests/'.$filename, + 'filename' => $filename, + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + } +}