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
16 changes: 16 additions & 0 deletions backend/app/DomainObjects/Enums/ProductQuantityAppliesTo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace HiEvents\DomainObjects\Enums;

enum ProductQuantityAppliesTo
{
use BaseEnum;

case OCCURRENCE;
case EVENT;

public static function defaultFor(ProductType $productType): self
{
return $productType === ProductType::TICKET ? self::OCCURRENCE : self::EVENT;
}
}
15 changes: 15 additions & 0 deletions backend/app/DomainObjects/EventOccurrenceDomainObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use HiEvents\DomainObjects\Interfaces\IsSortable;
use HiEvents\DomainObjects\SortingAndFiltering\AllowedSorts;
use HiEvents\DomainObjects\Status\EventOccurrenceStatus;
use HiEvents\Services\Domain\EventOccurrence\DTO\OccurrenceBookingLimitsDTO;
use Illuminate\Support\Collection;

class EventOccurrenceDomainObject extends EventOccurrenceDomainObjectAbstract implements IsFilterable, IsSortable
Expand All @@ -26,6 +27,8 @@ class EventOccurrenceDomainObject extends EventOccurrenceDomainObjectAbstract im

private ?EventLocationDomainObject $eventLocation = null;

private ?OccurrenceBookingLimitsDTO $bookingLimits = null;

public static function getAllowedFilterFields(): array
{
return [
Expand Down Expand Up @@ -191,4 +194,16 @@ public function getEventLocation(): ?EventLocationDomainObject
{
return $this->eventLocation;
}

public function setBookingLimits(?OccurrenceBookingLimitsDTO $bookingLimits): self
{
$this->bookingLimits = $bookingLimits;

return $this;
}

public function getBookingLimits(): ?OccurrenceBookingLimitsDTO
{
return $this->bookingLimits;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ abstract class ProductPriceDomainObjectAbstract extends \HiEvents\DomainObjects\
final public const IS_HIDDEN = 'is_hidden';
final public const ORDER = 'order';
final public const QUANTITY_AVAILABLE = 'quantity_available';
final public const QUANTITY_APPLIES_TO = 'quantity_applies_to';

protected int $id;
protected int $product_id;
Expand All @@ -39,6 +40,7 @@ abstract class ProductPriceDomainObjectAbstract extends \HiEvents\DomainObjects\
protected ?bool $is_hidden = false;
protected int $order = 1;
protected ?int $quantity_available = null;
protected string $quantity_applies_to = 'OCCURRENCE';

public function toArray(): array
{
Expand All @@ -57,6 +59,7 @@ public function toArray(): array
'is_hidden' => $this->is_hidden ?? null,
'order' => $this->order ?? null,
'quantity_available' => $this->quantity_available ?? null,
'quantity_applies_to' => $this->quantity_applies_to ?? null,
];
}

Expand Down Expand Up @@ -213,4 +216,15 @@ public function getQuantityAvailable(): ?int
{
return $this->quantity_available;
}

public function setQuantityAppliesTo(string $quantity_applies_to): self
{
$this->quantity_applies_to = $quantity_applies_to;
return $this;
}

public function getQuantityAppliesTo(): string
{
return $this->quantity_applies_to;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ abstract class ProductPriceOccurrenceOverrideDomainObjectAbstract extends \HiEve
final public const PRICE = 'price';
final public const CREATED_AT = 'created_at';
final public const UPDATED_AT = 'updated_at';
final public const QUANTITY_AVAILABLE = 'quantity_available';

protected int $id;
protected int $event_occurrence_id;
protected int $product_price_id;
protected float $price;
protected ?float $price = null;
protected ?string $created_at = null;
protected ?string $updated_at = null;
protected ?int $quantity_available = null;

public function toArray(): array
{
Expand All @@ -33,6 +35,7 @@ public function toArray(): array
'price' => $this->price ?? null,
'created_at' => $this->created_at ?? null,
'updated_at' => $this->updated_at ?? null,
'quantity_available' => $this->quantity_available ?? null,
];
}

Expand Down Expand Up @@ -69,13 +72,13 @@ public function getProductPriceId(): int
return $this->product_price_id;
}

public function setPrice(float $price): self
public function setPrice(?float $price): self
{
$this->price = $price;
return $this;
}

public function getPrice(): float
public function getPrice(): ?float
{
return $this->price;
}
Expand All @@ -101,4 +104,15 @@ public function getUpdatedAt(): ?string
{
return $this->updated_at;
}

public function setQuantityAvailable(?int $quantity_available): self
{
$this->quantity_available = $quantity_available;
return $this;
}

public function getQuantityAvailable(): ?int
{
return $this->quantity_available;
}
}
17 changes: 13 additions & 4 deletions backend/app/DomainObjects/ProductPriceDomainObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace HiEvents\DomainObjects;

use Carbon\Carbon;
use HiEvents\DomainObjects\Enums\ProductQuantityAppliesTo;
use HiEvents\Helper\Currency;
use LogicException;

Expand Down Expand Up @@ -79,28 +80,36 @@ public function isAfterSaleEndDate(): bool

public function isSoldOut(): bool
{
// todo this is temporary to see why/when this happens
if ($this->getQuantityAvailable() < 0) {
throw new LogicException('Quantity available cannot be less than 0');
}

if ($this->getQuantityAvailable() !== null && $this->getQuantityAvailable() <= 0) {
return true;
if ($this->getQuantityAvailable() !== null) {
return $this->getQuantityAvailable() <= 0;
}

if ($this->getInitialQuantityAvailable() === null) {
if ($this->getInitialQuantityAvailable() === null || $this->isQuantityPerOccurrence()) {
return false;
}

return $this->getQuantitySold() >= $this->getInitialQuantityAvailable();
}

public function isQuantityPerOccurrence(): bool
{
return $this->getQuantityAppliesTo() === ProductQuantityAppliesTo::OCCURRENCE->name;
}

public function isExhausted(): bool
{
if ($this->isAfterSaleEndDate()) {
return true;
}

if ($this->isQuantityPerOccurrence()) {
return $this->getQuantityAvailable() !== null && $this->getQuantityAvailable() <= 0;
}

return $this->getInitialQuantityAvailable() !== null
&& $this->getQuantitySold() + $this->quantityReserved >= $this->getInitialQuantityAvailable();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

namespace HiEvents\Exceptions;

class InvalidOccurrenceQuantityOverrideException extends BaseException {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace HiEvents\Http\Actions\EventOccurrences;

use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Resources\EventOccurrence\OccurrenceProductAvailabilityResource;
use HiEvents\Services\Application\Handlers\EventOccurrence\GetOccurrenceProductAvailabilityHandler;
use Illuminate\Http\JsonResponse;

class GetOccurrenceProductAvailabilityAction extends BaseAction
{
public function __construct(
private readonly GetOccurrenceProductAvailabilityHandler $handler,
) {}

public function __invoke(int $eventId, int $occurrenceId): JsonResponse
{
$this->isActionAuthorized($eventId, EventDomainObject::class);

return $this->resourceResponse(
resource: OccurrenceProductAvailabilityResource::class,
data: $this->handler->handle($eventId, $occurrenceId),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,46 @@
namespace HiEvents\Http\Actions\EventOccurrences;

use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\Exceptions\InvalidOccurrenceQuantityOverrideException;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Http\Request\EventOccurrence\UpsertPriceOverrideRequest;
use HiEvents\Resources\EventOccurrence\ProductPriceOccurrenceOverrideResource;
use HiEvents\Services\Application\Handlers\EventOccurrence\PriceOverride\DTO\UpsertPriceOverrideDTO;
use HiEvents\Services\Application\Handlers\EventOccurrence\PriceOverride\UpsertPriceOverrideHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;

class UpsertPriceOverrideAction extends BaseAction
{
public function __construct(
private readonly UpsertPriceOverrideHandler $handler,
) {}

/**
* @throws ValidationException
*/
public function __invoke(int $eventId, int $occurrenceId, UpsertPriceOverrideRequest $request): JsonResponse
{
$this->isActionAuthorized($eventId, EventDomainObject::class);

$override = $this->handler->handle(
new UpsertPriceOverrideDTO(
event_id: $eventId,
event_occurrence_id: $occurrenceId,
product_price_id: $request->validated('product_price_id'),
price: (float) $request->validated('price'),
)
);
$price = $request->validated('price');
$quantityAvailable = $request->validated('quantity_available');

try {
$override = $this->handler->handle(
new UpsertPriceOverrideDTO(
event_id: $eventId,
event_occurrence_id: $occurrenceId,
product_price_id: $request->validated('product_price_id'),
price: $price === null ? null : (float) $price,
quantity_available: $quantityAvailable === null ? null : (int) $quantityAvailable,
)
);
} catch (InvalidOccurrenceQuantityOverrideException $exception) {
throw ValidationException::withMessages([
'quantity_available' => $exception->getMessage(),
]);
}

return $this->resourceResponse(
resource: ProductPriceOccurrenceOverrideResource::class,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@
namespace HiEvents\Http\Request\EventOccurrence;

use HiEvents\Http\Request\BaseRequest;
use HiEvents\Validators\Rules\RulesHelper;

class UpsertPriceOverrideRequest extends BaseRequest
{
public function rules(): array
{
return [
'product_price_id' => ['required', 'integer'],
'price' => ['required', 'numeric', 'min:0', 'max:100000000'],
'price' => ['nullable', 'required_without:quantity_available', 'numeric', 'min:0', 'max:100000000'],
'quantity_available' => ['nullable', 'required_without:price', ...RulesHelper::INTEGER, 'min:0'],
];
}
}
2 changes: 2 additions & 0 deletions backend/app/Http/Request/Product/UpsertProductRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace HiEvents\Http\Request\Product;

use HiEvents\DomainObjects\Enums\ProductPriceType;
use HiEvents\DomainObjects\Enums\ProductQuantityAppliesTo;
use HiEvents\DomainObjects\Enums\ProductType;
use HiEvents\Http\Request\BaseRequest;
use HiEvents\Validators\Rules\RulesHelper;
Expand All @@ -29,6 +30,7 @@ public function rules(): array
'prices.*.sale_end_date' => 'date|nullable|after:prices.*.sale_start_date',
'prices.*.initial_quantity_available' => [...RulesHelper::INTEGER, 'nullable', 'min:0'],
'prices.*.is_hidden' => ['boolean'],
'prices.*.quantity_applies_to' => ['nullable', Rule::in(ProductQuantityAppliesTo::valuesArray())],
'description' => 'string|nullable',
'min_per_order' => [...RulesHelper::INTEGER, 'nullable'],
'is_hidden' => 'boolean',
Expand Down
2 changes: 1 addition & 1 deletion backend/app/Models/ProductCategory.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,6 @@ class ProductCategory extends BaseModel

public function products(): HasMany
{
return $this->hasMany(Product::class);
return $this->hasMany(Product::class)->orderBy('order');
}
}
41 changes: 41 additions & 0 deletions backend/app/Repository/Eloquent/AttendeeRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -171,4 +171,45 @@ public function getAttendeesByCheckInShortId(string $shortId, QueryParamsDTO $pa
limit: min($params->per_page, 250),
);
}

public function getSoldQuantitiesByPriceForOccurrence(int $occurrenceId): array
{
return $this->runQuery(fn () => $this->soldAttendeesQuery()
->where('attendees.event_occurrence_id', $occurrenceId)
->groupBy('attendees.product_price_id')
->selectRaw('attendees.product_price_id, COUNT(*) AS quantity')
->pluck('quantity', 'product_price_id')
->map(fn ($quantity) => (int) $quantity)
->all());
}

public function getMaxSoldPerOccurrenceByPrice(array $productPriceIds): array
{
return $this->runQuery(fn () => $this->soldAttendeesQuery()
->whereIn('attendees.product_price_id', $productPriceIds)
->whereNotNull('attendees.event_occurrence_id')
->groupBy('attendees.product_price_id', 'attendees.event_occurrence_id')
->selectRaw('attendees.product_price_id, COUNT(*) AS quantity')
->get()
->groupBy('product_price_id')
->map(fn ($rows) => (int) $rows->max('quantity'))
->all());
}

private function soldAttendeesQuery(): Builder
{
return Attendee::query()
->join('orders', 'orders.id', '=', 'attendees.order_id')
->whereNull('attendees.deleted_at')
->whereNull('orders.deleted_at')
->where(function (Builder $query) {
$query
->where('attendees.status', AttendeeStatus::ACTIVE->name)
->orWhere(function (Builder $query) {
$query
->where('attendees.status', AttendeeStatus::AWAITING_PAYMENT->name)
->where('orders.status', OrderStatus::AWAITING_OFFLINE_PAYMENT->name);
});
});
}
}
Loading
Loading