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/ProductForm/index.tsx b/frontend/src/components/forms/ProductForm/index.tsx
index 5ed39fdf65..cd0c74956a 100644
--- a/frontend/src/components/forms/ProductForm/index.tsx
+++ b/frontend/src/components/forms/ProductForm/index.tsx
@@ -63,7 +63,22 @@ interface ProductFormProps {
event?: Event,
}
+const hasQuantityValue = (value: unknown): boolean =>
+ value !== undefined && value !== null && value !== '';
+
+const SeriesQuantityWarning = ({eventId}: { eventId?: string | number }) => (
+
+
+ This limits total sales across every date in your schedule combined — it is not a
+ per-date limit. To limit attendance for each date, set a capacity on the Occurrence Schedule page.
+
+
+);
+
const ProductPriceTierForm = ({form, product, event}: ProductFormProps) => {
+ const isRecurringTicket = event?.type === EventType.RECURRING && form.values.product_type === 'TICKET';
+
return form?.values?.prices?.map((price, index) => {
const existingPrice = product?.prices?.find((p) => Number(p.id) === Number(price.id));
const deleteDisabled = form?.values?.prices?.length === 1 || (existingPrice && Number(existingPrice?.quantity_sold) > 0);
@@ -98,8 +113,11 @@ const ProductPriceTierForm = ({form, product, event}: ProductFormProps) => {
+ {!product && isRecurringTicket && hasQuantityValue(price.initial_quantity_available) && (
+
+ )}
{
const {data: event} = useGetEvent(eventId);
const {data: taxesAndFees} = useGetTaxesAndFees();
const isRecurring = event?.type === EventType.RECURRING;
+ const isRecurringTicket = isRecurring && form.values.product_type === 'TICKET';
const handleTaxOrFeeCreated = (taxOrFee: TaxAndFee) => {
const currentIds = form.values.tax_and_fee_ids || [];
@@ -331,12 +350,13 @@ export const ProductForm = ({form, product}: ProductFormProps) => {
placeholder={t`Unlimited`}
{...form.getInputProps('prices.0.initial_quantity_available')}
label={
- This is the default quantity across all dates. Each date's capacity
- can further limit availability on the Occurrence Schedule
page.
@@ -356,6 +376,9 @@ export const ProductForm = ({form, product}: ProductFormProps) => {
/>}
/>
+ {!product && isRecurringTicket && hasQuantityValue(form.values.prices?.[0]?.initial_quantity_available) && (
+
+ )}
>
)}
@@ -363,9 +386,10 @@ export const ProductForm = ({form, product}: ProductFormProps) => {