diff --git a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php index f075f65b9..6df66a358 100644 --- a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php +++ b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php @@ -309,6 +309,10 @@ private function validatePriceIdAndQuantity(int $productIndex, array $productAnd private function validateProductPricesQuantity(array $quantities, ProductDomainObject $product, int $productIndex): void { foreach ($quantities as $productQuantity) { + if ($productQuantity['quantity'] === 0) { + continue; + } + $numberAvailable = $this->availableProductQuantities ->productQuantities ->where('product_id', $product->getId()) diff --git a/backend/app/Services/Domain/Product/AvailableProductQuantitiesFetchService.php b/backend/app/Services/Domain/Product/AvailableProductQuantitiesFetchService.php index 12e060e2d..d9c8869ea 100644 --- a/backend/app/Services/Domain/Product/AvailableProductQuantitiesFetchService.php +++ b/backend/app/Services/Domain/Product/AvailableProductQuantitiesFetchService.php @@ -105,10 +105,12 @@ private function fetchReservedProductQuantities(int $eventId): Collection product_prices.label AS price_label, product_prices.initial_quantity_available, product_prices.quantity_sold, - COALESCE( - product_prices.initial_quantity_available - - product_prices.quantity_sold - - COALESCE(reserved_quantities.quantity_reserved, 0), + GREATEST( + COALESCE( + product_prices.initial_quantity_available + - product_prices.quantity_sold + - COALESCE(reserved_quantities.quantity_reserved, 0), + 0), 0) AS quantity_available, COALESCE(reserved_quantities.quantity_reserved, 0) AS quantity_reserved, CASE WHEN product_prices.initial_quantity_available IS NULL diff --git a/backend/app/Services/Domain/Product/ProductPriceUpdateService.php b/backend/app/Services/Domain/Product/ProductPriceUpdateService.php index 05f1e34c0..45dc5d0ae 100644 --- a/backend/app/Services/Domain/Product/ProductPriceUpdateService.php +++ b/backend/app/Services/Domain/Product/ProductPriceUpdateService.php @@ -12,6 +12,7 @@ use HiEvents\Services\Application\Handlers\Product\DTO\UpsertProductDTO; use HiEvents\Services\Domain\Product\DTO\ProductPriceDTO; use Illuminate\Support\Collection; +use Illuminate\Validation\ValidationException; class ProductPriceUpdateService { @@ -23,6 +24,7 @@ public function __construct( /** * @throws CannotDeleteEntityException + * @throws ValidationException */ public function updatePrices( ProductDomainObject $product, @@ -32,6 +34,8 @@ public function updatePrices( EventDomainObject $event, ): void { + $this->validateQuantityAvailable($productsData->prices, $existingPrices); + if ($productsData->type !== ProductPriceType::TIERED) { $prices = new Collection([new ProductPriceDTO( price: $productsData->type === ProductPriceType::FREE ? 0.00 : $productsData->prices->first()->price, @@ -86,6 +90,41 @@ public function updatePrices( $this->deletePrices($prices, $existingPrices); } + /** + * @throws ValidationException + */ + private function validateQuantityAvailable(?Collection $prices, Collection $existingPrices): void + { + if ($prices === null) { + return; + } + + foreach ($prices as $index => $price) { + if ($price->id === null || $price->initial_quantity_available === null) { + continue; + } + + /** @var ProductPriceDomainObject|null $existingPrice */ + $existingPrice = $existingPrices->first(fn(ProductPriceDomainObject $p) => $p->getId() === $price->id); + + if ($existingPrice === null) { + continue; + } + + if ($price->initial_quantity_available < $existingPrice->getQuantitySold()) { + throw ValidationException::withMessages([ + "prices.$index.initial_quantity_available" => __( + 'The available quantity for :price cannot be less than the number already sold (:sold)', + [ + 'price' => $existingPrice->getLabel() ?: __('Default'), + 'sold' => $existingPrice->getQuantitySold(), + ] + ), + ]); + } + } + } + /** * @throws CannotDeleteEntityException */ diff --git a/backend/database/migrations/2026_03_15_082351_fix_product_prices_quantity_available_below_sold.php b/backend/database/migrations/2026_03_15_082351_fix_product_prices_quantity_available_below_sold.php new file mode 100644 index 000000000..11570ecb8 --- /dev/null +++ b/backend/database/migrations/2026_03_15_082351_fix_product_prices_quantity_available_below_sold.php @@ -0,0 +1,23 @@ + initial_quantity_available + AND deleted_at IS NULL + '); + } + + public function down(): void + { + // Cannot be reversed as the original initial_quantity_available values are unknown + } +}; diff --git a/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php new file mode 100644 index 000000000..75fe3c430 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php @@ -0,0 +1,210 @@ +productRepository = Mockery::mock(ProductRepositoryInterface::class); + $this->promoCodeRepository = Mockery::mock(PromoCodeRepositoryInterface::class); + $this->eventRepository = Mockery::mock(EventRepositoryInterface::class); + $this->availabilityService = Mockery::mock(AvailableProductQuantitiesFetchService::class); + + $this->service = new OrderCreateRequestValidationService( + $this->productRepository, + $this->promoCodeRepository, + $this->eventRepository, + $this->availabilityService, + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function testZeroQuantityTiersAreSkippedDuringValidation(): void + { + $eventId = 1; + $productId = 10; + $selectedPriceId = 101; + $unselectedPriceId = 102; + + $this->setupMocks( + eventId: $eventId, + productId: $productId, + priceIds: [$selectedPriceId, $unselectedPriceId], + priceLabels: ['Selected Tier', 'Unselected Tier'], + availabilities: [ + ['price_id' => $selectedPriceId, 'quantity_available' => 5, 'quantity_reserved' => 0], + ['price_id' => $unselectedPriceId, 'quantity_available' => 0, 'quantity_reserved' => 0], + ], + ); + + $data = [ + 'products' => [ + [ + 'product_id' => $productId, + 'quantities' => [ + ['price_id' => $selectedPriceId, 'quantity' => 1], + ['price_id' => $unselectedPriceId, 'quantity' => 0], + ], + ], + ], + ]; + + $this->service->validateRequestData($eventId, $data); + $this->assertTrue(true); + } + + public function testZeroQuantityTierWithNegativeAvailabilityDoesNotThrow(): void + { + $eventId = 1; + $productId = 10; + $healthyPriceId = 101; + $brokenPriceId = 102; + + $this->setupMocks( + eventId: $eventId, + productId: $productId, + priceIds: [$healthyPriceId, $brokenPriceId], + priceLabels: ['Healthy Tier', 'Broken Tier'], + availabilities: [ + ['price_id' => $healthyPriceId, 'quantity_available' => 10, 'quantity_reserved' => 0], + ['price_id' => $brokenPriceId, 'quantity_available' => -5, 'quantity_reserved' => 0], + ], + ); + + $data = [ + 'products' => [ + [ + 'product_id' => $productId, + 'quantities' => [ + ['price_id' => $healthyPriceId, 'quantity' => 1], + ['price_id' => $brokenPriceId, 'quantity' => 0], + ], + ], + ], + ]; + + $this->service->validateRequestData($eventId, $data); + $this->assertTrue(true); + } + + public function testNonZeroQuantityStillValidatesAgainstAvailability(): void + { + $eventId = 1; + $productId = 10; + $priceId = 101; + + $this->setupMocks( + eventId: $eventId, + productId: $productId, + priceIds: [$priceId], + priceLabels: ['Test Tier'], + availabilities: [ + ['price_id' => $priceId, 'quantity_available' => 2, 'quantity_reserved' => 0], + ], + ); + + $data = [ + 'products' => [ + [ + 'product_id' => $productId, + 'quantities' => [ + ['price_id' => $priceId, 'quantity' => 5], + ], + ], + ], + ]; + + $this->expectException(ValidationException::class); + $this->service->validateRequestData($eventId, $data); + } + + private function setupMocks( + int $eventId, + int $productId, + array $priceIds, + array $priceLabels, + array $availabilities, + ): void + { + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getId')->andReturn($eventId); + $event->shouldReceive('getStatus')->andReturn(EventStatus::LIVE->name); + $event->shouldReceive('getCurrency')->andReturn('USD'); + + $this->eventRepository->shouldReceive('findById')->with($eventId)->andReturn($event); + + $productPrices = new Collection(); + foreach ($priceIds as $i => $priceId) { + $price = Mockery::mock(ProductPriceDomainObject::class); + $price->shouldReceive('getId')->andReturn($priceId); + $price->shouldReceive('getLabel')->andReturn($priceLabels[$i] ?? null); + $productPrices->push($price); + } + + $product = Mockery::mock(ProductDomainObject::class); + $product->shouldReceive('getId')->andReturn($productId); + $product->shouldReceive('getEventId')->andReturn($eventId); + $product->shouldReceive('getTitle')->andReturn('Test Product'); + $product->shouldReceive('getMaxPerOrder')->andReturn(100); + $product->shouldReceive('getMinPerOrder')->andReturn(1); + $product->shouldReceive('isSoldOut')->andReturn(false); + $product->shouldReceive('getType')->andReturn(ProductPriceType::TIERED->name); + $product->shouldReceive('getProductPrices')->andReturn($productPrices); + + $this->productRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->productRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$product])); + + $quantityDTOs = collect(); + foreach ($availabilities as $avail) { + $quantityDTOs->push(AvailableProductQuantitiesDTO::fromArray([ + 'product_id' => $productId, + 'price_id' => $avail['price_id'], + 'product_title' => 'Test Product', + 'price_label' => null, + 'quantity_available' => $avail['quantity_available'], + 'quantity_reserved' => $avail['quantity_reserved'], + 'initial_quantity_available' => 100, + 'capacities' => collect(), + ])); + } + + $this->availabilityService->shouldReceive('getAvailableProductQuantities') + ->with($eventId, Mockery::any()) + ->andReturn(new AvailableProductQuantitiesResponseDTO( + productQuantities: $quantityDTOs, + capacities: collect(), + )); + } +} diff --git a/backend/tests/Unit/Services/Domain/Product/ProductPriceUpdateServiceTest.php b/backend/tests/Unit/Services/Domain/Product/ProductPriceUpdateServiceTest.php new file mode 100644 index 000000000..f7dd8d99e --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Product/ProductPriceUpdateServiceTest.php @@ -0,0 +1,140 @@ +productPriceRepository = Mockery::mock(ProductPriceRepository::class); + $this->service = new ProductPriceUpdateService($this->productPriceRepository); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function testThrowsWhenInitialQuantityAvailableIsLessThanQuantitySold(): void + { + $existingPrices = new Collection([$this->createExistingPrice(id: 1, quantitySold: 10, label: 'Early Bird')]); + [$product, $event] = $this->createProductAndEvent($existingPrices); + + $productsData = $this->createUpsertDTO(ProductPriceType::PAID, [ + new ProductPriceDTO(price: 10.00, initial_quantity_available: 5, id: 1), + ]); + + $this->expectException(ValidationException::class); + $this->service->updatePrices($product, $productsData, $existingPrices, $event); + } + + public function testAllowsInitialQuantityAvailableEqualToQuantitySold(): void + { + $existingPrices = new Collection([$this->createExistingPrice(id: 1, quantitySold: 10, label: 'Early Bird')]); + [$product, $event] = $this->createProductAndEvent($existingPrices); + + $this->productPriceRepository->shouldReceive('updateWhere')->once(); + + $productsData = $this->createUpsertDTO(ProductPriceType::PAID, [ + new ProductPriceDTO(price: 10.00, initial_quantity_available: 10, id: 1), + ]); + + $this->service->updatePrices($product, $productsData, $existingPrices, $event); + $this->assertTrue(true); + } + + public function testAllowsNullInitialQuantityAvailable(): void + { + $existingPrices = new Collection([$this->createExistingPrice(id: 1, quantitySold: 10, label: 'Early Bird')]); + [$product, $event] = $this->createProductAndEvent($existingPrices); + + $this->productPriceRepository->shouldReceive('updateWhere')->once(); + + $productsData = $this->createUpsertDTO(ProductPriceType::PAID, [ + new ProductPriceDTO(price: 10.00, initial_quantity_available: null, id: 1), + ]); + + $this->service->updatePrices($product, $productsData, $existingPrices, $event); + $this->assertTrue(true); + } + + public function testThrowsForCorrectTierInTieredProduct(): void + { + $existingPrices = new Collection([ + $this->createExistingPrice(id: 1, quantitySold: 5, label: 'Tier 1'), + $this->createExistingPrice(id: 2, quantitySold: 20, label: 'Tier 2'), + ]); + [$product, $event] = $this->createProductAndEvent($existingPrices); + + $productsData = $this->createUpsertDTO(ProductPriceType::TIERED, [ + new ProductPriceDTO(price: 10.00, initial_quantity_available: 10, id: 1), + new ProductPriceDTO(price: 20.00, initial_quantity_available: 15, id: 2), + ]); + + try { + $this->service->updatePrices($product, $productsData, $existingPrices, $event); + $this->fail('Expected ValidationException was not thrown'); + } catch (ValidationException $e) { + $errors = $e->errors(); + $this->assertArrayHasKey('prices.1.initial_quantity_available', $errors); + $this->assertStringContainsString('Tier 2', $errors['prices.1.initial_quantity_available'][0]); + $this->assertStringContainsString('20', $errors['prices.1.initial_quantity_available'][0]); + } + } + + private function createExistingPrice(int $id, int $quantitySold, string $label): MockInterface + { + $price = Mockery::mock(ProductPriceDomainObject::class); + $price->shouldReceive('getId')->andReturn($id); + $price->shouldReceive('getQuantitySold')->andReturn($quantitySold); + $price->shouldReceive('getLabel')->andReturn($label); + return $price; + } + + private function createProductAndEvent(Collection $existingPrices): array + { + $product = Mockery::mock(ProductDomainObject::class); + $product->shouldReceive('getId')->andReturn(1); + $product->shouldReceive('getProductPrices')->andReturn($existingPrices); + + $event = Mockery::mock(EventDomainObject::class); + $event->shouldReceive('getTimezone')->andReturn('UTC'); + + return [$product, $event]; + } + + private function createUpsertDTO(ProductPriceType $type, array $prices): UpsertProductDTO + { + return UpsertProductDTO::fromArray([ + 'account_id' => 1, + 'event_id' => 1, + 'product_id' => 1, + 'product_category_id' => 1, + 'title' => 'Test', + 'type' => $type, + 'product_type' => ProductType::TICKET, + 'prices' => new Collection($prices), + ]); + } +}