diff --git a/CLAUDE.md b/CLAUDE.md index a3ef65116c..7141894ff6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,6 +151,11 @@ Gotchas: #### E2E Tests - There is a Playwright E2E suite in `e2e/` (see `e2e/README.md`). It runs the real stack (Laravel + SSR frontend + Postgres + Redis + Mailpit) in Docker. +- **To test uncommitted changes, run specs against the dev stack** — the hermetic e2e stack bakes source into images and `docker compose up` never rebuilds them. From `e2e/`: + ```bash + E2E_BASE_URL=https://localhost:8443 MAILPIT_URL=http://localhost:8025 E2E_SAAS_MODE=true npx playwright test + ``` + `E2E_SAAS_MODE=true` is required (the dev stack requires email verification; the fixture only confirms via Mailpit in SaaS mode), a queue worker must be running to deliver the verification emails, and superadmin-dependent specs need a one-time `php artisan dev:bootstrap --email=superadmin@e2e.test --password='SuperAdminPass123!'`. See "Against the running dev stack" in `e2e/README.md`. - **When you add or meaningfully change a user-facing flow, add or update an E2E spec for it where practical.** Follow the existing pattern: arrange data via the API/`factory`, drive only the flow under test through the UI with a thin page object, and assert on real page content (the created/edited item appears), not just a URL change. Tag fast, load-bearing checks with `@smoke`. - Not everything needs E2E — reserve it for real user journeys (create/edit/complete flows). Pure logic belongs in backend unit/feature tests instead. diff --git a/backend/app/Console/Commands/BootstrapDevDataCommand.php b/backend/app/Console/Commands/BootstrapDevDataCommand.php index 32dc916bc0..2e68bb8286 100644 --- a/backend/app/Console/Commands/BootstrapDevDataCommand.php +++ b/backend/app/Console/Commands/BootstrapDevDataCommand.php @@ -6,6 +6,7 @@ use HiEvents\DomainObjects\Enums\EventType; use HiEvents\DomainObjects\Enums\ProductPriceType; use HiEvents\DomainObjects\Enums\ProductType; +use HiEvents\DomainObjects\Enums\PromoCodeDiscountAppliesToEnum; use HiEvents\DomainObjects\Enums\PromoCodeDiscountTypeEnum; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\Status\EventStatus; @@ -131,6 +132,7 @@ public function handle( discount: 10.0, expiry_date: null, max_allowed_usages: null, + discount_applies_to: PromoCodeDiscountAppliesToEnum::EACH_PRODUCT, )); $affiliate = $createAffiliateHandler->handle($singleEvent->getId(), $account->getId(), new UpsertAffiliateDTO( diff --git a/backend/app/DomainObjects/Enums/PromoCodeDiscountAppliesToEnum.php b/backend/app/DomainObjects/Enums/PromoCodeDiscountAppliesToEnum.php new file mode 100644 index 0000000000..a202729d59 --- /dev/null +++ b/backend/app/DomainObjects/Enums/PromoCodeDiscountAppliesToEnum.php @@ -0,0 +1,11 @@ +eventOccurrences === null || $this->eventOccurrences->isEmpty()) { - return null; - } + $this->nextOccurrenceStartDate = $nextOccurrenceStartDate; - $nextOccurrence = $this->eventOccurrences - ->filter(fn (EventOccurrenceDomainObject $o) => $o->getStatus() === EventOccurrenceStatus::ACTIVE->name) - ->filter(fn (EventOccurrenceDomainObject $o) => ! $o->isPast()) - ->sortBy(fn (EventOccurrenceDomainObject $o) => $o->getStartDate()) - ->first(); + return $this; + } - return $nextOccurrence?->getStartDate(); + public function setLastOccurrenceStartDate(?string $lastOccurrenceStartDate): self + { + $this->lastOccurrenceStartDate = $lastOccurrenceStartDate; + + return $this; } - public function isEventInPast(): bool + public function getLastOccurrenceStartDate(): ?string { - $endDate = $this->getEndDate(); - if ($endDate === null) { - return false; - } + return $this->lastOccurrenceStartDate; + } - $parsed = Carbon::parse($endDate); - if ($this->getTimezone()) { - $parsed->setTimezone($this->getTimezone()); - } + public function setOccurrencesMonth(?string $occurrencesMonth): self + { + $this->occurrencesMonth = $occurrencesMonth; - return $parsed->isPast(); + return $this; } - public function isEventInFuture(): bool + public function getOccurrencesMonth(): ?string { - $startDate = $this->getStartDate(); - if ($startDate === null) { - return false; + return $this->occurrencesMonth; + } + + public function getNextOccurrenceStartDate(): ?string + { + if ($this->nextOccurrenceStartDate !== null) { + return $this->nextOccurrenceStartDate; } - $parsed = Carbon::parse($startDate); - if ($this->getTimezone()) { - $parsed->setTimezone($this->getTimezone()); + if ($this->eventOccurrences === null || $this->eventOccurrences->isEmpty()) { + return null; } - return $parsed->isFuture(); + $nextOccurrence = $this->eventOccurrences + ->filter(fn (EventOccurrenceDomainObject $o) => $o->getStatus() === EventOccurrenceStatus::ACTIVE->name) + ->filter(fn (EventOccurrenceDomainObject $o) => ! $o->isPast()) + ->sortBy(fn (EventOccurrenceDomainObject $o) => $o->getStartDate()) + ->first(); + + return $nextOccurrence?->getStartDate(); } public function isEventOngoing(): bool @@ -287,20 +297,11 @@ public function isEventOngoing(): bool return false; } - foreach ($this->eventOccurrences as $occurrence) { - if ($occurrence->getStatus() !== EventOccurrenceStatus::ACTIVE->name) { - continue; - } - - $start = Carbon::parse($occurrence->getStartDate(), 'UTC'); - $end = $occurrence->getEndDate() ? Carbon::parse($occurrence->getEndDate(), 'UTC') : null; - - if ($start->isPast() && ($end === null || $end->isFuture())) { - return true; - } - } - - return false; + return $this->eventOccurrences->contains( + fn (EventOccurrenceDomainObject $o) => $o->getStatus() === EventOccurrenceStatus::ACTIVE->name + && ! $o->isFuture() + && ! $o->isPast() + ); } public function getLifecycleStatus(): string @@ -309,11 +310,17 @@ public function getLifecycleStatus(): string return EventLifecycleStatus::ONGOING->name; } - if ($this->isEventInFuture() || $this->getStartDate() === null) { + if ($this->eventOccurrences === null || $this->eventOccurrences->isEmpty()) { return EventLifecycleStatus::UPCOMING->name; } - return EventLifecycleStatus::ENDED->name; + $hasOccurrenceStillToCome = $this->eventOccurrences->contains( + fn (EventOccurrenceDomainObject $o) => ! $o->isPast() + ); + + return $hasOccurrenceStillToCome + ? EventLifecycleStatus::UPCOMING->name + : EventLifecycleStatus::ENDED->name; } public function isRecurring(): bool diff --git a/backend/app/DomainObjects/Generated/PromoCodeDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/PromoCodeDomainObjectAbstract.php index c1e7174e18..20c34cb9d7 100644 --- a/backend/app/DomainObjects/Generated/PromoCodeDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/PromoCodeDomainObjectAbstract.php @@ -23,6 +23,7 @@ abstract class PromoCodeDomainObjectAbstract extends \HiEvents\DomainObjects\Abs final public const CREATED_AT = 'created_at'; final public const UPDATED_AT = 'updated_at'; final public const DELETED_AT = 'deleted_at'; + final public const DISCOUNT_APPLIES_TO = 'discount_applies_to'; protected int $id; protected int $event_id; @@ -37,6 +38,7 @@ abstract class PromoCodeDomainObjectAbstract extends \HiEvents\DomainObjects\Abs protected string $created_at; protected ?string $updated_at = null; protected ?string $deleted_at = null; + protected string $discount_applies_to = 'EACH_PRODUCT'; public function toArray(): array { @@ -54,6 +56,7 @@ public function toArray(): array 'created_at' => $this->created_at ?? null, 'updated_at' => $this->updated_at ?? null, 'deleted_at' => $this->deleted_at ?? null, + 'discount_applies_to' => $this->discount_applies_to ?? null, ]; } @@ -199,4 +202,15 @@ public function getDeletedAt(): ?string { return $this->deleted_at; } + + public function setDiscountAppliesTo(string $discount_applies_to): self + { + $this->discount_applies_to = $discount_applies_to; + return $this; + } + + public function getDiscountAppliesTo(): string + { + return $this->discount_applies_to; + } } diff --git a/backend/app/DomainObjects/PromoCodeDomainObject.php b/backend/app/DomainObjects/PromoCodeDomainObject.php index 8ac770b93e..f0eb1d7b3f 100644 --- a/backend/app/DomainObjects/PromoCodeDomainObject.php +++ b/backend/app/DomainObjects/PromoCodeDomainObject.php @@ -3,6 +3,7 @@ namespace HiEvents\DomainObjects; use Carbon\Carbon; +use HiEvents\DomainObjects\Enums\PromoCodeDiscountAppliesToEnum; use HiEvents\DomainObjects\Enums\PromoCodeDiscountTypeEnum; use HiEvents\DomainObjects\Interfaces\IsSortable; use HiEvents\DomainObjects\SortingAndFiltering\AllowedSorts; @@ -59,7 +60,6 @@ public function isValid(): bool public function appliesToProduct(ProductDomainObject $product): bool { - // If there's no product IDs we apply the promo to all products if (! $this->getApplicableProductIds()) { return true; } @@ -81,4 +81,10 @@ public function isNoDiscountCode(): bool { return $this->getDiscountType() === PromoCodeDiscountTypeEnum::NONE->name; } + + public function isOrderLevelDiscount(): bool + { + return $this->isFixedDiscount() + && $this->getDiscountAppliesTo() === PromoCodeDiscountAppliesToEnum::ORDER->name; + } } diff --git a/backend/app/Exports/PromoCodesExport.php b/backend/app/Exports/PromoCodesExport.php index 3042ad336b..3603c72eb7 100644 --- a/backend/app/Exports/PromoCodesExport.php +++ b/backend/app/Exports/PromoCodesExport.php @@ -34,6 +34,7 @@ public function headings(): array 'Code', 'Discount', 'Discount Type', + 'Discount Applies To', 'Max Allowed Uses', 'Expiry Date', 'Event ID', @@ -49,6 +50,7 @@ public function map($discountCode): array $discountCode->getCode(), $discountCode->getDiscount(), $discountCode->getDiscountType(), + $discountCode->getDiscountAppliesTo(), $discountCode->getMaxAllowedUsages(), $discountCode->getExpiryDate(), $discountCode->getEventId(), diff --git a/backend/app/Http/Actions/EventOccurrences/GetEventOccurrencesPublicAction.php b/backend/app/Http/Actions/EventOccurrences/GetEventOccurrencesPublicAction.php new file mode 100644 index 0000000000..1598706766 --- /dev/null +++ b/backend/app/Http/Actions/EventOccurrences/GetEventOccurrencesPublicAction.php @@ -0,0 +1,54 @@ +query('start_date_from'); + $startDateTo = $request->query('start_date_to'); + + try { + $result = $this->handler->handle(new GetPublicEventOccurrencesDTO( + eventId: $eventId, + startDateFrom: is_string($startDateFrom) ? $startDateFrom : null, + startDateTo: is_string($startDateTo) ? $startDateTo : null, + )); + } catch (InvalidOccurrenceDatesException $exception) { + throw ValidationException::withMessages([ + 'start_date_from' => $exception->getMessage(), + ]); + } + + if (! $this->canUserViewEvent($result->event)) { + return $this->notFoundResponse(); + } + + $showCapacity = $result->event->getEventSettings()?->getShowAvailableOccurrenceCapacity() ?? false; + + return $this->jsonResponse([ + 'data' => $result->occurrences->map( + fn (EventOccurrenceDomainObject $occurrence) => new EventOccurrenceResourcePublic($occurrence, $showCapacity) + )->values(), + ]); + } +} diff --git a/backend/app/Http/Actions/Events/BasePublicEventAction.php b/backend/app/Http/Actions/Events/BasePublicEventAction.php new file mode 100644 index 0000000000..6c24722eeb --- /dev/null +++ b/backend/app/Http/Actions/Events/BasePublicEventAction.php @@ -0,0 +1,36 @@ +getStatus() === EventStatus::LIVE->name) { + return true; + } + + if ($this->isUserAuthenticated() && $event->getAccountId() === $this->getAuthenticatedAccountId()) { + return true; + } + + if ($this->isUserAuthenticated() && $this->getAuthenticatedUserRole() === Role::SUPERADMIN) { + Log::debug(__('Superadmin user is viewing non-live event with ID :eventId', [ + 'eventId' => $event->getId(), + 'accountId' => $this->getAuthenticatedAccountId(), + ])); + + return true; + } + + return false; + } +} diff --git a/backend/app/Http/Actions/Events/GetEventPublicAction.php b/backend/app/Http/Actions/Events/GetEventPublicAction.php index f2404371fd..e5e8e4c7c9 100644 --- a/backend/app/Http/Actions/Events/GetEventPublicAction.php +++ b/backend/app/Http/Actions/Events/GetEventPublicAction.php @@ -2,10 +2,6 @@ namespace HiEvents\Http\Actions\Events; -use HiEvents\DomainObjects\Enums\Role; -use HiEvents\DomainObjects\EventDomainObject; -use HiEvents\DomainObjects\Status\EventStatus; -use HiEvents\Http\Actions\BaseAction; use HiEvents\Resources\Event\EventResourcePublic; use HiEvents\Services\Application\Handlers\Event\DTO\GetPublicEventDTO; use HiEvents\Services\Application\Handlers\Event\GetPublicEventHandler; @@ -14,7 +10,7 @@ use Illuminate\Http\Response; use Psr\Log\LoggerInterface; -class GetEventPublicAction extends BaseAction +class GetEventPublicAction extends BasePublicEventAction { public function __construct( private readonly GetPublicEventHandler $getPublicEventHandler, @@ -41,26 +37,4 @@ public function __invoke(int $eventId, Request $request): Response|JsonResponse return $this->resourceResponse(EventResourcePublic::class, $event); } - - private function canUserViewEvent(EventDomainObject $event): bool - { - if ($event->getStatus() === EventStatus::LIVE->name) { - return true; - } - - if ($this->isUserAuthenticated() && $event->getAccountId() === $this->getAuthenticatedAccountId()) { - return true; - } - - if ($this->isUserAuthenticated() && $this->getAuthenticatedUserRole() === Role::SUPERADMIN) { - $this->logger->debug(__('Superadmin user is viewing non-live event with ID :eventId', [ - 'eventId' => $event->getId(), - 'accountId' => $this->getAuthenticatedAccountId(), - ])); - - return true; - } - - return false; - } } diff --git a/backend/app/Http/Actions/PromoCodes/CreatePromoCodeAction.php b/backend/app/Http/Actions/PromoCodes/CreatePromoCodeAction.php index 0e2bc37f73..41f39d2bd0 100644 --- a/backend/app/Http/Actions/PromoCodes/CreatePromoCodeAction.php +++ b/backend/app/Http/Actions/PromoCodes/CreatePromoCodeAction.php @@ -2,6 +2,7 @@ namespace HiEvents\Http\Actions\PromoCodes; +use HiEvents\DomainObjects\Enums\PromoCodeDiscountAppliesToEnum; use HiEvents\DomainObjects\Enums\PromoCodeDiscountTypeEnum; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Exceptions\ResourceConflictException; @@ -40,6 +41,9 @@ public function __invoke(CreateUpdatePromoCodeRequest $request, int $eventId): J discount: $request->float('discount'), expiry_date: $request->input('expiry_date'), max_allowed_usages: $request->input('max_allowed_usages'), + discount_applies_to: PromoCodeDiscountAppliesToEnum::fromName( + $request->input('discount_applies_to', PromoCodeDiscountAppliesToEnum::EACH_PRODUCT->name) + ), )); } catch (ResourceConflictException $e) { throw ValidationException::withMessages([ diff --git a/backend/app/Http/Actions/PromoCodes/GetPromoCodePublic.php b/backend/app/Http/Actions/PromoCodes/GetPromoCodePublic.php index 5649d6e3c4..c8b7763f57 100644 --- a/backend/app/Http/Actions/PromoCodes/GetPromoCodePublic.php +++ b/backend/app/Http/Actions/PromoCodes/GetPromoCodePublic.php @@ -18,14 +18,25 @@ public function __construct( public function __invoke(int $eventId, string $promoCode, Request $request): JsonResponse { - // intentionally not returning a 404 $promoCode = $this->promoCodeRepository->findFirstWhere([ PromoCodeDomainObjectAbstract::CODE => strtolower(trim($promoCode)), PromoCodeDomainObjectAbstract::EVENT_ID => $eventId, ]); + $isUsable = $this->promoCodeUsageValidationService->isPromoCodeUsable($promoCode); + + if (! $isUsable) { + return $this->jsonResponse([ + 'valid' => false, + ]); + } + return $this->jsonResponse([ - 'valid' => $this->promoCodeUsageValidationService->isPromoCodeUsable($promoCode), + 'valid' => true, + 'discount' => $promoCode->getDiscount(), + 'discount_type' => $promoCode->getDiscountType(), + 'discount_applies_to' => $promoCode->getDiscountAppliesTo(), + 'applies_to_all_products' => empty($promoCode->getApplicableProductIds()), ]); } } diff --git a/backend/app/Http/Actions/PromoCodes/UpdatePromoCodeAction.php b/backend/app/Http/Actions/PromoCodes/UpdatePromoCodeAction.php index d9ea210938..623321ed58 100644 --- a/backend/app/Http/Actions/PromoCodes/UpdatePromoCodeAction.php +++ b/backend/app/Http/Actions/PromoCodes/UpdatePromoCodeAction.php @@ -2,6 +2,7 @@ namespace HiEvents\Http\Actions\PromoCodes; +use HiEvents\DomainObjects\Enums\PromoCodeDiscountAppliesToEnum; use HiEvents\DomainObjects\Enums\PromoCodeDiscountTypeEnum; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\Exceptions\ResourceConflictException; @@ -40,6 +41,9 @@ public function __invoke(CreateUpdatePromoCodeRequest $request, int $eventId, in discount: $request->float('discount'), expiry_date: $request->input('expiry_date'), max_allowed_usages: $request->input('max_allowed_usages'), + discount_applies_to: $request->has('discount_applies_to') + ? PromoCodeDiscountAppliesToEnum::fromName($request->input('discount_applies_to')) + : null, )); } catch (ResourceConflictException $e) { throw ValidationException::withMessages([ diff --git a/backend/app/Http/Request/PromoCode/CreateUpdatePromoCodeRequest.php b/backend/app/Http/Request/PromoCode/CreateUpdatePromoCodeRequest.php index c16c10010a..90359eedcf 100644 --- a/backend/app/Http/Request/PromoCode/CreateUpdatePromoCodeRequest.php +++ b/backend/app/Http/Request/PromoCode/CreateUpdatePromoCodeRequest.php @@ -2,6 +2,7 @@ namespace HiEvents\Http\Request\PromoCode; +use HiEvents\DomainObjects\Enums\PromoCodeDiscountAppliesToEnum; use HiEvents\DomainObjects\Enums\PromoCodeDiscountTypeEnum; use HiEvents\Http\Request\BaseRequest; use Illuminate\Validation\Rule; @@ -29,6 +30,10 @@ function ($attribute, $value, $fail) { 'required', Rule::in(PromoCodeDiscountTypeEnum::valuesArray()), ], + 'discount_applies_to' => [ + 'sometimes', + Rule::in(PromoCodeDiscountAppliesToEnum::valuesArray()), + ], ]; } } diff --git a/backend/app/Models/PromoCode.php b/backend/app/Models/PromoCode.php index 6dcaa790f1..08ba375fb9 100644 --- a/backend/app/Models/PromoCode.php +++ b/backend/app/Models/PromoCode.php @@ -24,6 +24,7 @@ protected function getFillableFields(): array PromoCodeDomainObjectAbstract::CODE, PromoCodeDomainObjectAbstract::DISCOUNT, PromoCodeDomainObjectAbstract::DISCOUNT_TYPE, + PromoCodeDomainObjectAbstract::DISCOUNT_APPLIES_TO, PromoCodeDomainObjectAbstract::APPLICABLE_PRODUCT_IDS, PromoCodeDomainObjectAbstract::EXPIRY_DATE, PromoCodeDomainObjectAbstract::EVENT_ID, diff --git a/backend/app/Resources/Event/EventResourcePublic.php b/backend/app/Resources/Event/EventResourcePublic.php index b4c573dfc9..af10e71787 100644 --- a/backend/app/Resources/Event/EventResourcePublic.php +++ b/backend/app/Resources/Event/EventResourcePublic.php @@ -46,6 +46,8 @@ public function toArray(Request $request): array 'end_date' => $this->getEndDate(), 'next_occurrence_start_date' => $this->getNextOccurrenceStartDate(), 'upcoming_occurrences_sold_out' => $this->getUpcomingOccurrencesSoldOut(), + 'last_occurrence_date' => $this->when($isRecurring, fn () => $this->getLastOccurrenceStartDate()), + 'occurrences_month' => $this->when($isRecurring, fn () => $this->getOccurrencesMonth()), 'type' => $this->getType(), 'currency' => $this->getCurrency(), 'slug' => $this->getSlug(), diff --git a/backend/app/Resources/PromoCode/PromoCodeResource.php b/backend/app/Resources/PromoCode/PromoCodeResource.php index ec713886ad..b737d55aae 100644 --- a/backend/app/Resources/PromoCode/PromoCodeResource.php +++ b/backend/app/Resources/PromoCode/PromoCodeResource.php @@ -19,6 +19,7 @@ public function toArray(Request $request): array 'applicable_product_ids' => $this->getApplicableProductIds(), 'discount' => $this->getDiscount(), 'discount_type' => $this->getDiscountType(), + 'discount_applies_to' => $this->getDiscountAppliesTo(), 'created_at' => $this->getCreatedAt(), 'updated_at' => $this->getUpdatedAt(), 'expiry_date' => $this->getExpiryDate(), diff --git a/backend/app/Services/Application/Handlers/Event/DTO/PublicOccurrenceFetchResultDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/PublicOccurrenceFetchResultDTO.php new file mode 100644 index 0000000000..a68eb3e2e2 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Event/DTO/PublicOccurrenceFetchResultDTO.php @@ -0,0 +1,14 @@ +findById($data->eventId); - $hideSoldOutOccurrences = $event->getType() === EventType::RECURRING->name - && ($event->getEventSettings()?->getHideSoldOutOccurrences() ?? false) - && ! $this->eventHasWaitlistEnabledProducts($event); + $isRecurring = $event->getType() === EventType::RECURRING->name; + $hideSoldOutOccurrences = $this->occurrenceVisibilityService->shouldHideSoldOutOccurrences($event); + $occurrenceWhere = $this->occurrenceVisibilityService->buildWhereConditions( + eventId: $data->eventId, + isRecurring: $isRecurring, + hideSoldOutOccurrences: $hideSoldOutOccurrences, + ); + + $verifiedOccurrence = $this->resolveVerifiedOccurrence($data, $hideSoldOutOccurrences); + + if ($isRecurring) { + $this->setRecurringEventOccurrences($event, $data->eventId, $occurrenceWhere, $hideSoldOutOccurrences, $verifiedOccurrence); + } else { + $event->setEventOccurrences( + $this->fetchOccurrences($occurrenceWhere, $verifiedOccurrence)->occurrences + ); + } - $occurrenceWhere = [ - EventOccurrenceDomainObjectAbstract::EVENT_ID => $data->eventId, - [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name], - ]; + $promoCodeDomainObject = $this->promoCodeRepository->findFirstWhere([ + PromoCodeDomainObjectAbstract::EVENT_ID => $data->eventId, + PromoCodeDomainObjectAbstract::CODE => $data->promoCode, + ]); - if ($event->getType() === EventType::RECURRING->name) { - $occurrenceWhere[] = self::isNotEnded(); + if (! $promoCodeDomainObject?->isValid()) { + $promoCodeDomainObject = null; } - if ($hideSoldOutOccurrences) { - $occurrenceWhere[] = self::hasRemainingCapacity(); + if (! $data->isAuthenticated) { + $this->eventPageViewIncrementService->increment($data->eventId, $data->ipAddress); } - $occurrences = $this->occurrenceRepository - ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ - new Relationship(domainObject: LocationDomainObject::class, name: 'location'), - ])) - ->findWhere( - where: $occurrenceWhere, - orderAndDirections: [ - new OrderAndDirection(EventOccurrenceDomainObjectAbstract::START_DATE, 'asc'), - ], - limit: self::MAX_PUBLIC_OCCURRENCES + 1, - ); + return $event->setProductCategories($this->productFilterService->filter( + productsCategories: $event->getProductCategories(), + promoCode: $promoCodeDomainObject, + eventOccurrenceId: $verifiedOccurrence?->getId(), + )); + } - $verifiedOccurrence = null; - if ($data->eventOccurrenceId !== null) { - $verifiedOccurrence = $occurrences->first( - fn (EventOccurrenceDomainObject $o) => $o->getId() === $data->eventOccurrenceId - ); - if ($verifiedOccurrence === null) { - $fallbackWhere = [ - EventOccurrenceDomainObjectAbstract::ID => $data->eventOccurrenceId, - EventOccurrenceDomainObjectAbstract::EVENT_ID => $data->eventId, - [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name], - ]; - - if ($hideSoldOutOccurrences) { - $fallbackWhere[] = self::hasRemainingCapacity(); - } - - $verifiedOccurrence = $this->occurrenceRepository - ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ - new Relationship(domainObject: LocationDomainObject::class, name: 'location'), - ])) - ->findFirstWhere($fallbackWhere); - } - if ($verifiedOccurrence !== null && $verifiedOccurrence->isPast()) { - $verifiedOccurrence = null; - } + private function setRecurringEventOccurrences( + EventDomainObject $event, + int $eventId, + array $occurrenceWhere, + bool $hideSoldOutOccurrences, + ?EventOccurrenceDomainObject $verifiedOccurrence, + ): void { + $nextBookableWhere = $hideSoldOutOccurrences + ? $occurrenceWhere + : [...$occurrenceWhere, PublicOccurrenceVisibilityService::hasRemainingCapacity()]; + + $nextBookable = $this->findEdgeOccurrence($nextBookableWhere, 'asc'); + $event->setNextOccurrenceStartDate($nextBookable?->getStartDate()); + $event->setLastOccurrenceStartDate($this->findEdgeOccurrence($occurrenceWhere, 'desc')?->getStartDate()); + + $anchorOccurrence = $verifiedOccurrence ?? $nextBookable; + if ($anchorOccurrence === null && ! $hideSoldOutOccurrences) { + $anchorOccurrence = $this->findEdgeOccurrence($occurrenceWhere, 'asc'); } - $verifiedOccurrenceId = $verifiedOccurrence?->getId(); + $timezone = $event->getTimezone() ?: 'UTC'; + $anchorMonthStart = Carbon::parse($anchorOccurrence?->getStartDate() ?? now(), 'UTC') + ->setTimezone($timezone) + ->startOfMonth(); - if ($occurrences->count() > self::MAX_PUBLIC_OCCURRENCES) { - $occurrences = $occurrences->take(self::MAX_PUBLIC_OCCURRENCES)->values(); - } + $monthWhere = [ + ...$occurrenceWhere, + [EventOccurrenceDomainObjectAbstract::START_DATE, '>=', $anchorMonthStart->copy()->utc()->toDateTimeString()], + [EventOccurrenceDomainObjectAbstract::START_DATE, '<=', $anchorMonthStart->copy()->endOfMonth()->utc()->toDateTimeString()], + ]; - if ($verifiedOccurrence !== null - && ! $occurrences->contains(fn (EventOccurrenceDomainObject $o) => $o->getId() === $verifiedOccurrenceId)) { - $occurrences->push($verifiedOccurrence); - } + $result = $this->fetchOccurrences($monthWhere, $verifiedOccurrence); - $event->setEventOccurrences($occurrences); + $event->setEventOccurrences($result->occurrences); + $event->setOccurrencesMonth($result->truncated ? null : $anchorMonthStart->format('Y-m')); - if ($hideSoldOutOccurrences && $occurrences->isEmpty()) { + if ($hideSoldOutOccurrences && $nextBookable === null) { $event->setUpcomingOccurrencesSoldOut( $this->occurrenceRepository->findFirstWhere([ - EventOccurrenceDomainObjectAbstract::EVENT_ID => $data->eventId, + EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId, [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name], - self::isNotEnded(), + PublicOccurrenceVisibilityService::isNotEnded(), static function ($query): void { $query->whereColumn( EventOccurrenceDomainObjectAbstract::USED_CAPACITY, @@ -153,59 +161,74 @@ static function ($query): void { ]) !== null ); } + } - $promoCodeDomainObject = $this->promoCodeRepository->findFirstWhere([ - PromoCodeDomainObjectAbstract::EVENT_ID => $data->eventId, - PromoCodeDomainObjectAbstract::CODE => $data->promoCode, - ]); + private function fetchOccurrences(array $where, ?EventOccurrenceDomainObject $verifiedOccurrence): PublicOccurrenceFetchResultDTO + { + $occurrences = $this->occurrenceRepository + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ])) + ->findWhere( + where: $where, + orderAndDirections: [ + new OrderAndDirection(EventOccurrenceDomainObjectAbstract::START_DATE, 'asc'), + ], + limit: self::MAX_PUBLIC_OCCURRENCES + 1, + ); - if (! $promoCodeDomainObject?->isValid()) { - $promoCodeDomainObject = null; + $truncated = $occurrences->count() > self::MAX_PUBLIC_OCCURRENCES; + if ($truncated) { + $occurrences = $occurrences->take(self::MAX_PUBLIC_OCCURRENCES)->values(); } - if (! $data->isAuthenticated) { - $this->eventPageViewIncrementService->increment($data->eventId, $data->ipAddress); + if ($verifiedOccurrence !== null + && ! $occurrences->contains(fn (EventOccurrenceDomainObject $o) => $o->getId() === $verifiedOccurrence->getId())) { + $occurrences->push($verifiedOccurrence); } - return $event->setProductCategories($this->productFilterService->filter( - productsCategories: $event->getProductCategories(), - promoCode: $promoCodeDomainObject, - eventOccurrenceId: $verifiedOccurrenceId, - )); + return new PublicOccurrenceFetchResultDTO($occurrences, $truncated); } - private function eventHasWaitlistEnabledProducts(EventDomainObject $event): bool + private function findEdgeOccurrence(array $where, string $direction): ?EventOccurrenceDomainObject { - return $event->getProductCategories() - ?->contains( - fn (ProductCategoryDomainObject $category) => $category->getProducts() - ?->contains(fn (ProductDomainObject $product) => $product->getWaitlistEnabled() === true) ?? false - ) ?? false; + return $this->occurrenceRepository + ->findWhere( + where: $where, + orderAndDirections: [ + new OrderAndDirection(EventOccurrenceDomainObjectAbstract::START_DATE, $direction), + ], + limit: 1, + ) + ->first(); } - private static function hasRemainingCapacity(): Closure + private function resolveVerifiedOccurrence(GetPublicEventDTO $data, bool $hideSoldOutOccurrences): ?EventOccurrenceDomainObject { - return static function ($query): void { - $query->whereNull(EventOccurrenceDomainObjectAbstract::CAPACITY) - ->orWhereColumn( - EventOccurrenceDomainObjectAbstract::USED_CAPACITY, - '<', - EventOccurrenceDomainObjectAbstract::CAPACITY, - ); - }; - } + if ($data->eventOccurrenceId === null) { + return null; + } - private static function isNotEnded(): Closure - { - return static function ($query): void { - $query->whereRaw( - sprintf( - 'COALESCE(%s, %s) >= ?', - EventOccurrenceDomainObjectAbstract::END_DATE, - EventOccurrenceDomainObjectAbstract::START_DATE, - ), - [now()->toDateTimeString()], - ); - }; + $where = [ + EventOccurrenceDomainObjectAbstract::ID => $data->eventOccurrenceId, + EventOccurrenceDomainObjectAbstract::EVENT_ID => $data->eventId, + [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name], + ]; + + if ($hideSoldOutOccurrences) { + $where[] = PublicOccurrenceVisibilityService::hasRemainingCapacity(); + } + + $verifiedOccurrence = $this->occurrenceRepository + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ])) + ->findFirstWhere($where); + + if ($verifiedOccurrence !== null && $verifiedOccurrence->isPast()) { + return null; + } + + return $verifiedOccurrence; } } diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/DTO/GetPublicEventOccurrencesDTO.php b/backend/app/Services/Application/Handlers/EventOccurrence/DTO/GetPublicEventOccurrencesDTO.php new file mode 100644 index 0000000000..a335805455 --- /dev/null +++ b/backend/app/Services/Application/Handlers/EventOccurrence/DTO/GetPublicEventOccurrencesDTO.php @@ -0,0 +1,14 @@ +validateRange($dto); + + $event = $this->eventRepository + ->loadRelation(new Relationship(ProductCategoryDomainObject::class, [ + new Relationship(ProductDomainObject::class), + ])) + ->loadRelation(new Relationship(EventSettingDomainObject::class)) + ->findById($dto->eventId); + + $where = $this->occurrenceVisibilityService->buildWhereConditions( + eventId: $dto->eventId, + isRecurring: $event->getType() === EventType::RECURRING->name, + hideSoldOutOccurrences: $this->occurrenceVisibilityService->shouldHideSoldOutOccurrences($event), + ); + + $where[] = [EventOccurrenceDomainObjectAbstract::START_DATE, '>=', $startDateFrom->toDateTimeString()]; + $where[] = [EventOccurrenceDomainObjectAbstract::START_DATE, '<=', $startDateTo->toDateTimeString()]; + + $occurrences = $this->occurrenceRepository + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ])) + ->findWhere( + where: $where, + orderAndDirections: [ + new OrderAndDirection(EventOccurrenceDomainObjectAbstract::START_DATE, 'asc'), + ], + limit: GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES, + ); + + return new GetPublicEventOccurrencesResultDTO( + event: $event, + occurrences: $occurrences, + ); + } + + /** + * @return array{0: Carbon, 1: Carbon} + * + * @throws InvalidOccurrenceDatesException + */ + private function validateRange(GetPublicEventOccurrencesDTO $dto): array + { + if ($dto->startDateFrom === null || $dto->startDateTo === null) { + throw new InvalidOccurrenceDatesException( + __('Both start_date_from and start_date_to are required.') + ); + } + + try { + $startDateFrom = Carbon::parse($dto->startDateFrom, 'UTC'); + $startDateTo = Carbon::parse($dto->startDateTo, 'UTC'); + } catch (InvalidFormatException) { + throw new InvalidOccurrenceDatesException( + __('The date range is invalid.') + ); + } + + if ($startDateFrom->greaterThan($startDateTo) + || $startDateFrom->diffInDays($startDateTo) > self::MAX_RANGE_DAYS) { + throw new InvalidOccurrenceDatesException( + __('The date range must be valid and span at most :days days.', ['days' => self::MAX_RANGE_DAYS]) + ); + } + + return [$startDateFrom, $startDateTo]; + } +} diff --git a/backend/app/Services/Application/Handlers/PromoCode/CreatePromoCodeHandler.php b/backend/app/Services/Application/Handlers/PromoCode/CreatePromoCodeHandler.php index ba8eac266d..0c9c770db3 100644 --- a/backend/app/Services/Application/Handlers/PromoCode/CreatePromoCodeHandler.php +++ b/backend/app/Services/Application/Handlers/PromoCode/CreatePromoCodeHandler.php @@ -2,6 +2,7 @@ namespace HiEvents\Services\Application\Handlers\PromoCode; +use HiEvents\DomainObjects\Enums\PromoCodeDiscountAppliesToEnum; use HiEvents\DomainObjects\PromoCodeDomainObject; use HiEvents\Exceptions\ResourceConflictException; use HiEvents\Services\Application\Handlers\PromoCode\DTO\UpsertPromoCodeDTO; @@ -29,6 +30,7 @@ public function handle(int $eventId, UpsertPromoCodeDTO $promoCodeDTO): PromoCod ->setExpiryDate($promoCodeDTO->expiry_date) ->setMaxAllowedUsages($promoCodeDTO->max_allowed_usages) ->setApplicableProductIds($promoCodeDTO->applicable_product_ids) + ->setDiscountAppliesTo(($promoCodeDTO->discount_applies_to ?? PromoCodeDiscountAppliesToEnum::EACH_PRODUCT)->name) ); } } diff --git a/backend/app/Services/Application/Handlers/PromoCode/DTO/UpsertPromoCodeDTO.php b/backend/app/Services/Application/Handlers/PromoCode/DTO/UpsertPromoCodeDTO.php index dd27227d27..2fec96de76 100644 --- a/backend/app/Services/Application/Handlers/PromoCode/DTO/UpsertPromoCodeDTO.php +++ b/backend/app/Services/Application/Handlers/PromoCode/DTO/UpsertPromoCodeDTO.php @@ -2,6 +2,7 @@ namespace HiEvents\Services\Application\Handlers\PromoCode\DTO; +use HiEvents\DomainObjects\Enums\PromoCodeDiscountAppliesToEnum; use HiEvents\DomainObjects\Enums\PromoCodeDiscountTypeEnum; class UpsertPromoCodeDTO @@ -14,5 +15,6 @@ public function __construct( public readonly ?float $discount, public readonly ?string $expiry_date, public readonly ?int $max_allowed_usages, + public readonly ?PromoCodeDiscountAppliesToEnum $discount_applies_to, ) {} } diff --git a/backend/app/Services/Application/Handlers/PromoCode/UpdatePromoCodeHandler.php b/backend/app/Services/Application/Handlers/PromoCode/UpdatePromoCodeHandler.php index c58756930b..e0cb0179a6 100644 --- a/backend/app/Services/Application/Handlers/PromoCode/UpdatePromoCodeHandler.php +++ b/backend/app/Services/Application/Handlers/PromoCode/UpdatePromoCodeHandler.php @@ -62,6 +62,8 @@ public function handle(int $promoCodeId, UpsertPromoCodeDTO $promoCodeDTO): Prom ? 0.00 : (float) $promoCodeDTO->discount, PromoCodeDomainObjectAbstract::DISCOUNT_TYPE => $promoCodeDTO->discount_type?->name, + PromoCodeDomainObjectAbstract::DISCOUNT_APPLIES_TO => $promoCodeDTO->discount_applies_to?->name + ?? $promoCode->getDiscountAppliesTo(), PromoCodeDomainObjectAbstract::EXPIRY_DATE => $promoCodeDTO->expiry_date ? DateHelper::convertToUTC($promoCodeDTO->expiry_date, $event->getTimezone()) : null, diff --git a/backend/app/Services/Domain/Event/DuplicateEventService.php b/backend/app/Services/Domain/Event/DuplicateEventService.php index e22d198e80..d840e26f4c 100644 --- a/backend/app/Services/Domain/Event/DuplicateEventService.php +++ b/backend/app/Services/Domain/Event/DuplicateEventService.php @@ -370,7 +370,8 @@ private function clonePromoCodes(EventDomainObject $event, int $newEventId, arra ->setDiscountType($promoCode->getDiscountType()) ->setDiscount($promoCode->getDiscount()) ->setExpiryDate($promoCode->getExpiryDate()) - ->setMaxAllowedUsages($promoCode->getMaxAllowedUsages()), + ->setMaxAllowedUsages($promoCode->getMaxAllowedUsages()) + ->setDiscountAppliesTo($promoCode->getDiscountAppliesTo()), ); } } diff --git a/backend/app/Services/Domain/EventOccurrence/PublicOccurrenceVisibilityService.php b/backend/app/Services/Domain/EventOccurrence/PublicOccurrenceVisibilityService.php new file mode 100644 index 0000000000..bdbfce0b68 --- /dev/null +++ b/backend/app/Services/Domain/EventOccurrence/PublicOccurrenceVisibilityService.php @@ -0,0 +1,76 @@ +getType() === EventType::RECURRING->name + && ($event->getEventSettings()?->getHideSoldOutOccurrences() ?? false) + && ! $this->eventHasWaitlistEnabledProducts($event); + } + + public function buildWhereConditions(int $eventId, bool $isRecurring, bool $hideSoldOutOccurrences): array + { + $where = [ + EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId, + [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name], + ]; + + if ($isRecurring) { + $where[] = self::isNotEnded(); + } + + if ($hideSoldOutOccurrences) { + $where[] = self::hasRemainingCapacity(); + } + + return $where; + } + + public static function hasRemainingCapacity(): Closure + { + return static function ($query): void { + $query->whereNull(EventOccurrenceDomainObjectAbstract::CAPACITY) + ->orWhereColumn( + EventOccurrenceDomainObjectAbstract::USED_CAPACITY, + '<', + EventOccurrenceDomainObjectAbstract::CAPACITY, + ); + }; + } + + public static function isNotEnded(): Closure + { + return static function ($query): void { + $query->whereRaw( + sprintf( + 'COALESCE(%s, %s) >= ?', + EventOccurrenceDomainObjectAbstract::END_DATE, + EventOccurrenceDomainObjectAbstract::START_DATE, + ), + [now()->toDateTimeString()], + ); + }; + } + + private function eventHasWaitlistEnabledProducts(EventDomainObject $event): bool + { + return $event->getProductCategories() + ?->contains( + fn (ProductCategoryDomainObject $category) => $category->getProducts() + ?->contains(fn (ProductDomainObject $product) => $product->getWaitlistEnabled() === true) ?? false + ) ?? false; + } +} diff --git a/backend/app/Services/Domain/Order/DTO/OrderItemPricingLineDTO.php b/backend/app/Services/Domain/Order/DTO/OrderItemPricingLineDTO.php new file mode 100644 index 0000000000..203eb50e8d --- /dev/null +++ b/backend/app/Services/Domain/Order/DTO/OrderItemPricingLineDTO.php @@ -0,0 +1,18 @@ + $lines + * @return array> + */ + public function allocate(Collection $lines, PromoCodeDomainObject $promoCode, string $currency): array + { + $multiplier = Currency::isZeroDecimalCurrency($currency) ? 1 : 100; + + $eligibleLines = []; + foreach ($lines as $index => $line) { + if ($this->isEligible($line, $promoCode)) { + $eligibleLines[$index] = [ + 'unitMinor' => (int) round($line->prices->price * $multiplier), + 'quantity' => $line->product_price->quantity, + ]; + } + } + + $subtotalMinor = 0; + foreach ($eligibleLines as $eligibleLine) { + $subtotalMinor += $eligibleLine['unitMinor'] * $eligibleLine['quantity']; + } + + if ($subtotalMinor === 0) { + return $this->toAllocations($lines, [], [], $multiplier); + } + + $targetMinor = min((int) round($promoCode->getDiscount() * $multiplier), $subtotalMinor); + + $perUnitMinor = []; + $remainingMinor = $targetMinor; + foreach ($eligibleLines as $index => $eligibleLine) { + $perUnitMinor[$index] = intdiv($targetMinor * $eligibleLine['unitMinor'], $subtotalMinor); + $remainingMinor -= $perUnitMinor[$index] * $eligibleLine['quantity']; + } + + $splitUnits = []; + while ($remainingMinor > 0) { + $index = $this->nextLineToIncrement($eligibleLines, $perUnitMinor, $remainingMinor, $targetMinor, $subtotalMinor); + + if ($index === null) { + $index = $this->lineToSplit($eligibleLines, $perUnitMinor); + $splitUnits[$index] = $remainingMinor; + break; + } + + $perUnitMinor[$index]++; + $remainingMinor -= $eligibleLines[$index]['quantity']; + } + + return $this->toAllocations($lines, $perUnitMinor, $splitUnits, $multiplier); + } + + private function isEligible(OrderItemPricingLineDTO $line, PromoCodeDomainObject $promoCode): bool + { + return $promoCode->appliesToProduct($line->product) + && ! $line->product->isFreeType() + && ! $line->product->isDonationType() + && $line->prices->price > 0; + } + + /** + * @param array $eligibleLines + * @param array $perUnitMinor + */ + private function nextLineToIncrement( + array $eligibleLines, + array $perUnitMinor, + int $remainingMinor, + int $targetMinor, + int $subtotalMinor, + ): ?int { + $bestIndex = null; + $bestFraction = -1; + + foreach ($eligibleLines as $index => $eligibleLine) { + if ($perUnitMinor[$index] >= $eligibleLine['unitMinor'] || $eligibleLine['quantity'] > $remainingMinor) { + continue; + } + + $fraction = ($targetMinor * $eligibleLine['unitMinor']) % $subtotalMinor; + + if ($fraction > $bestFraction) { + $bestFraction = $fraction; + $bestIndex = $index; + } + } + + return $bestIndex; + } + + /** + * @param array $eligibleLines + * @param array $perUnitMinor + */ + private function lineToSplit(array $eligibleLines, array $perUnitMinor): int + { + $splitIndex = null; + $smallestQuantity = null; + + foreach ($eligibleLines as $index => $eligibleLine) { + if ($perUnitMinor[$index] >= $eligibleLine['unitMinor']) { + continue; + } + + if ($smallestQuantity === null || $eligibleLine['quantity'] < $smallestQuantity) { + $smallestQuantity = $eligibleLine['quantity']; + $splitIndex = $index; + } + } + + return $splitIndex; + } + + /** + * @param Collection $lines + * @param array $perUnitMinor + * @param array $splitUnits + * @return array> + */ + private function toAllocations(Collection $lines, array $perUnitMinor, array $splitUnits, int $multiplier): array + { + $allocations = []; + + foreach ($lines as $index => $line) { + $quantity = $line->product_price->quantity; + $unitMinorDiscount = $perUnitMinor[$index] ?? 0; + + if (isset($splitUnits[$index])) { + $allocations[$index] = [ + new OrderLineDiscountAllocationDTO( + per_unit_discount: ($unitMinorDiscount + 1) / $multiplier, + quantity: $splitUnits[$index], + ), + new OrderLineDiscountAllocationDTO( + per_unit_discount: $unitMinorDiscount / $multiplier, + quantity: $quantity - $splitUnits[$index], + ), + ]; + + continue; + } + + $allocations[$index] = [ + new OrderLineDiscountAllocationDTO(per_unit_discount: $unitMinorDiscount / $multiplier, quantity: $quantity), + ]; + } + + return $allocations; + } +} diff --git a/backend/app/Services/Domain/Order/OrderItemProcessingService.php b/backend/app/Services/Domain/Order/OrderItemProcessingService.php index 8f3877f488..3401d1a6da 100644 --- a/backend/app/Services/Domain/Order/OrderItemProcessingService.php +++ b/backend/app/Services/Domain/Order/OrderItemProcessingService.php @@ -19,7 +19,10 @@ use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Services\Application\Handlers\Order\DTO\ProductOrderDetailsDTO; +use HiEvents\Services\Domain\Order\DTO\OrderItemPricingLineDTO; +use HiEvents\Services\Domain\Order\DTO\OrderLineDiscountAllocationDTO; use HiEvents\Services\Domain\Product\DTO\OrderProductPriceDTO; +use HiEvents\Services\Domain\Product\DTO\PriceDTO; use HiEvents\Services\Domain\Product\ProductPriceService; use HiEvents\Services\Domain\Tax\TaxAndFeeCalculationService; use Illuminate\Support\Collection; @@ -38,6 +41,7 @@ public function __construct( private readonly ProductPriceService $productPriceService, private readonly OrderPlatformFeePassThroughService $platformFeeService, private readonly EventRepositoryInterface $eventRepository, + private readonly OrderDiscountAllocationService $orderDiscountAllocationService, ) {} /** @@ -51,7 +55,29 @@ public function process( ): Collection { $this->loadPlatformFeeConfiguration($event->getId()); - $orderItems = collect(); + $pricingLines = $this->buildPricingLines($productsOrderDetails, $event, $promoCode); + + if ($promoCode?->isOrderLevelDiscount()) { + $pricingLines = $this->applyOrderLevelDiscount($pricingLines, $promoCode, $event->getCurrency()); + } + + return $pricingLines->map(function (OrderItemPricingLineDTO $line) use ($order, $event) { + return $this->orderRepository->addOrderItem( + $this->calculateOrderItemData($line, $order, $event->getCurrency()) + ); + }); + } + + /** + * @param Collection $productsOrderDetails + * @return Collection + */ + private function buildPricingLines( + Collection $productsOrderDetails, + EventDomainObject $event, + ?PromoCodeDomainObject $promoCode, + ): Collection { + $pricingLines = collect(); foreach ($productsOrderDetails as $productOrderDetail) { $product = $this->productRepository @@ -70,16 +96,55 @@ public function process( $eventOccurrenceId = $productOrderDetail->event_occurrence_id; - $productOrderDetail->quantities->each(function (OrderProductPriceDTO $productPrice) use ($promoCode, $order, $orderItems, $product, $event, $eventOccurrenceId) { + $productOrderDetail->quantities->each(function (OrderProductPriceDTO $productPrice) use ($pricingLines, $promoCode, $product, $eventOccurrenceId) { if ($productPrice->quantity === 0) { return; } - $orderItemData = $this->calculateOrderItemData($product, $productPrice, $order, $promoCode, $event->getCurrency(), $eventOccurrenceId); - $orderItems->push($this->orderRepository->addOrderItem($orderItemData)); + $pricingLines->push(new OrderItemPricingLineDTO( + product: $product, + product_price: $productPrice, + prices: $this->productPriceService->getPrice($product, $productPrice, $promoCode, $eventOccurrenceId), + event_occurrence_id: $eventOccurrenceId, + )); }); } - return $orderItems; + return $pricingLines; + } + + /** + * @param Collection $pricingLines + * @return Collection + */ + private function applyOrderLevelDiscount(Collection $pricingLines, PromoCodeDomainObject $promoCode, string $currency): Collection + { + $allocations = $this->orderDiscountAllocationService->allocate($pricingLines, $promoCode, $currency); + + return $pricingLines + ->flatMap(static function (OrderItemPricingLineDTO $line, int $index) use ($allocations) { + return collect($allocations[$index])->map(static function (OrderLineDiscountAllocationDTO $allocation) use ($line) { + if ($allocation->per_unit_discount <= 0 && $allocation->quantity === $line->product_price->quantity) { + return $line; + } + + return new OrderItemPricingLineDTO( + product: $line->product, + product_price: new OrderProductPriceDTO( + quantity: $allocation->quantity, + price_id: $line->product_price->price_id, + price: $line->product_price->price, + ), + prices: $allocation->per_unit_discount > 0 + ? new PriceDTO( + price: Currency::round($line->prices->price - $allocation->per_unit_discount), + price_before_discount: $line->prices->price, + ) + : $line->prices, + event_occurrence_id: $line->event_occurrence_id, + ); + }); + }) + ->values(); } private function loadPlatformFeeConfiguration(int $eventId): void @@ -103,16 +168,15 @@ private function loadPlatformFeeConfiguration(int $eventId): void } private function calculateOrderItemData( - ProductDomainObject $product, - OrderProductPriceDTO $productPriceDetails, + OrderItemPricingLineDTO $line, OrderDomainObject $order, - ?PromoCodeDomainObject $promoCode, string $currency, - ?int $eventOccurrenceId = null, ): array { - $prices = $this->productPriceService->getPrice($product, $productPriceDetails, $promoCode, $eventOccurrenceId); - $priceWithDiscount = $prices->price; - $priceBeforeDiscount = $prices->price_before_discount; + $product = $line->product; + $productPriceDetails = $line->product_price; + $eventOccurrenceId = $line->event_occurrence_id; + $priceWithDiscount = $line->prices->price; + $priceBeforeDiscount = $line->prices->price_before_discount; $itemTotalWithDiscount = $priceWithDiscount * $productPriceDetails->quantity; diff --git a/backend/app/Services/Domain/Product/ProductFilterService.php b/backend/app/Services/Domain/Product/ProductFilterService.php index 14d8015ce4..fe029663c0 100644 --- a/backend/app/Services/Domain/Product/ProductFilterService.php +++ b/backend/app/Services/Domain/Product/ProductFilterService.php @@ -142,6 +142,10 @@ private function shouldProductBeDiscounted(?PromoCodeDomainObject $promoCode, Pr return false; } + if ($promoCode?->isOrderLevelDiscount()) { + return false; + } + return $promoCode && $promoCode->isDiscountCode() && $promoCode->appliesToProduct($product); diff --git a/backend/app/Services/Domain/Product/ProductPriceService.php b/backend/app/Services/Domain/Product/ProductPriceService.php index c1524fa317..6b8a8f84dc 100644 --- a/backend/app/Services/Domain/Product/ProductPriceService.php +++ b/backend/app/Services/Domain/Product/ProductPriceService.php @@ -54,6 +54,10 @@ public function getPrice( return new PriceDTO($price); } + if ($promoCode->isOrderLevelDiscount()) { + return new PriceDTO($price); + } + if ($promoCode->isFixedDiscount()) { $discountPrice = Currency::round($price - $promoCode->getDiscount()); } elseif ($promoCode->isPercentageDiscount()) { diff --git a/backend/app/Services/Domain/PromoCode/CreatePromoCodeService.php b/backend/app/Services/Domain/PromoCode/CreatePromoCodeService.php index cd6570b986..c1a24f5c97 100644 --- a/backend/app/Services/Domain/PromoCode/CreatePromoCodeService.php +++ b/backend/app/Services/Domain/PromoCode/CreatePromoCodeService.php @@ -44,6 +44,7 @@ public function createPromoCode(PromoCodeDomainObject $promoCode): PromoCodeDoma ? 0.00 : $promoCode->getDiscount(), PromoCodeDomainObjectAbstract::DISCOUNT_TYPE => $promoCode->getDiscountType(), + PromoCodeDomainObjectAbstract::DISCOUNT_APPLIES_TO => $promoCode->getDiscountAppliesTo(), PromoCodeDomainObjectAbstract::EXPIRY_DATE => $promoCode->getExpiryDate() ? DateHelper::convertToUTC($promoCode->getExpiryDate(), $event->getTimezone()) : null, diff --git a/backend/database/migrations/2026_07_26_000000_add_discount_applies_to_to_promo_codes_table.php b/backend/database/migrations/2026_07_26_000000_add_discount_applies_to_to_promo_codes_table.php new file mode 100644 index 0000000000..1ff105e3a2 --- /dev/null +++ b/backend/database/migrations/2026_07_26_000000_add_discount_applies_to_to_promo_codes_table.php @@ -0,0 +1,24 @@ +string('discount_applies_to') + ->default(PromoCodeDiscountAppliesToEnum::EACH_PRODUCT->name); + }); + } + + public function down(): void + { + Schema::table('promo_codes', static function (Blueprint $table) { + $table->dropColumn('discount_applies_to'); + }); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index 4e4a8b0260..660d1a015d 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -93,6 +93,7 @@ use HiEvents\Http\Actions\EventOccurrences\GenerateOccurrencesAction; use HiEvents\Http\Actions\EventOccurrences\GetEventOccurrenceAction; use HiEvents\Http\Actions\EventOccurrences\GetEventOccurrencesAction; +use HiEvents\Http\Actions\EventOccurrences\GetEventOccurrencesPublicAction; use HiEvents\Http\Actions\EventOccurrences\GetPriceOverridesAction; use HiEvents\Http\Actions\EventOccurrences\GetProductVisibilityAction; use HiEvents\Http\Actions\EventOccurrences\ReactivateOccurrenceAction; @@ -555,6 +556,8 @@ function (Router $router): void { function (Router $router): void { // Events $router->get('/events/{event_id}', GetEventPublicAction::class); + $router->get('/events/{event_id}/occurrences', GetEventOccurrencesPublicAction::class) + ->middleware('throttle:60,1'); // Organizers $router->get('/organizers/{organizer_id}', GetPublicOrganizerAction::class); diff --git a/backend/tests/Unit/DomainObjects/EventDomainObjectTest.php b/backend/tests/Unit/DomainObjects/EventDomainObjectTest.php index 229a1d3f10..55229570b5 100644 --- a/backend/tests/Unit/DomainObjects/EventDomainObjectTest.php +++ b/backend/tests/Unit/DomainObjects/EventDomainObjectTest.php @@ -110,79 +110,61 @@ public function test_get_end_date_returns_null_when_no_occurrences(): void $this->assertNull($eventWithEmpty->getEndDate()); } - public function test_is_event_in_past_returns_true_when_all_occurrences_are_past(): void + public function test_is_event_ongoing_returns_true_when_active_occurrence_has_started_but_not_ended(): void { $occurrences = collect([ $this->createOccurrence( - Carbon::now()->subDays(3)->toDateTimeString(), - Carbon::now()->subDays(2)->toDateTimeString(), - ), - $this->createOccurrence( - Carbon::now()->subDays(2)->toDateTimeString(), - Carbon::now()->subDay()->toDateTimeString(), + Carbon::now()->subHour()->toDateTimeString(), + Carbon::now()->addHour()->toDateTimeString(), + EventOccurrenceStatus::ACTIVE->name, ), ]); $event = $this->createEvent($occurrences); - $this->assertTrue($event->isEventInPast()); + $this->assertTrue($event->isEventOngoing()); } - public function test_is_event_in_past_returns_false_when_some_occurrences_are_future(): void + public function test_is_event_ongoing_returns_false_for_cancelled_occurrences(): void { $occurrences = collect([ $this->createOccurrence( - Carbon::now()->subDays(2)->toDateTimeString(), - Carbon::now()->subDay()->toDateTimeString(), - ), - $this->createOccurrence( - Carbon::now()->addDay()->toDateTimeString(), - Carbon::now()->addDays(2)->toDateTimeString(), + Carbon::now()->subHour()->toDateTimeString(), + Carbon::now()->addHour()->toDateTimeString(), + EventOccurrenceStatus::CANCELLED->name, ), ]); $event = $this->createEvent($occurrences); - $this->assertFalse($event->isEventInPast()); + $this->assertFalse($event->isEventOngoing()); } - public function test_is_event_in_future_returns_true_when_earliest_start_is_future(): void + public function test_is_event_ongoing_returns_false_when_started_occurrence_has_no_end_date(): void { $occurrences = collect([ $this->createOccurrence( - Carbon::now()->addDay()->toDateTimeString(), - Carbon::now()->addDays(2)->toDateTimeString(), - ), - $this->createOccurrence( - Carbon::now()->addDays(3)->toDateTimeString(), - Carbon::now()->addDays(4)->toDateTimeString(), + Carbon::now()->subHour()->toDateTimeString(), + null, + EventOccurrenceStatus::ACTIVE->name, ), ]); $event = $this->createEvent($occurrences); - $this->assertTrue($event->isEventInFuture()); + $this->assertFalse($event->isEventOngoing()); } - public function test_is_event_in_future_returns_false_when_earliest_start_is_past(): void + public function test_is_event_ongoing_returns_false_when_no_occurrences(): void { - $occurrences = collect([ - $this->createOccurrence( - Carbon::now()->subDay()->toDateTimeString(), - Carbon::now()->addDay()->toDateTimeString(), - ), - $this->createOccurrence( - Carbon::now()->addDays(2)->toDateTimeString(), - Carbon::now()->addDays(3)->toDateTimeString(), - ), - ]); - - $event = $this->createEvent($occurrences); + $event = $this->createEvent(); + $this->assertFalse($event->isEventOngoing()); - $this->assertFalse($event->isEventInFuture()); + $eventWithEmpty = $this->createEvent(collect([])); + $this->assertFalse($eventWithEmpty->isEventOngoing()); } - public function test_is_event_ongoing_returns_true_when_active_occurrence_has_started_but_not_ended(): void + public function test_get_lifecycle_status_returns_ongoing_when_ongoing(): void { $occurrences = collect([ $this->createOccurrence( @@ -194,96 +176,112 @@ public function test_is_event_ongoing_returns_true_when_active_occurrence_has_st $event = $this->createEvent($occurrences); - $this->assertTrue($event->isEventOngoing()); + $this->assertEquals(EventLifecycleStatus::ONGOING->name, $event->getLifecycleStatus()); } - public function test_is_event_ongoing_returns_false_for_cancelled_occurrences(): void + public function test_get_lifecycle_status_returns_upcoming_when_all_future(): void { $occurrences = collect([ $this->createOccurrence( - Carbon::now()->subHour()->toDateTimeString(), - Carbon::now()->addHour()->toDateTimeString(), - EventOccurrenceStatus::CANCELLED->name, + Carbon::now()->addDay()->toDateTimeString(), + Carbon::now()->addDays(2)->toDateTimeString(), ), ]); $event = $this->createEvent($occurrences); - $this->assertFalse($event->isEventOngoing()); + $this->assertEquals(EventLifecycleStatus::UPCOMING->name, $event->getLifecycleStatus()); } - public function test_is_event_ongoing_returns_true_when_active_occurrence_has_no_end_date(): void + public function test_get_lifecycle_status_returns_ended_when_all_past(): void { $occurrences = collect([ $this->createOccurrence( - Carbon::now()->subHour()->toDateTimeString(), - null, - EventOccurrenceStatus::ACTIVE->name, + Carbon::now()->subDays(3)->toDateTimeString(), + Carbon::now()->subDay()->toDateTimeString(), ), ]); $event = $this->createEvent($occurrences); - $this->assertTrue($event->isEventOngoing()); + $this->assertEquals(EventLifecycleStatus::ENDED->name, $event->getLifecycleStatus()); } - public function test_is_event_ongoing_returns_false_when_no_occurrences(): void + public function test_get_lifecycle_status_returns_upcoming_when_no_occurrences(): void { - $event = $this->createEvent(); - $this->assertFalse($event->isEventOngoing()); + $event = $this->createEvent(collect()); - $eventWithEmpty = $this->createEvent(collect([])); - $this->assertFalse($eventWithEmpty->isEventOngoing()); + $this->assertEquals(EventLifecycleStatus::UPCOMING->name, $event->getLifecycleStatus()); } - public function test_get_lifecycle_status_returns_ongoing_when_ongoing(): void + public function test_get_lifecycle_status_returns_upcoming_for_recurring_event_mid_series(): void { $occurrences = collect([ $this->createOccurrence( - Carbon::now()->subHour()->toDateTimeString(), - Carbon::now()->addHour()->toDateTimeString(), - EventOccurrenceStatus::ACTIVE->name, + Carbon::now()->subDays(3)->toDateTimeString(), + Carbon::now()->subDays(3)->addHours(2)->toDateTimeString(), + ), + $this->createOccurrence( + Carbon::now()->addDays(3)->toDateTimeString(), + Carbon::now()->addDays(3)->addHours(2)->toDateTimeString(), ), ]); $event = $this->createEvent($occurrences); - $this->assertEquals(EventLifecycleStatus::ONGOING->name, $event->getLifecycleStatus()); + $this->assertEquals(EventLifecycleStatus::UPCOMING->name, $event->getLifecycleStatus()); } - public function test_get_lifecycle_status_returns_upcoming_when_all_future(): void + public function test_get_lifecycle_status_returns_ongoing_when_one_occurrence_in_series_is_live(): void { $occurrences = collect([ $this->createOccurrence( - Carbon::now()->addDay()->toDateTimeString(), - Carbon::now()->addDays(2)->toDateTimeString(), + Carbon::now()->subDays(3)->toDateTimeString(), + Carbon::now()->subDays(3)->addHours(2)->toDateTimeString(), + ), + $this->createOccurrence( + Carbon::now()->subHour()->toDateTimeString(), + Carbon::now()->addHour()->toDateTimeString(), + ), + $this->createOccurrence( + Carbon::now()->addDays(3)->toDateTimeString(), + Carbon::now()->addDays(3)->addHours(2)->toDateTimeString(), ), ]); $event = $this->createEvent($occurrences); - $this->assertEquals(EventLifecycleStatus::UPCOMING->name, $event->getLifecycleStatus()); + $this->assertEquals(EventLifecycleStatus::ONGOING->name, $event->getLifecycleStatus()); } - public function test_get_lifecycle_status_returns_ended_when_all_past(): void + public function test_get_lifecycle_status_returns_upcoming_when_only_remaining_occurrence_is_cancelled(): void { $occurrences = collect([ $this->createOccurrence( Carbon::now()->subDays(3)->toDateTimeString(), - Carbon::now()->subDay()->toDateTimeString(), + Carbon::now()->subDays(3)->addHours(2)->toDateTimeString(), + ), + $this->createOccurrence( + Carbon::now()->addDay()->toDateTimeString(), + Carbon::now()->addDay()->addHours(2)->toDateTimeString(), + EventOccurrenceStatus::CANCELLED->name, ), ]); $event = $this->createEvent($occurrences); - $this->assertEquals(EventLifecycleStatus::ENDED->name, $event->getLifecycleStatus()); + $this->assertEquals(EventLifecycleStatus::UPCOMING->name, $event->getLifecycleStatus()); } - public function test_get_lifecycle_status_returns_upcoming_when_no_occurrences(): void + public function test_get_lifecycle_status_returns_ended_when_started_occurrence_has_no_end_date(): void { - $event = $this->createEvent(collect()); + $occurrences = collect([ + $this->createOccurrence(Carbon::now()->subHour()->toDateTimeString()), + ]); - $this->assertEquals(EventLifecycleStatus::UPCOMING->name, $event->getLifecycleStatus()); + $event = $this->createEvent($occurrences); + + $this->assertEquals(EventLifecycleStatus::ENDED->name, $event->getLifecycleStatus()); } public function test_is_recurring_returns_true_for_recurring_type(): void diff --git a/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php index ed0c074535..9dd40136ae 100644 --- a/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php @@ -12,12 +12,14 @@ use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\PromoCodeDomainObject; use HiEvents\DomainObjects\Status\EventOccurrenceStatus; +use HiEvents\Repository\Eloquent\Value\OrderAndDirection; use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; use HiEvents\Services\Application\Handlers\Event\DTO\GetPublicEventDTO; use HiEvents\Services\Application\Handlers\Event\GetPublicEventHandler; use HiEvents\Services\Domain\Event\EventPageViewIncrementService; +use HiEvents\Services\Domain\EventOccurrence\PublicOccurrenceVisibilityService; use HiEvents\Services\Domain\Product\ProductFilterService; use Illuminate\Support\Collection; use Mockery as m; @@ -52,7 +54,8 @@ protected function setUp(): void $this->occurrenceRepository, $this->promoCodeRepository, $this->ticketFilterService, - $this->eventPageViewIncrementService + $this->eventPageViewIncrementService, + new PublicOccurrenceVisibilityService, ); } @@ -257,6 +260,7 @@ public function test_handle_excludes_sold_out_occurrences_when_recurring_event_h $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event); + $this->expectEdgeOccurrenceQueries(); $this->occurrenceRepository ->shouldReceive('findWhere') ->once() @@ -266,7 +270,7 @@ public function test_handle_excludes_sold_out_occurrences_when_recurring_event_h )->count() === 2), m::any(), m::any(), - m::any(), + GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1, ) ->andReturn(collect()); $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturnNull(); @@ -296,6 +300,7 @@ public function test_handle_keeps_sold_out_occurrences_when_event_has_waitlist_e $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event); + $this->expectEdgeOccurrenceQueries(); $this->occurrenceRepository ->shouldReceive('findWhere') ->once() @@ -305,7 +310,7 @@ public function test_handle_keeps_sold_out_occurrences_when_event_has_waitlist_e )->count() === 1), m::any(), m::any(), - m::any(), + GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1, ) ->andReturn(collect()); $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull(); @@ -328,6 +333,7 @@ public function test_handle_keeps_sold_out_occurrences_when_hiding_disabled(): v $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event); + $this->expectEdgeOccurrenceQueries(); $this->occurrenceRepository ->shouldReceive('findWhere') ->once() @@ -335,13 +341,13 @@ public function test_handle_keeps_sold_out_occurrences_when_hiding_disabled(): v m::on(static fn (array $where): bool => collect($where)->filter( static fn ($condition): bool => $condition instanceof Closure )->count() === 1 - && ! collect($where)->contains( + && collect($where)->contains( static fn ($condition): bool => is_array($condition) && ($condition[0] ?? null) === EventOccurrenceDomainObjectAbstract::START_DATE )), m::any(), m::any(), - m::any(), + GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1, ) ->andReturn(collect()); $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull(); @@ -370,7 +376,7 @@ public function test_handle_ignores_requested_sold_out_occurrence_when_hidden(): $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event); - $this->occurrenceRepository->shouldReceive('findWhere')->once()->andReturn(collect()); + $this->occurrenceRepository->shouldReceive('findWhere')->andReturn(collect()); $this->occurrenceRepository ->shouldReceive('findFirstWhere') ->once() @@ -421,7 +427,7 @@ public function test_handle_flags_upcoming_occurrences_sold_out_when_all_hidden( $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf(); $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event); - $this->occurrenceRepository->shouldReceive('findWhere')->once()->andReturn(collect()); + $this->occurrenceRepository->shouldReceive('findWhere')->andReturn(collect()); $this->occurrenceRepository ->shouldReceive('findFirstWhere') ->once() @@ -444,6 +450,175 @@ public function test_handle_flags_upcoming_occurrences_sold_out_when_all_hidden( $this->assertTrue($result->getUpcomingOccurrencesSoldOut()); } + public function test_handle_windows_recurring_occurrences_to_anchor_month_in_event_timezone(): void + { + $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: true, ipAddress: '127.0.0.1', promoCode: null); + $event = (new EventDomainObject) + ->setType(EventType::RECURRING->name) + ->setTimezone('America/New_York') + ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(false)) + ->setProductCategories(collect()); + + $nextBookable = $this->makeOccurrence(1, '2026-08-01 02:00:00'); + $lastOccurrence = $this->makeOccurrence(99, '2027-06-30 22:00:00'); + $windowOccurrence = $this->makeOccurrence(2, '2026-08-01 03:00:00'); + + $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event); + $this->expectEdgeOccurrenceQueries($nextBookable, $lastOccurrence); + + $capturedWindowWhere = null; + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->with(m::any(), m::any(), m::any(), GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1) + ->andReturnUsing(static function (array $where) use (&$capturedWindowWhere, $windowOccurrence) { + $capturedWindowWhere = $where; + + return collect([$windowOccurrence]); + }); + + $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull(); + $this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect()); + $this->eventPageViewIncrementService->shouldNotReceive('increment'); + + $result = $this->handler->handle($data); + + $bounds = collect($capturedWindowWhere) + ->filter(static fn ($condition): bool => is_array($condition) + && ($condition[0] ?? null) === EventOccurrenceDomainObjectAbstract::START_DATE) + ->values(); + + $this->assertSame('>=', $bounds[0][1]); + $this->assertSame('2026-07-01 04:00:00', $bounds[0][2]); + $this->assertSame('<=', $bounds[1][1]); + $this->assertSame('2026-08-01 03:59:59', $bounds[1][2]); + $this->assertSame('2026-07', $result->getOccurrencesMonth()); + $this->assertSame('2026-08-01 02:00:00', $result->getNextOccurrenceStartDate()); + $this->assertSame('2027-06-30 22:00:00', $result->getLastOccurrenceStartDate()); + } + + public function test_handle_anchors_window_on_deep_linked_occurrence_month(): void + { + $linkedOccurrenceId = 42; + $data = new GetPublicEventDTO( + eventId: 1, + isAuthenticated: true, + ipAddress: '127.0.0.1', + promoCode: null, + eventOccurrenceId: $linkedOccurrenceId, + ); + $event = (new EventDomainObject) + ->setType(EventType::RECURRING->name) + ->setTimezone('UTC') + ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(false)) + ->setProductCategories(collect()); + + $linkedOccurrence = $this->makeOccurrence($linkedOccurrenceId, '2027-03-15 10:00:00'); + $nextBookable = $this->makeOccurrence(1, '2026-08-02 10:00:00'); + + $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event); + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(m::on(static fn (array $where): bool => ($where[EventOccurrenceDomainObjectAbstract::ID] ?? null) === $linkedOccurrenceId)) + ->andReturn($linkedOccurrence); + $this->expectEdgeOccurrenceQueries($nextBookable, $linkedOccurrence); + + $capturedWindowWhere = null; + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->with(m::any(), m::any(), m::any(), GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1) + ->andReturnUsing(static function (array $where) use (&$capturedWindowWhere, $linkedOccurrence) { + $capturedWindowWhere = $where; + + return collect([$linkedOccurrence]); + }); + + $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull(); + $this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect()); + $this->eventPageViewIncrementService->shouldNotReceive('increment'); + + $result = $this->handler->handle($data); + + $bounds = collect($capturedWindowWhere) + ->filter(static fn ($condition): bool => is_array($condition) + && ($condition[0] ?? null) === EventOccurrenceDomainObjectAbstract::START_DATE) + ->values(); + + $this->assertSame('2027-03-01 00:00:00', $bounds[0][2]); + $this->assertSame('2027-03-31 23:59:59', $bounds[1][2]); + $this->assertSame('2027-03', $result->getOccurrencesMonth()); + $this->assertSame('2026-08-02 10:00:00', $result->getNextOccurrenceStartDate()); + $this->assertTrue($result->getEventOccurrences()->contains( + fn (EventOccurrenceDomainObject $occurrence) => $occurrence->getId() === $linkedOccurrenceId + )); + } + + public function test_handle_sets_null_occurrences_month_when_anchor_month_truncated(): void + { + $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: true, ipAddress: '127.0.0.1', promoCode: null); + $event = (new EventDomainObject) + ->setType(EventType::RECURRING->name) + ->setTimezone('UTC') + ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(false)) + ->setProductCategories(collect()); + + $nextBookable = $this->makeOccurrence(1, '2026-08-01 10:00:00'); + $windowOccurrences = collect(range(1, GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1)) + ->map(fn (int $id) => $this->makeOccurrence($id, '2026-08-01 10:00:00')); + + $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event); + $this->expectEdgeOccurrenceQueries($nextBookable, $nextBookable); + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->with(m::any(), m::any(), m::any(), GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1) + ->andReturn($windowOccurrences); + + $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull(); + $this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect()); + $this->eventPageViewIncrementService->shouldNotReceive('increment'); + + $result = $this->handler->handle($data); + + $this->assertNull($result->getOccurrencesMonth()); + $this->assertCount(GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES, $result->getEventOccurrences()); + } + + private function expectEdgeOccurrenceQueries( + ?EventOccurrenceDomainObject $nextBookable = null, + ?EventOccurrenceDomainObject $lastOccurrence = null, + ): void { + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->with( + m::any(), + m::any(), + m::on(static fn (array $orders): bool => ($orders[0] ?? null) instanceof OrderAndDirection + && $orders[0]->getDirection() === OrderAndDirection::DIRECTION_ASC), + 1, + ) + ->andReturn(collect(array_filter([$nextBookable]))); + + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->with( + m::any(), + m::any(), + m::on(static fn (array $orders): bool => ($orders[0] ?? null) instanceof OrderAndDirection + && $orders[0]->getDirection() === OrderAndDirection::DIRECTION_DESC), + 1, + ) + ->andReturn(collect(array_filter([$lastOccurrence]))); + } + private function setupEventRepositoryMock($event, $eventId): void { $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetPublicEventOccurrencesHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetPublicEventOccurrencesHandlerTest.php new file mode 100644 index 0000000000..b0d431cb76 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetPublicEventOccurrencesHandlerTest.php @@ -0,0 +1,172 @@ +eventRepository = m::mock(EventRepositoryInterface::class); + $this->occurrenceRepository = m::mock(EventOccurrenceRepositoryInterface::class); + + $this->handler = new GetPublicEventOccurrencesHandler( + $this->eventRepository, + $this->occurrenceRepository, + new PublicOccurrenceVisibilityService, + ); + } + + public function test_handle_throws_when_range_is_missing(): void + { + $this->expectException(InvalidOccurrenceDatesException::class); + + $this->handler->handle(new GetPublicEventOccurrencesDTO( + eventId: 1, + startDateFrom: '2026-08-01 00:00:00', + startDateTo: null, + )); + } + + public function test_handle_throws_when_range_is_unparseable(): void + { + $this->expectException(InvalidOccurrenceDatesException::class); + + $this->handler->handle(new GetPublicEventOccurrencesDTO( + eventId: 1, + startDateFrom: 'not-a-date', + startDateTo: '2026-08-31 23:59:59', + )); + } + + public function test_handle_throws_when_range_is_inverted(): void + { + $this->expectException(InvalidOccurrenceDatesException::class); + + $this->handler->handle(new GetPublicEventOccurrencesDTO( + eventId: 1, + startDateFrom: '2026-09-01 00:00:00', + startDateTo: '2026-08-01 00:00:00', + )); + } + + public function test_handle_throws_when_range_exceeds_maximum_span(): void + { + $this->expectException(InvalidOccurrenceDatesException::class); + + $this->handler->handle(new GetPublicEventOccurrencesDTO( + eventId: 1, + startDateFrom: '2026-08-01 00:00:00', + startDateTo: '2026-10-01 00:00:00', + )); + } + + public function test_handle_returns_occurrences_within_range(): void + { + $event = (new EventDomainObject) + ->setId(1) + ->setType(EventType::RECURRING->name) + ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(false)) + ->setProductCategories(collect()); + + $occurrence = (new EventOccurrenceDomainObject) + ->setId(10) + ->setEventId(1) + ->setStartDate('2026-08-10 10:00:00') + ->setStatus(EventOccurrenceStatus::ACTIVE->name); + + $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->eventRepository->shouldReceive('findById')->with(1)->andReturn($event); + + $capturedWhere = null; + $capturedLimit = null; + $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->andReturnUsing(static function (array $where, $columns = null, $orders = null, $limit = null) use (&$capturedWhere, &$capturedLimit, $occurrence) { + $capturedWhere = $where; + $capturedLimit = $limit; + + return collect([$occurrence]); + }); + + $result = $this->handler->handle(new GetPublicEventOccurrencesDTO( + eventId: 1, + startDateFrom: '2026-08-01 00:00:00', + startDateTo: '2026-08-31 23:59:59', + )); + + $this->assertSame($event, $result->event); + $this->assertTrue($result->occurrences->contains( + fn (EventOccurrenceDomainObject $o) => $o->getId() === 10 + )); + $this->assertSame(GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES, $capturedLimit); + + $bounds = collect($capturedWhere) + ->filter(static fn ($condition): bool => is_array($condition) + && ($condition[0] ?? null) === EventOccurrenceDomainObjectAbstract::START_DATE) + ->values(); + + $this->assertSame(['>=', '2026-08-01 00:00:00'], [$bounds[0][1], $bounds[0][2]]); + $this->assertSame(['<=', '2026-08-31 23:59:59'], [$bounds[1][1], $bounds[1][2]]); + } + + public function test_handle_applies_sold_out_filter_when_event_hides_sold_out_occurrences(): void + { + $event = (new EventDomainObject) + ->setId(1) + ->setType(EventType::RECURRING->name) + ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(true)) + ->setProductCategories(collect()); + + $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->eventRepository->shouldReceive('findById')->with(1)->andReturn($event); + + $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->with( + m::on(static fn (array $where): bool => collect($where)->filter( + static fn ($condition): bool => $condition instanceof Closure + )->count() === 2), + m::any(), + m::any(), + m::any(), + ) + ->andReturn(collect()); + + $result = $this->handler->handle(new GetPublicEventOccurrencesDTO( + eventId: 1, + startDateFrom: '2026-08-01 00:00:00', + startDateTo: '2026-08-31 23:59:59', + )); + + $this->assertCount(0, $result->occurrences); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/PromoCode/UpdatePromoCodeHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/PromoCode/UpdatePromoCodeHandlerTest.php index 7ec103fdc6..e90b313c84 100644 --- a/backend/tests/Unit/Services/Application/Handlers/PromoCode/UpdatePromoCodeHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/PromoCode/UpdatePromoCodeHandlerTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Services\Application\Handlers\PromoCode; +use HiEvents\DomainObjects\Enums\PromoCodeDiscountAppliesToEnum; use HiEvents\DomainObjects\Enums\PromoCodeDiscountTypeEnum; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\PromoCodeDomainObject; @@ -50,7 +51,8 @@ public function test_handle_throws_exception_when_promo_code_not_found_for_event discount_type: PromoCodeDiscountTypeEnum::PERCENTAGE, discount: 10.0, expiry_date: null, - max_allowed_usages: null + max_allowed_usages: null, + discount_applies_to: PromoCodeDiscountAppliesToEnum::EACH_PRODUCT ); $this->promoCodeRepository @@ -81,7 +83,8 @@ public function test_handle_verifies_promo_code_belongs_to_event(): void discount_type: PromoCodeDiscountTypeEnum::PERCENTAGE, discount: 10.0, expiry_date: null, - max_allowed_usages: null + max_allowed_usages: null, + discount_applies_to: PromoCodeDiscountAppliesToEnum::EACH_PRODUCT ); $this->promoCodeRepository @@ -112,7 +115,8 @@ public function test_handle_successfully_updates_promo_code_when_ownership_verif discount_type: PromoCodeDiscountTypeEnum::PERCENTAGE, discount: 10.0, expiry_date: null, - max_allowed_usages: null + max_allowed_usages: null, + discount_applies_to: PromoCodeDiscountAppliesToEnum::EACH_PRODUCT ); $existingPromoCode = m::mock(PromoCodeDomainObject::class); @@ -161,6 +165,53 @@ public function test_handle_successfully_updates_promo_code_when_ownership_verif $this->assertSame($updatedPromoCode, $result); } + public function test_handle_preserves_stored_discount_applies_to_when_omitted(): void + { + $promoCodeId = 1; + $eventId = 2; + $dto = new UpsertPromoCodeDTO( + code: 'testcode', + event_id: $eventId, + applicable_product_ids: [], + discount_type: PromoCodeDiscountTypeEnum::FIXED, + discount: 10.0, + expiry_date: null, + max_allowed_usages: null, + discount_applies_to: null + ); + + $existingPromoCode = m::mock(PromoCodeDomainObject::class); + $existingPromoCode->shouldReceive('getId')->andReturn($promoCodeId); + $existingPromoCode->shouldReceive('getDiscountAppliesTo')->andReturn(PromoCodeDiscountAppliesToEnum::ORDER->name); + + $event = m::mock(EventDomainObject::class); + $event->shouldReceive('getTimezone')->andReturn('UTC'); + + $this->promoCodeRepository + ->shouldReceive('findFirstWhere') + ->twice() + ->andReturn($existingPromoCode); + + $this->eventProductValidationService + ->shouldReceive('validateProductIds') + ->once(); + + $this->eventRepository + ->shouldReceive('findById') + ->once() + ->andReturn($event); + + $this->promoCodeRepository + ->shouldReceive('updateFromArray') + ->once() + ->with($promoCodeId, m::on( + static fn (array $attributes) => $attributes['discount_applies_to'] === PromoCodeDiscountAppliesToEnum::ORDER->name + )) + ->andReturn($existingPromoCode); + + $this->assertSame($existingPromoCode, $this->handler->handle($promoCodeId, $dto)); + } + protected function tearDown(): void { m::close(); diff --git a/backend/tests/Unit/Services/Domain/EventOccurrence/PublicOccurrenceVisibilityServiceTest.php b/backend/tests/Unit/Services/Domain/EventOccurrence/PublicOccurrenceVisibilityServiceTest.php new file mode 100644 index 0000000000..2149a995c7 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/EventOccurrence/PublicOccurrenceVisibilityServiceTest.php @@ -0,0 +1,99 @@ +service = new PublicOccurrenceVisibilityService; + } + + public function test_should_hide_sold_out_occurrences_for_recurring_event_with_setting_enabled(): void + { + $event = (new EventDomainObject) + ->setType(EventType::RECURRING->name) + ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(true)) + ->setProductCategories(collect()); + + $this->assertTrue($this->service->shouldHideSoldOutOccurrences($event)); + } + + public function test_should_not_hide_sold_out_occurrences_for_single_event(): void + { + $event = (new EventDomainObject) + ->setType(EventType::SINGLE->name) + ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(true)) + ->setProductCategories(collect()); + + $this->assertFalse($this->service->shouldHideSoldOutOccurrences($event)); + } + + public function test_should_not_hide_sold_out_occurrences_when_setting_disabled(): void + { + $event = (new EventDomainObject) + ->setType(EventType::RECURRING->name) + ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(false)) + ->setProductCategories(collect()); + + $this->assertFalse($this->service->shouldHideSoldOutOccurrences($event)); + } + + public function test_should_not_hide_sold_out_occurrences_when_waitlist_product_exists(): void + { + $category = new ProductCategoryDomainObject; + $category->setProducts(collect([ + (new ProductDomainObject)->setWaitlistEnabled(true), + ])); + + $event = (new EventDomainObject) + ->setType(EventType::RECURRING->name) + ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(true)) + ->setProductCategories(collect([$category])); + + $this->assertFalse($this->service->shouldHideSoldOutOccurrences($event)); + } + + public function test_build_where_conditions_for_recurring_event_hiding_sold_out(): void + { + $where = $this->service->buildWhereConditions( + eventId: 5, + isRecurring: true, + hideSoldOutOccurrences: true, + ); + + $this->assertSame(5, $where[EventOccurrenceDomainObjectAbstract::EVENT_ID]); + $this->assertContains( + [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name], + $where, + ); + $this->assertCount(2, array_filter($where, static fn ($condition) => $condition instanceof Closure)); + } + + public function test_build_where_conditions_for_single_event_without_hiding(): void + { + $where = $this->service->buildWhereConditions( + eventId: 5, + isRecurring: false, + hideSoldOutOccurrences: false, + ); + + $this->assertSame(5, $where[EventOccurrenceDomainObjectAbstract::EVENT_ID]); + $this->assertCount(0, array_filter($where, static fn ($condition) => $condition instanceof Closure)); + } +} diff --git a/backend/tests/Unit/Services/Domain/Order/OrderDiscountAllocationServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderDiscountAllocationServiceTest.php new file mode 100644 index 0000000000..e02b2fde0d --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Order/OrderDiscountAllocationServiceTest.php @@ -0,0 +1,272 @@ +service = new OrderDiscountAllocationService; + } + + public function test_single_line_single_quantity_gets_exact_discount(): void + { + $lines = collect([ + $this->createLine(productId: 1, unitPrice: 50.00, quantity: 1), + ]); + + $result = $this->service->allocate($lines, $this->createPromoCode(10.00), 'USD'); + + $this->assertSame([[10.00, 1]], $this->toArrays($result[0])); + } + + public function test_indivisible_discount_splits_a_line_to_stay_exact(): void + { + $lines = collect([ + $this->createLine(productId: 1, unitPrice: 20.00, quantity: 3), + ]); + + $result = $this->service->allocate($lines, $this->createPromoCode(10.00), 'USD'); + + $this->assertSame([[3.34, 1], [3.33, 2]], $this->toArrays($result[0])); + $this->assertEqualsWithDelta(10.00, $this->totalAllocated($result), 0.0001); + } + + public function test_discount_is_allocated_pro_rata_across_lines(): void + { + $lines = collect([ + $this->createLine(productId: 1, unitPrice: 50.00, quantity: 2), + $this->createLine(productId: 2, unitPrice: 25.00, quantity: 4), + ]); + + $result = $this->service->allocate($lines, $this->createPromoCode(30.00), 'USD'); + + $this->assertSame([[7.50, 2]], $this->toArrays($result[0])); + $this->assertSame([[3.75, 4]], $this->toArrays($result[1])); + } + + public function test_remainder_is_distributed_exactly_across_lines(): void + { + $lines = collect([ + $this->createLine(productId: 1, unitPrice: 10.00, quantity: 2), + $this->createLine(productId: 2, unitPrice: 10.00, quantity: 3), + ]); + + $result = $this->service->allocate($lines, $this->createPromoCode(9.99), 'USD'); + + $this->assertSame([[2.01, 2]], $this->toArrays($result[0])); + $this->assertSame([[1.99, 3]], $this->toArrays($result[1])); + $this->assertEqualsWithDelta(9.99, $this->totalAllocated($result), 0.0001); + } + + public function test_small_discount_on_large_quantity_stays_exact(): void + { + $lines = collect([ + $this->createLine(productId: 1, unitPrice: 10.00, quantity: 250), + ]); + + $result = $this->service->allocate($lines, $this->createPromoCode(1.00), 'USD'); + + $this->assertSame([[0.01, 100], [0.00, 150]], $this->toArrays($result[0])); + $this->assertEqualsWithDelta(1.00, $this->totalAllocated($result), 0.0001); + } + + public function test_exhausted_headroom_falls_back_to_a_split_without_overshooting(): void + { + $lines = collect([ + $this->createLine(productId: 1, unitPrice: 0.01, quantity: 3), + $this->createLine(productId: 2, unitPrice: 0.01, quantity: 5), + ]); + + $result = $this->service->allocate($lines, $this->createPromoCode(0.04), 'USD'); + + $this->assertEqualsWithDelta(0.04, $this->totalAllocated($result), 0.0001); + } + + public function test_zero_decimal_currency_allocates_whole_units(): void + { + $lines = collect([ + $this->createLine(productId: 1, unitPrice: 1000.0, quantity: 3), + ]); + + $result = $this->service->allocate($lines, $this->createPromoCode(1000.0), 'JPY'); + + $this->assertSame([[334.0, 1], [333.0, 2]], $this->toArrays($result[0])); + $this->assertEqualsWithDelta(1000.0, $this->totalAllocated($result), 0.0001); + } + + public function test_discount_larger_than_subtotal_is_clamped_to_subtotal(): void + { + $lines = collect([ + $this->createLine(productId: 1, unitPrice: 50.00, quantity: 1), + $this->createLine(productId: 2, unitPrice: 50.00, quantity: 2), + ]); + + $result = $this->service->allocate($lines, $this->createPromoCode(500.00), 'USD'); + + $this->assertSame([[50.00, 1]], $this->toArrays($result[0])); + $this->assertSame([[50.00, 2]], $this->toArrays($result[1])); + } + + public function test_discount_equal_to_subtotal_zeroes_every_line(): void + { + $lines = collect([ + $this->createLine(productId: 1, unitPrice: 20.00, quantity: 2), + $this->createLine(productId: 2, unitPrice: 10.00, quantity: 1), + ]); + + $result = $this->service->allocate($lines, $this->createPromoCode(50.00), 'USD'); + + $this->assertSame([[20.00, 2]], $this->toArrays($result[0])); + $this->assertSame([[10.00, 1]], $this->toArrays($result[1])); + } + + public function test_lines_outside_applicable_products_get_no_discount(): void + { + $lines = collect([ + $this->createLine(productId: 1, unitPrice: 50.00, quantity: 1), + $this->createLine(productId: 2, unitPrice: 50.00, quantity: 1), + ]); + + $result = $this->service->allocate($lines, $this->createPromoCode(10.00, applicableProductIds: [1]), 'USD'); + + $this->assertSame([[10.00, 1]], $this->toArrays($result[0])); + $this->assertSame([[0.00, 1]], $this->toArrays($result[1])); + } + + public function test_free_and_donation_lines_get_no_discount(): void + { + $lines = collect([ + $this->createLine(productId: 1, unitPrice: 0.00, quantity: 1, type: ProductPriceType::FREE), + $this->createLine(productId: 2, unitPrice: 25.00, quantity: 1, type: ProductPriceType::DONATION), + $this->createLine(productId: 3, unitPrice: 50.00, quantity: 1), + ]); + + $result = $this->service->allocate($lines, $this->createPromoCode(10.00), 'USD'); + + $this->assertSame([[0.00, 1]], $this->toArrays($result[0])); + $this->assertSame([[0.00, 1]], $this->toArrays($result[1])); + $this->assertSame([[10.00, 1]], $this->toArrays($result[2])); + } + + public function test_no_eligible_lines_returns_zero_allocations(): void + { + $lines = collect([ + $this->createLine(productId: 1, unitPrice: 0.00, quantity: 2, type: ProductPriceType::FREE), + $this->createLine(productId: 2, unitPrice: 25.00, quantity: 1, type: ProductPriceType::DONATION), + ]); + + $result = $this->service->allocate($lines, $this->createPromoCode(10.00), 'USD'); + + $this->assertSame([[0.00, 2]], $this->toArrays($result[0])); + $this->assertSame([[0.00, 1]], $this->toArrays($result[1])); + } + + public function test_allocation_is_deterministic(): void + { + $lines = collect([ + $this->createLine(productId: 1, unitPrice: 13.33, quantity: 3), + $this->createLine(productId: 2, unitPrice: 7.77, quantity: 2), + $this->createLine(productId: 3, unitPrice: 19.99, quantity: 5), + ]); + $promoCode = $this->createPromoCode(25.00); + + $this->assertEquals( + $this->service->allocate($lines, $promoCode, 'USD'), + $this->service->allocate($lines, $promoCode, 'USD'), + ); + $this->assertEqualsWithDelta( + 25.00, + $this->totalAllocated($this->service->allocate($lines, $promoCode, 'USD')), + 0.0001, + ); + } + + public function test_per_unit_discount_never_exceeds_unit_price(): void + { + $lines = collect([ + $this->createLine(productId: 1, unitPrice: 1.00, quantity: 2), + $this->createLine(productId: 2, unitPrice: 99.99, quantity: 1), + ]); + + $result = $this->service->allocate($lines, $this->createPromoCode(75.00), 'USD'); + + foreach ($result[0] as $allocation) { + $this->assertLessThanOrEqual(1.00, $allocation->per_unit_discount); + } + foreach ($result[1] as $allocation) { + $this->assertLessThanOrEqual(99.99, $allocation->per_unit_discount); + } + $this->assertEqualsWithDelta(75.00, $this->totalAllocated($result), 0.0001); + } + + /** + * @param array> $allocations + */ + private function totalAllocated(array $allocations): float + { + $total = 0.0; + foreach ($allocations as $lineAllocations) { + foreach ($lineAllocations as $allocation) { + $total += $allocation->per_unit_discount * $allocation->quantity; + } + } + + return $total; + } + + /** + * @param array $lineAllocations + * @return array + */ + private function toArrays(array $lineAllocations): array + { + return array_map( + static fn (OrderLineDiscountAllocationDTO $allocation) => [$allocation->per_unit_discount, $allocation->quantity], + $lineAllocations, + ); + } + + private function createLine( + int $productId, + float $unitPrice, + int $quantity, + ProductPriceType $type = ProductPriceType::PAID, + ): OrderItemPricingLineDTO { + $product = (new ProductDomainObject) + ->setId($productId) + ->setType($type->name); + + return new OrderItemPricingLineDTO( + product: $product, + product_price: new OrderProductPriceDTO(quantity: $quantity, price_id: $productId * 100), + prices: new PriceDTO($unitPrice), + event_occurrence_id: null, + ); + } + + private function createPromoCode(float $discount, ?array $applicableProductIds = null): PromoCodeDomainObject + { + return (new PromoCodeDomainObject) + ->setDiscountType(PromoCodeDiscountTypeEnum::FIXED->name) + ->setDiscountAppliesTo(PromoCodeDiscountAppliesToEnum::ORDER->name) + ->setDiscount($discount) + ->setApplicableProductIds($applicableProductIds); + } +} diff --git a/backend/tests/Unit/Services/Domain/Order/OrderItemProcessingServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderItemProcessingServiceTest.php new file mode 100644 index 0000000000..89d9a652c4 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Order/OrderItemProcessingServiceTest.php @@ -0,0 +1,207 @@ +setDiscountType(PromoCodeDiscountTypeEnum::FIXED->name) + ->setDiscountAppliesTo(PromoCodeDiscountAppliesToEnum::ORDER->name) + ->setDiscount(30.00); + + $orderItems = $this->processOrder($promoCode); + + $this->assertCount(2, $orderItems); + + [$first, $second] = $this->capturedOrderItems; + + $this->assertEquals(42.50, $first['price']); + $this->assertEquals(50.00, $first['price_before_discount']); + $this->assertEquals(85.00, $first['total_before_additions']); + + $this->assertEquals(21.25, $second['price']); + $this->assertEquals(25.00, $second['price_before_discount']); + $this->assertEquals(85.00, $second['total_before_additions']); + + $this->assertEquals(8.50, $first['total_tax']); + $this->assertEquals(8.50, $second['total_tax']); + } + + public function test_per_product_discount_is_applied_to_every_unit(): void + { + $promoCode = (new PromoCodeDomainObject) + ->setDiscountType(PromoCodeDiscountTypeEnum::FIXED->name) + ->setDiscountAppliesTo(PromoCodeDiscountAppliesToEnum::EACH_PRODUCT->name) + ->setDiscount(10.00); + + $this->processOrder($promoCode); + + [$first, $second] = $this->capturedOrderItems; + + $this->assertEquals(40.00, $first['price']); + $this->assertEquals(50.00, $first['price_before_discount']); + $this->assertEquals(80.00, $first['total_before_additions']); + + $this->assertEquals(15.00, $second['price']); + $this->assertEquals(25.00, $second['price_before_discount']); + $this->assertEquals(60.00, $second['total_before_additions']); + } + + public function test_indivisible_order_level_discount_splits_a_line_into_exact_items(): void + { + $promoCode = (new PromoCodeDomainObject) + ->setDiscountType(PromoCodeDiscountTypeEnum::FIXED->name) + ->setDiscountAppliesTo(PromoCodeDiscountAppliesToEnum::ORDER->name) + ->setDiscount(10.00); + + $orderItems = $this->processOrder($promoCode, [[10, 3]]); + + $this->assertCount(2, $orderItems); + + [$first, $second] = $this->capturedOrderItems; + + $this->assertEquals(46.66, $first['price']); + $this->assertEquals(1, $first['quantity']); + $this->assertEquals(50.00, $first['price_before_discount']); + $this->assertEquals(46.66, $first['total_before_additions']); + + $this->assertEquals(46.67, $second['price']); + $this->assertEquals(2, $second['quantity']); + $this->assertEquals(50.00, $second['price_before_discount']); + $this->assertEquals(93.34, $second['total_before_additions']); + + $this->assertEqualsWithDelta( + 140.00, + $first['total_before_additions'] + $second['total_before_additions'], + 0.0001, + ); + } + + public function test_no_promo_code_leaves_prices_untouched(): void + { + $this->processOrder(null); + + [$first, $second] = $this->capturedOrderItems; + + $this->assertEquals(50.00, $first['price']); + $this->assertNull($first['price_before_discount']); + $this->assertEquals(100.00, $first['total_before_additions']); + + $this->assertEquals(25.00, $second['price']); + $this->assertNull($second['price_before_discount']); + $this->assertEquals(100.00, $second['total_before_additions']); + } + + private function processOrder(?PromoCodeDomainObject $promoCode, array $lines = [[10, 2], [20, 4]]) + { + $event = (new EventDomainObject) + ->setId(1) + ->setCurrency('USD'); + + $order = (new OrderDomainObject)->setId(99); + + $products = [ + 10 => $this->createProduct(10, 50.00), + 20 => $this->createProduct(20, 25.00), + ]; + + $orderRepository = Mockery::mock(OrderRepositoryInterface::class); + $orderRepository->shouldReceive('addOrderItem') + ->andReturnUsing(function (array $data) { + $this->capturedOrderItems[] = $data; + + return Mockery::mock(OrderItemDomainObject::class); + }); + + $productRepository = Mockery::mock(ProductRepositoryInterface::class); + $productRepository->shouldReceive('loadRelation')->andReturnSelf(); + $productRepository->shouldReceive('findFirstWhere') + ->andReturnUsing(static fn (array $where) => $products[$where['id']]); + + $taxCalculationService = Mockery::mock(TaxAndFeeCalculationService::class); + $taxCalculationService->shouldReceive('calculateTaxAndFeesForProduct') + ->andReturnUsing(static fn ($product, float $price, int $quantity) => new TaxCalculationResponse( + feeTotal: 0.0, + taxTotal: round($price * 0.10 * $quantity, 2), + rollUp: [], + )); + + $eventRepository = Mockery::mock(EventRepositoryInterface::class); + $eventRepository->shouldReceive('loadRelation')->andReturnSelf(); + $eventRepository->shouldReceive('findById')->andReturn($event); + + $service = new OrderItemProcessingService( + orderRepository: $orderRepository, + productRepository: $productRepository, + taxCalculationService: $taxCalculationService, + productPriceService: new ProductPriceService( + Mockery::mock(ProductPriceOccurrenceOverrideRepositoryInterface::class) + ), + platformFeeService: Mockery::mock(OrderPlatformFeePassThroughService::class), + eventRepository: $eventRepository, + orderDiscountAllocationService: new OrderDiscountAllocationService, + ); + + return $service->process( + order: $order, + productsOrderDetails: collect(array_map( + static fn (array $line) => new ProductOrderDetailsDTO( + product_id: $line[0], + quantities: collect([new OrderProductPriceDTO(quantity: $line[1], price_id: $line[0] * 10)]), + ), + $lines, + )), + event: $event, + promoCode: $promoCode, + ); + } + + private function createProduct(int $id, float $price): ProductDomainObject + { + return (new ProductDomainObject) + ->setId($id) + ->setType(ProductPriceType::PAID->name) + ->setProductType(ProductType::TICKET->name) + ->setTitle('Product '.$id) + ->setProductPrices(collect([ + (new ProductPriceDomainObject) + ->setId($id * 10) + ->setPrice($price), + ])); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/backend/tests/Unit/Services/Domain/Product/ProductPriceServiceTest.php b/backend/tests/Unit/Services/Domain/Product/ProductPriceServiceTest.php index 864f61b386..f94aa80ec6 100644 --- a/backend/tests/Unit/Services/Domain/Product/ProductPriceServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Product/ProductPriceServiceTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\Services\Domain\Product; use HiEvents\DomainObjects\Enums\ProductPriceType; +use HiEvents\DomainObjects\Enums\PromoCodeDiscountAppliesToEnum; use HiEvents\DomainObjects\Enums\PromoCodeDiscountTypeEnum; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; @@ -97,6 +98,7 @@ public function test_get_price_applies_promo_code_after_override(): void $promoCode->shouldReceive('getDiscountType')->andReturn(PromoCodeDiscountTypeEnum::PERCENTAGE->name); $promoCode->shouldReceive('isFixedDiscount')->andReturn(false); $promoCode->shouldReceive('isPercentageDiscount')->andReturn(true); + $promoCode->shouldReceive('isOrderLevelDiscount')->andReturn(false); $promoCode->shouldReceive('getDiscount')->andReturn(10); $result = $this->service->getPrice($product, $orderDetail, $promoCode, 5); @@ -105,6 +107,60 @@ public function test_get_price_applies_promo_code_after_override(): void $this->assertEquals(40.00, $result->price_before_discount); } + public function test_get_price_applies_fixed_per_product_discount_to_unit_price(): void + { + $product = $this->createProduct(ProductPriceType::PAID->name, 50.00); + $orderDetail = new OrderProductPriceDTO(quantity: 1, price_id: 100); + + $result = $this->service->getPrice( + $product, + $orderDetail, + $this->createFixedPromoCode(PromoCodeDiscountAppliesToEnum::EACH_PRODUCT), + ); + + $this->assertEquals(40.00, $result->price); + $this->assertEquals(50.00, $result->price_before_discount); + } + + public function test_get_price_ignores_order_level_fixed_discount(): void + { + $product = $this->createProduct(ProductPriceType::PAID->name, 50.00); + $orderDetail = new OrderProductPriceDTO(quantity: 1, price_id: 100); + + $result = $this->service->getPrice( + $product, + $orderDetail, + $this->createFixedPromoCode(PromoCodeDiscountAppliesToEnum::ORDER), + ); + + $this->assertEquals(50.00, $result->price); + $this->assertNull($result->price_before_discount); + } + + public function test_percentage_discount_stored_as_order_level_still_discounts_unit_price(): void + { + $product = $this->createProduct(ProductPriceType::PAID->name, 50.00); + $orderDetail = new OrderProductPriceDTO(quantity: 1, price_id: 100); + + $promoCode = (new PromoCodeDomainObject) + ->setDiscountType(PromoCodeDiscountTypeEnum::PERCENTAGE->name) + ->setDiscountAppliesTo(PromoCodeDiscountAppliesToEnum::ORDER->name) + ->setDiscount(10.00); + + $result = $this->service->getPrice($product, $orderDetail, $promoCode); + + $this->assertEquals(45.00, $result->price); + $this->assertEquals(50.00, $result->price_before_discount); + } + + private function createFixedPromoCode(PromoCodeDiscountAppliesToEnum $appliesTo): PromoCodeDomainObject + { + return (new PromoCodeDomainObject) + ->setDiscountType(PromoCodeDiscountTypeEnum::FIXED->name) + ->setDiscountAppliesTo($appliesTo->name) + ->setDiscount(10.00); + } + public function test_donation_keeps_donor_amount_above_override_minimum(): void { $product = $this->createProduct(ProductPriceType::DONATION->name, 10.00); diff --git a/e2e/README.md b/e2e/README.md index 77d96945e5..84688c7498 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -82,12 +82,29 @@ docker compose -f docker/e2e/docker-compose.e2e.yml down -v ### Against the running dev stack -The suite is data-isolated (unique emails per run), so it can target the dev stack directly. -Use `--skip-stack` so the script doesn't manage the e2e stack, and point it at the dev -stack's URLs. Note it leaves test data behind in the dev database. +The suite is data-isolated (unique emails per run), so it can target the dev stack directly — +useful for testing uncommitted changes without rebuilding the hermetic images (the dev stack +mounts source live). Note it leaves test data behind in the dev database. + +`E2E_SAAS_MODE=true` is required: the dev stack requires email verification, and the account +fixture only confirms the code from Mailpit in SaaS mode. That also means a queue worker must +be running to deliver the verification emails, and superadmin-dependent specs need the e2e +superadmin provisioned once: + +```bash +cd docker/development +docker compose -f docker-compose.dev.yml exec -d backend php artisan queue:work +docker compose -f docker-compose.dev.yml exec backend php artisan dev:bootstrap \ + --email=superadmin@e2e.test --password='SuperAdminPass123!' +``` + +Then run specs directly (from `e2e/`), or the whole suite via the script (from the repo root): ```bash -E2E_BASE_URL=https://localhost:8443 MAILPIT_URL=http://localhost:8025 \ +E2E_BASE_URL=https://localhost:8443 MAILPIT_URL=http://localhost:8025 E2E_SAAS_MODE=true \ + npx playwright test tests/events/recurring-event-checkout.spec.ts + +E2E_BASE_URL=https://localhost:8443 MAILPIT_URL=http://localhost:8025 E2E_SAAS_MODE=true \ ./e2e/run-e2e.sh --skip-stack --skip-deps ``` diff --git a/e2e/api/types.ts b/e2e/api/types.ts index ee5805c808..ecaf22178d 100644 --- a/e2e/api/types.ts +++ b/e2e/api/types.ts @@ -223,6 +223,7 @@ export interface CreatePromoCodePayload { code: string; discount_type: 'NONE' | 'FIXED' | 'PERCENTAGE'; discount?: number; + discount_applies_to?: 'ORDER' | 'EACH_PRODUCT'; applicable_product_ids?: number[]; expiry_date?: string; max_allowed_usages?: number; diff --git a/e2e/pages/occurrence.page.ts b/e2e/pages/occurrence.page.ts index 36bdb6d39e..35c868323d 100644 --- a/e2e/pages/occurrence.page.ts +++ b/e2e/pages/occurrence.page.ts @@ -98,14 +98,49 @@ export class PublicOccurrenceSelector { return this.page.locator('.hi-slot-header-day'); } + monthHeader(): Locator { + return this.page.locator('.hi-dp-level'); + } + productsLoadingOverlay(): Locator { return this.page.locator('.hi-occurrence-loading-overlay'); } + monthLoadingOverlay(): Locator { + return this.page.locator('.hi-calendar-month-loading'); + } + + async waitForMonthLoaded(): Promise { + await this.monthLoadingOverlay().waitFor({ state: 'visible', timeout: 300 }).catch(() => {}); + await this.monthLoadingOverlay().waitFor({ state: 'detached' }); + } + + async navigateToMonthOf(isoDate: string): Promise { + const target = new Date(isoDate); + const targetIndex = target.getUTCFullYear() * 12 + target.getUTCMonth(); + await this.calendar().waitFor(); + await this.waitForMonthLoaded(); + for (let attempt = 0; attempt < 24; attempt++) { + const header = (await this.monthHeader().innerText()).trim(); + const displayed = new Date(header.replace(' ', ' 1, ')); + const displayedIndex = displayed.getFullYear() * 12 + displayed.getMonth(); + if (displayedIndex === targetIndex) return; + if (displayedIndex < targetIndex) { + await this.nextMonthButton().click(); + } else { + await this.previousMonthButton().click(); + } + await this.waitForMonthLoaded(); + } + throw new Error(`Could not navigate the occurrence calendar to the month of ${isoDate}`); + } + async selectDay(label: RegExp): Promise { await this.calendar().waitFor(); + await this.waitForMonthLoaded(); for (let attempt = 0; attempt < 2 && (await this.dayButton(label).count()) === 0; attempt++) { await this.nextMonthButton().click(); + await this.waitForMonthLoaded(); } await this.dayButton(label).click(); } diff --git a/e2e/pages/promo-code.page.ts b/e2e/pages/promo-code.page.ts index 60690be284..383cdc6c6e 100644 --- a/e2e/pages/promo-code.page.ts +++ b/e2e/pages/promo-code.page.ts @@ -1,5 +1,12 @@ import type { Page } from '@playwright/test'; +interface CreatePromoCodeOptions { + discountType?: 'Percentage' | 'Fixed amount'; + discount?: number; + appliesTo?: 'Entire order' | 'Each product'; + expiryDate?: string; +} + export class PromoCodePage { constructor(private readonly page: Page) {} @@ -8,10 +15,36 @@ export class PromoCodePage { await this.page.waitForLoadState('networkidle'); } - async createPromoCode(code: string): Promise { + async createPromoCode(code: string, options: CreatePromoCodeOptions = {}): Promise { await this.page.getByTestId('promo-code-create-button').click(); await this.page.getByRole('heading', { name: 'Create Promo Code' }).waitFor(); await this.page.getByLabel(/^Code/).fill(code); + + if (options.discountType) { + await this.page.getByRole('combobox', { name: 'Discount Type' }).click(); + await this.page.getByRole('option', { name: options.discountType }).click(); + } + + if (options.discount !== undefined) { + await this.page.getByLabel(/^Discount (%|in)/).fill(String(options.discount)); + } + + if (options.appliesTo) { + await this.page.getByTestId('promo-code-discount-applies-to').getByText(options.appliesTo).click(); + } + + if (options.expiryDate) { + await this.page.getByTestId('promo-code-advanced-toggle').click(); + await this.page.getByLabel('Expiry Date').fill(options.expiryDate); + } + await this.page.getByTestId('promo-code-submit-button').click(); } + + async openEditModal(code: string): Promise { + const row = this.page.getByRole('row').filter({ hasText: code.toUpperCase() }); + await row.getByTestId('promo-code-actions-button').click(); + await this.page.getByRole('menuitem', { name: 'Edit Code' }).click(); + await this.page.getByRole('heading', { name: 'Edit Promo Code' }).waitFor(); + } } diff --git a/e2e/tests/checkout/kitchen-sink-recurring.spec.ts b/e2e/tests/checkout/kitchen-sink-recurring.spec.ts index 7e3a32da54..438d1165a4 100644 --- a/e2e/tests/checkout/kitchen-sink-recurring.spec.ts +++ b/e2e/tests/checkout/kitchen-sink-recurring.spec.ts @@ -46,16 +46,6 @@ const paneHeaderDay = (isoDate: string): string => { return `${weekday}, ${month} ${day}`; }; -async function revealDay(selector: PublicOccurrenceSelector, label: RegExp): Promise { - await selector.calendar().waitFor(); - for (let attempt = 0; attempt < 2 && (await selector.dayButton(label).count()) === 0; attempt++) { - await selector.nextMonthButton().click(); - } - for (let attempt = 0; attempt < 4 && (await selector.dayButton(label).count()) === 0; attempt++) { - await selector.previousMonthButton().click(); - } -} - async function arrangeRecurringKitchenSink( api: ApiClient, organizerId: number, @@ -104,12 +94,11 @@ function buildCheckoutOptions( await expect(page.getByRole('heading', { name: 'Select a Date & Time' })).toBeVisible(); for (const occurrence of occurrences) { - const label = dayButtonLabel(occurrence.start_date); - await revealDay(selector, label); - await expect(selector.dayButton(label)).toBeVisible(); + await selector.navigateToMonthOf(occurrence.start_date); + await expect(selector.dayButton(dayButtonLabel(occurrence.start_date))).toBeVisible(); } - await revealDay(selector, dayButtonLabel(first.start_date)); + await selector.navigateToMonthOf(first.start_date); await expect(selector.slotHeaderDay()).toHaveText(paneHeaderDay(first.start_date)); await expect(paneTime).toContainText(/7:00\s?PM/i); await expect(paneLocation).toHaveCount(0); @@ -117,7 +106,7 @@ function buildCheckoutOptions( await expect(standardRow.getByText(BASE_STANDARD_INCLUSIVE)).toBeVisible(); const secondLabel = dayButtonLabel(second.start_date); - await revealDay(selector, secondLabel); + await selector.navigateToMonthOf(second.start_date); await selector.dayButton(secondLabel).click(); await expect(selector.slotHeaderDay()).toHaveText(paneHeaderDay(second.start_date)); await expect(paneTime).toContainText(/7:00\s?PM/i); diff --git a/e2e/tests/checkout/promo-code-checkout.spec.ts b/e2e/tests/checkout/promo-code-checkout.spec.ts index b5329824d1..ef8147907e 100644 --- a/e2e/tests/checkout/promo-code-checkout.spec.ts +++ b/e2e/tests/checkout/promo-code-checkout.spec.ts @@ -1,8 +1,13 @@ +import type { Page } from '@playwright/test'; import { test, expect } from '../../fixtures'; import { CheckoutPage } from '../../pages/checkout.page'; import { createLiveEventWithPaidTicket } from '../../api/factory'; import { uniqueCode, uniqueEmail } from '../../utils/unique'; +const summaryLineItem = (page: Page, productTitle: string) => page.getByTitle(productTitle).locator('../..'); + +const buyer = (email: string) => ({ firstName: 'Promo', lastName: 'Buyer', email }); + test.describe('promo code checkout', () => { test( 'a buyer completes a paid-ticket order for free with a 100% promo code', @@ -33,6 +38,159 @@ test.describe('promo code checkout', () => { }, ); + test('a partial percentage code discounts every ticket', async ({ page, api, account }) => { + const event = await createLiveEventWithPaidTicket(api, account.organizerId); + const code = uniqueCode(); + await api.createPromoCode(event.eventId, { code, discount_type: 'PERCENTAGE', discount: 10 }); + + const checkout = new CheckoutPage(page); + await checkout.gotoPublicEvent(event.eventId, event.slug); + await checkout.applyPromoCode(code); + + const productRow = page.locator('.hi-product-row').filter({ hasText: event.productTitle }); + await expect(productRow.getByText('$22.50')).toBeVisible(); + await expect(productRow.getByText('$25.00')).toBeVisible(); + + await checkout.setQuantityForProduct(event.productTitle, 2); + await checkout.continueToCheckout(); + await checkout.fillOrderDetails(buyer(uniqueEmail('buyer'))); + + await expect(summaryLineItem(page, event.productTitle).getByText('$45.00')).toBeVisible(); + await expect(summaryLineItem(page, event.productTitle).getByText('$50.00')).toBeVisible(); + }); + + test('a per-ticket fixed code multiplies the discount by quantity', async ({ page, api, account }) => { + const event = await createLiveEventWithPaidTicket(api, account.organizerId); + const code = uniqueCode(); + await api.createPromoCode(event.eventId, { + code, + discount_type: 'FIXED', + discount: 6, + discount_applies_to: 'EACH_PRODUCT', + }); + + const checkout = new CheckoutPage(page); + await checkout.gotoPublicEvent(event.eventId, event.slug); + await checkout.applyPromoCode(code); + + const productRow = page.locator('.hi-product-row').filter({ hasText: event.productTitle }); + await expect(productRow.getByText('$19.00')).toBeVisible(); + await expect(productRow.getByText('$25.00')).toBeVisible(); + + await checkout.setQuantityForProduct(event.productTitle, 2); + await checkout.continueToCheckout(); + await checkout.fillOrderDetails(buyer(uniqueEmail('buyer'))); + + await expect(summaryLineItem(page, event.productTitle).getByText('$38.00')).toBeVisible(); + await expect(summaryLineItem(page, event.productTitle).getByText('$50.00')).toBeVisible(); + }); + + test('a per-order fixed code discounts the order once, not per ticket', async ({ page, api, account }) => { + const event = await createLiveEventWithPaidTicket(api, account.organizerId); + const code = uniqueCode(); + await api.createPromoCode(event.eventId, { + code, + discount_type: 'FIXED', + discount: 10, + discount_applies_to: 'ORDER', + }); + + const checkout = new CheckoutPage(page); + await checkout.gotoPublicEvent(event.eventId, event.slug); + await checkout.applyPromoCode(code); + + await expect(page.getByText('applied — $10.00 off your order')).toBeVisible(); + const productRow = page.locator('.hi-product-row').filter({ hasText: event.productTitle }); + await expect(productRow.getByText('$25.00')).toBeVisible(); + await expect(productRow.locator('.hi-price-tier-price-amount')).toHaveCount(1); + + await checkout.setQuantityForProduct(event.productTitle, 2); + await checkout.continueToCheckout(); + await checkout.fillOrderDetails(buyer(uniqueEmail('buyer'))); + + await expect(summaryLineItem(page, event.productTitle).getByText('$40.00')).toBeVisible(); + await expect(summaryLineItem(page, event.productTitle).getByText('$50.00')).toBeVisible(); + }); + + test('a per-order discount that cannot split evenly stays exact by splitting a line', async ({ page, api, account }) => { + const event = await createLiveEventWithPaidTicket(api, account.organizerId); + const code = uniqueCode(); + await api.createPromoCode(event.eventId, { + code, + discount_type: 'FIXED', + discount: 10, + discount_applies_to: 'ORDER', + }); + + const checkout = new CheckoutPage(page); + await checkout.gotoPublicEvent(event.eventId, event.slug); + await checkout.applyPromoCode(code); + await expect(page.locator('.hi-promo-code-applied')).toBeVisible(); + + await checkout.setQuantityForProduct(event.productTitle, 3); + await checkout.continueToCheckout(); + await checkout.fillOrderDetails(buyer(uniqueEmail('buyer'))); + + await expect(page.getByText('$21.66')).toBeVisible(); + await expect(page.getByText('$43.34')).toBeVisible(); + await expect(page.getByText('$65.00').first()).toBeVisible(); + }); + + test('a per-order discount is split pro-rata across multiple products', async ({ page, api, account }) => { + const event = await createLiveEventWithPaidTicket(api, account.organizerId, 50); + const categories = await api.listProductCategories(event.eventId); + await api.createProduct(event.eventId, { + title: 'Cheap Ticket', + product_type: 'TICKET', + type: 'PAID', + product_category_id: categories[0].id, + prices: [{ price: 25 }], + }); + const code = uniqueCode(); + await api.createPromoCode(event.eventId, { + code, + discount_type: 'FIXED', + discount: 30, + discount_applies_to: 'ORDER', + }); + + const checkout = new CheckoutPage(page); + await checkout.gotoPublicEvent(event.eventId, event.slug); + await checkout.applyPromoCode(code); + await expect(page.locator('.hi-promo-code-applied')).toBeVisible(); + + await checkout.setQuantityForProduct(event.productTitle, 1); + await checkout.setQuantityForProduct('Cheap Ticket', 2); + await checkout.continueToCheckout(); + await checkout.fillOrderDetails(buyer(uniqueEmail('buyer'))); + + await expect(summaryLineItem(page, event.productTitle).getByText('$35.00')).toBeVisible(); + await expect(summaryLineItem(page, event.productTitle).getByText('$50.00')).toBeVisible(); + await expect(summaryLineItem(page, 'Cheap Ticket').getByText('$35.00')).toBeVisible(); + await expect(summaryLineItem(page, 'Cheap Ticket').getByText('$50.00')).toBeVisible(); + }); + + test('a no-discount code applies without changing any price', async ({ page, api, account }) => { + const event = await createLiveEventWithPaidTicket(api, account.organizerId); + const code = uniqueCode(); + await api.createPromoCode(event.eventId, { code, discount_type: 'NONE' }); + + const checkout = new CheckoutPage(page); + await checkout.gotoPublicEvent(event.eventId, event.slug); + await checkout.applyPromoCode(code); + + await expect(page.locator('.hi-promo-code-applied')).toBeVisible(); + const productRow = page.locator('.hi-product-row').filter({ hasText: event.productTitle }); + await expect(productRow.getByText('$25.00')).toBeVisible(); + await expect(productRow.locator('.hi-price-tier-price-amount')).toHaveCount(1); + + await checkout.setQuantityForProduct(event.productTitle, 2); + await checkout.continueToCheckout(); + await checkout.fillOrderDetails(buyer(uniqueEmail('buyer'))); + + await expect(summaryLineItem(page, event.productTitle).getByText('$50.00')).toHaveCount(1); + }); + test('applying an invalid promo code shows an error', async ({ page, api, account }) => { const event = await createLiveEventWithPaidTicket(api, account.organizerId); diff --git a/e2e/tests/events/recurring-event-checkout.spec.ts b/e2e/tests/events/recurring-event-checkout.spec.ts index f2933a6534..1b3b6f7852 100644 --- a/e2e/tests/events/recurring-event-checkout.spec.ts +++ b/e2e/tests/events/recurring-event-checkout.spec.ts @@ -10,9 +10,16 @@ const utcParts = (isoDate: string) => { weekday: date.toLocaleString('en-US', { weekday: 'long', timeZone: 'UTC' }), month: date.toLocaleString('en-US', { month: 'long', timeZone: 'UTC' }), day: date.getUTCDate(), + year: date.getUTCFullYear(), }; }; +const utcMonthsBetween = (fromIsoDate: string, toIsoDate: string) => { + const from = new Date(fromIsoDate); + const to = new Date(toIsoDate); + return (to.getUTCFullYear() - from.getUTCFullYear()) * 12 + (to.getUTCMonth() - from.getUTCMonth()); +}; + test.describe('recurring event checkout', () => { test('a buyer picks a specific occurrence and completes a free order', async ({ page, api, account }) => { const event = await createRecurringLiveEvent(api, account.organizerId, { count: 3 }); @@ -36,4 +43,48 @@ test.describe('recurring event checkout', () => { await expect(page.getByText(`You're going to ${event.title}`)).toBeVisible(); await expect(page.getByText(new RegExp(`${month} ${day}\\b`)).first()).toBeVisible(); }); + + test('a buyer navigates months beyond the embedded window and completes an order', async ({ page, api, account }) => { + const event = await createRecurringLiveEvent(api, account.organizerId, { count: 20 }); + const sorted = [...event.occurrences].sort((a, b) => a.start_date.localeCompare(b.start_date)); + const target = sorted[sorted.length - 2]; + const monthsAhead = utcMonthsBetween(sorted[0].start_date, target.start_date); + expect(monthsAhead).toBeGreaterThanOrEqual(3); + const { weekday, month, day } = utcParts(target.start_date); + const buyer = { firstName: 'FarMonth', lastName: 'Buyer', email: uniqueEmail('buyer') }; + + const checkout = new CheckoutPage(page); + const selector = new PublicOccurrenceSelector(page); + await checkout.gotoPublicEvent(event.eventId, event.slug); + await selector.calendar().waitFor(); + for (let i = 0; i < monthsAhead; i++) { + await selector.nextMonthButton().click(); + } + await selector.dayButton(new RegExp(`^${weekday}, ${month} ${day},`)).click(); + + await expect(selector.slotHeaderDay()).toHaveText(`${weekday}, ${month} ${day}`); + await expect(selector.productsLoadingOverlay()).toHaveCount(0); + + await checkout.setFirstProductQuantity(1); + await checkout.continueToCheckout(); + await checkout.fillOrderDetails(buyer); + await checkout.completeFreeOrder(); + + await expect(page.getByText(`You're going to ${event.title}`)).toBeVisible(); + await expect(page.getByText(new RegExp(`${month} ${day}\\b`)).first()).toBeVisible(); + }); + + test('a deep link to a far-out occurrence anchors the calendar on its month', async ({ page, api, account }) => { + const event = await createRecurringLiveEvent(api, account.organizerId, { count: 20 }); + const sorted = [...event.occurrences].sort((a, b) => a.start_date.localeCompare(b.start_date)); + const target = sorted[sorted.length - 1]; + expect(utcMonthsBetween(sorted[0].start_date, target.start_date)).toBeGreaterThanOrEqual(3); + const { weekday, month, day, year } = utcParts(target.start_date); + + const selector = new PublicOccurrenceSelector(page); + await page.goto(`/event/${event.eventId}/${event.slug}?occurrence_id=${target.id}`); + + await expect(selector.monthHeader()).toHaveText(`${month} ${year}`); + await expect(selector.slotHeaderDay()).toHaveText(`${weekday}, ${month} ${day}`); + }); }); diff --git a/e2e/tests/management/promo-codes.spec.ts b/e2e/tests/management/promo-codes.spec.ts index afec7ce134..e0bbf7548b 100644 --- a/e2e/tests/management/promo-codes.spec.ts +++ b/e2e/tests/management/promo-codes.spec.ts @@ -16,4 +16,28 @@ test.describe('promo codes', () => { await expect(row).toBeVisible(); await expect(row.getByText('All Products')).toBeVisible(); }); + + test('an organizer creates a per-order fixed code and the edit modal restores its settings', async ({ authedPage, api, account }) => { + const event = await createDraftEvent(api, account.organizerId); + const code = uniqueCode('ORDER'); + + const promoCodes = new PromoCodePage(authedPage); + await promoCodes.goto(event.eventId); + await promoCodes.createPromoCode(code, { + discountType: 'Fixed amount', + discount: 10, + appliesTo: 'Entire order', + expiryDate: '2030-12-31T23:59', + }); + + const row = authedPage.getByRole('row').filter({ hasText: code.toUpperCase() }); + await expect(row).toBeVisible(); + + await promoCodes.openEditModal(code); + + const appliesTo = authedPage.getByTestId('promo-code-discount-applies-to'); + await expect(appliesTo.getByRole('radio', { name: 'Entire order' })).toBeChecked(); + await expect(authedPage.getByLabel('Expiry Date')).toBeVisible(); + await expect(authedPage.getByTestId('promo-code-advanced-toggle')).toHaveText('Hide advanced options'); + }); }); diff --git a/frontend/public/widget-test.html b/frontend/public/widget-test.html index 7c2ae9b718..427e081666 100644 --- a/frontend/public/widget-test.html +++ b/frontend/public/widget-test.html @@ -19,7 +19,7 @@

-
{ - const response = await publicApi.get>( - `events/${eventId}/occurrences` + queryParamsHelper.buildQueryString(pagination) + all: async (eventId: IdParam, startDateFrom: string, startDateTo: string) => { + const params = new URLSearchParams({ + start_date_from: startDateFrom, + start_date_to: startDateTo, + }); + const response = await publicApi.get>( + `events/${eventId}/occurrences?${params.toString()}` ); return response.data; }, diff --git a/frontend/src/api/promo-code.client.ts b/frontend/src/api/promo-code.client.ts index 3769b5c73d..1a3eb87970 100644 --- a/frontend/src/api/promo-code.client.ts +++ b/frontend/src/api/promo-code.client.ts @@ -1,6 +1,6 @@ import {api} from "./client"; import { - GenericDataResponse, GenericPaginatedResponse, IdParam, PromoCode, QueryFilters, + GenericDataResponse, GenericPaginatedResponse, IdParam, PromoCode, PromoCodeValidationResponse, QueryFilters, } from "../types"; import {publicApi} from "./public-client.ts"; import {queryParamsHelper} from "../utilites/queryParamsHelper.ts"; @@ -36,7 +36,7 @@ export const promoCodeClient = { export const promoCodeClientPublic = { validateCode: async (eventId: IdParam, promoCode: string | null) => { - const response = await publicApi.get<{ valid: boolean }>( + const response = await publicApi.get( `events/${eventId}/promo-codes/${promoCode}` ); return response.data; diff --git a/frontend/src/components/common/AdvancedOptions/AdvancedOptions.module.scss b/frontend/src/components/common/AdvancedOptions/AdvancedOptions.module.scss new file mode 100644 index 0000000000..28d5140470 --- /dev/null +++ b/frontend/src/components/common/AdvancedOptions/AdvancedOptions.module.scss @@ -0,0 +1,33 @@ +.advancedToggle { + display: flex; + align-items: center; + gap: 6px; + background: transparent; + border: none; + color: var(--hi-primary); + font-family: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + padding: 6px 8px; + margin: 4px 0 16px -8px; + border-radius: 6px; + transition: background 140ms ease; + + &:hover { + background: color-mix(in srgb, var(--hi-primary) 8%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--hi-primary); + outline-offset: 1px; + } + + .chevron { + transition: transform 180ms ease; + + &.chevronOpen { + transform: rotate(90deg); + } + } +} diff --git a/frontend/src/components/common/AdvancedOptions/index.tsx b/frontend/src/components/common/AdvancedOptions/index.tsx new file mode 100644 index 0000000000..53abd87f5f --- /dev/null +++ b/frontend/src/components/common/AdvancedOptions/index.tsx @@ -0,0 +1,36 @@ +import {Collapse} from "@mantine/core"; +import {t} from "@lingui/macro"; +import {IconChevronRight} from "@tabler/icons-react"; +import {ReactNode} from "react"; +import classes from "./AdvancedOptions.module.scss"; + +interface AdvancedOptionsProps { + opened: boolean; + onToggle: () => void; + dataTestId?: string; + children: ReactNode; +} + +export const AdvancedOptions = ({opened, onToggle, dataTestId, children}: AdvancedOptionsProps) => { + return ( + <> + + + + {children} + + + ); +} diff --git a/frontend/src/components/common/EventDateRange/index.tsx b/frontend/src/components/common/EventDateRange/index.tsx index 8b7e2550e3..aa2d799ae2 100644 --- a/frontend/src/components/common/EventDateRange/index.tsx +++ b/frontend/src/components/common/EventDateRange/index.tsx @@ -46,17 +46,19 @@ export const EventDateRange = ({event, occurrence}: EventDateRangeProps) => { .filter(o => o.status !== EventOccurrenceStatus.CANCELLED && !o.is_past) .sort((a, b) => a.start_date.localeCompare(b.start_date)); - if (upcomingOccurrences.length > 0) { - const next = upcomingOccurrences[0]; - if (upcomingOccurrences.length === 1) { + const nextStartDate = event.next_occurrence_start_date || upcomingOccurrences[0]?.start_date; + + if (nextStartDate) { + const isSingleRemaining = upcomingOccurrences.length === 1 + && (!event.last_occurrence_date || event.last_occurrence_date === nextStartDate); + + if (isSingleRemaining) { + const next = upcomingOccurrences[0]; return formatRange(next.start_date, next.end_date, event.timezone); } - const nextFormatted = formatDateWithLocale(next.start_date, "shortDateTime", event.timezone); - return ( - - {t`Next: ${nextFormatted}`} · {t`${upcomingOccurrences.length} upcoming dates`} - - ); + + const nextFormatted = formatDateWithLocale(nextStartDate, "shortDateTime", event.timezone); + return {t`Next: ${nextFormatted}`}; } if (event.upcoming_occurrences_sold_out) { diff --git a/frontend/src/components/common/EventDocumentHead/index.tsx b/frontend/src/components/common/EventDocumentHead/index.tsx index db85d16516..75694c773b 100644 --- a/frontend/src/components/common/EventDocumentHead/index.tsx +++ b/frontend/src/components/common/EventDocumentHead/index.tsx @@ -19,8 +19,10 @@ export const EventDocumentHead = ({event}: EventDocumentHeadProps) => { const keywords = eventSettings?.seo_keywords; const image = eventCoverImageUrl(event); const url = eventHomepageUrl(event); - const startDate = utcToTz(new Date(event.start_date), event.timezone); - const endDate = event.end_date ? utcToTz(new Date(event.end_date), event.timezone) : undefined; + const seriesStartDate = event.next_occurrence_start_date || event.start_date; + const seriesEndDate = event.last_occurrence_date || event.end_date; + const startDate = utcToTz(new Date(seriesStartDate), event.timezone); + const endDate = seriesEndDate ? utcToTz(new Date(seriesEndDate), event.timezone) : undefined; const locationSummary = summariseEventLocations(event); const effective = locationSummary.kind === 'single' ? locationSummary.eventLocation : null; diff --git a/frontend/src/components/common/PromoCodeTable/index.tsx b/frontend/src/components/common/PromoCodeTable/index.tsx index 182ed03a98..e848fe3921 100644 --- a/frontend/src/components/common/PromoCodeTable/index.tsx +++ b/frontend/src/components/common/PromoCodeTable/index.tsx @@ -1,5 +1,5 @@ import {t} from "@lingui/macro"; -import {Event, PromoCode, PromoCodeDiscountType} from "../../../types.ts"; +import {Event, PromoCode, PromoCodeDiscountAppliesTo, PromoCodeDiscountType} from "../../../types.ts"; import {prettyDate, relativeDate} from "../../../utilites/dates.ts"; import {Badge, Button, Flex, Group, Menu, Table as MantineTable, Tooltip} from "@mantine/core"; import {Table, TableHead} from "../Table"; @@ -83,7 +83,15 @@ export const PromoCodeTable = ({event, promoCodes, openCreateModal}: PromoCodeTa } if (code.discount_type === PromoCodeDiscountType.Fixed) { - return ; + return ( + <> + + {' '} + {code.discount_applies_to === PromoCodeDiscountAppliesTo.Order + ? t`per order` + : t`per product`} + + ); } return <>{code.discount}%; @@ -160,7 +168,7 @@ export const PromoCodeTable = ({event, promoCodes, openCreateModal}: PromoCodeTa - + diff --git a/frontend/src/components/forms/CheckInListForm/CheckInListForm.module.scss b/frontend/src/components/forms/CheckInListForm/CheckInListForm.module.scss index d0a974433d..26d79c2639 100644 --- a/frontend/src/components/forms/CheckInListForm/CheckInListForm.module.scss +++ b/frontend/src/components/forms/CheckInListForm/CheckInListForm.module.scss @@ -1,37 +1,3 @@ -.advancedToggle { - display: flex; - align-items: center; - gap: 6px; - background: transparent; - border: none; - color: var(--hi-primary); - font-family: inherit; - font-size: 13px; - font-weight: 600; - cursor: pointer; - padding: 6px 8px; - margin: 4px 0 12px -8px; - border-radius: 6px; - transition: background 140ms ease; - - &:hover { - background: color-mix(in srgb, var(--hi-primary) 8%, transparent); - } - - &:focus-visible { - outline: 2px solid var(--hi-primary); - outline-offset: 1px; - } - - .chevron { - transition: transform 180ms ease; - - &.chevronOpen { - transform: rotate(90deg); - } - } -} - .visibilitySection { margin-top: 20px; padding: 16px; diff --git a/frontend/src/components/forms/CheckInListForm/index.tsx b/frontend/src/components/forms/CheckInListForm/index.tsx index 5eec6652d7..92b44c1cf0 100644 --- a/frontend/src/components/forms/CheckInListForm/index.tsx +++ b/frontend/src/components/forms/CheckInListForm/index.tsx @@ -1,4 +1,4 @@ -import {Collapse, Select, Switch, Textarea, TextInput} from "@mantine/core"; +import {Select, Switch, Textarea, TextInput} from "@mantine/core"; import {t, Trans} from "@lingui/macro"; import {UseFormReturnType} from "@mantine/form"; import { @@ -11,9 +11,9 @@ import { import {InputGroup} from "../../common/InputGroup"; import {ProductSelector} from "../../common/ProductSelector"; import {Callout} from "../../common/Callout"; +import {AdvancedOptions} from "../../common/AdvancedOptions"; import {useEffect, useMemo, useState} from "react"; import { - IconChevronRight, IconClipboardText, IconEye, IconMessageCircleQuestion, @@ -68,15 +68,12 @@ export const CheckInListForm = ({ })); }, [activeOccurrences, timezone]); - // Open advanced panel automatically if editing a list that already uses any of those options. const [showAdvanced, setShowAdvanced] = useState(() => hasAdvancedValuesSet(form)); - // UI mirror of "product_ids is empty" — default on for new lists. const [scopeToAll, setScopeToAll] = useState( () => !form.values.product_ids || form.values.product_ids.length === 0, ); - // Reflect late-hydrated values (edit modal sets product_ids in an effect). useEffect(() => { const hasProducts = (form.values.product_ids?.length ?? 0) > 0; if (hasProducts && scopeToAll) setScopeToAll(false); @@ -103,7 +100,6 @@ export const CheckInListForm = ({ placeholder={t`VIP check-in list`} /> - {/* UI-only: empty product_ids = "covers every ticket" on the backend. */} )} - - - + setShowAdvanced(v => !v)}>