Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions backend/app/Services/Domain/Product/ProductPriceUpdateService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -23,6 +24,7 @@ public function __construct(

/**
* @throws CannotDeleteEntityException
* @throws ValidationException
*/
public function updatePrices(
ProductDomainObject $product,
Expand All @@ -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,
Expand Down Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
public function up(): void
{
DB::statement('
UPDATE product_prices
SET initial_quantity_available = quantity_sold
WHERE initial_quantity_available IS NOT NULL
AND quantity_sold > initial_quantity_available
AND deleted_at IS NULL
');
}

public function down(): void
{
// Cannot be reversed as the original initial_quantity_available values are unknown
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
<?php

namespace Tests\Unit\Services\Domain\Order;

use HiEvents\DomainObjects\Enums\ProductPriceType;
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\DomainObjects\Status\EventStatus;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
use HiEvents\Services\Domain\Order\OrderCreateRequestValidationService;
use HiEvents\Services\Domain\Product\AvailableProductQuantitiesFetchService;
use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesDTO;
use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesResponseDTO;
use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException;
use Mockery;
use Mockery\MockInterface;
use Tests\TestCase;

class OrderCreateRequestValidationServiceTest extends TestCase
{
private ProductRepositoryInterface|MockInterface $productRepository;
private PromoCodeRepositoryInterface|MockInterface $promoCodeRepository;
private EventRepositoryInterface|MockInterface $eventRepository;
private AvailableProductQuantitiesFetchService|MockInterface $availabilityService;
private OrderCreateRequestValidationService $service;

protected function setUp(): void
{
parent::setUp();

$this->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(),
));
}
}
Loading
Loading