diff --git a/.agents/skills/translations/SKILL.md b/.agents/skills/translations/SKILL.md
new file mode 100644
index 0000000000..532b1c1b10
--- /dev/null
+++ b/.agents/skills/translations/SKILL.md
@@ -0,0 +1,64 @@
+---
+name: translations
+description: Frontend translation workflow using Lingui - extracting, adding, and compiling translations for all supported languages
+---
+
+# Frontend Translation Workflow (Lingui)
+
+IMPORTANT: Always update translations as you develop features. When adding new translatable strings, immediately add translations for all supported languages.
+
+## Core Commands
+
+```bash
+cd frontend
+
+# Extract translatable strings (use --clean for accurate counts)
+yarn messages:extract --clean
+
+# Compile translations for production
+yarn messages:compile
+
+# Check for untranslated strings
+cd scripts && ./list_untranslated_strings.sh
+```
+
+## Process
+
+1. **Extract**: `yarn messages:extract --clean`
+2. **Check**: Look at the output table for missing translation counts
+3. **Add translations**: Update the `.po` files for each language
+4. **Verify**: Run extract again to confirm 0 missing
+5. **Compile**: `yarn messages:compile`
+
+## Adding Translations
+
+Add entries to each locale's `.po` file in `frontend/src/locales/`:
+
+```po
+#: src/path/to/component.tsx:123
+msgid "Your English String"
+msgstr "Translated String"
+```
+
+## Supported Languages
+
+| Code | Language |
+|------|----------|
+| en | English (source - no translation needed) |
+| de | Deutsch |
+| es | Espanol |
+| fr | Francais |
+| pt | Portugues |
+| pt-br | Portugues do Brasil |
+| it | Italiano |
+| nl | Nederlands |
+| zh-cn | Simplified Chinese |
+| zh-hk | Traditional Chinese (HK) |
+| vi | Tieng Viet |
+| ru | Russian (currently untranslated) |
+
+## Troubleshooting
+
+- **Counts seem wrong**: Use `--clean` flag to remove obsolete entries
+- **Translation not appearing**: Run `yarn messages:compile` after adding
+- **Syntax errors**: Check for proper escaping of quotes in `.po` files
diff --git a/.claude/skills/translations/SKILL.md b/.claude/skills/translations/SKILL.md
index 532b1c1b10..cb9ffeb320 100644
--- a/.claude/skills/translations/SKILL.md
+++ b/.claude/skills/translations/SKILL.md
@@ -1,64 +1 @@
----
-name: translations
-description: Frontend translation workflow using Lingui - extracting, adding, and compiling translations for all supported languages
----
-
-# Frontend Translation Workflow (Lingui)
-
-IMPORTANT: Always update translations as you develop features. When adding new translatable strings, immediately add translations for all supported languages.
-
-## Core Commands
-
-```bash
-cd frontend
-
-# Extract translatable strings (use --clean for accurate counts)
-yarn messages:extract --clean
-
-# Compile translations for production
-yarn messages:compile
-
-# Check for untranslated strings
-cd scripts && ./list_untranslated_strings.sh
-```
-
-## Process
-
-1. **Extract**: `yarn messages:extract --clean`
-2. **Check**: Look at the output table for missing translation counts
-3. **Add translations**: Update the `.po` files for each language
-4. **Verify**: Run extract again to confirm 0 missing
-5. **Compile**: `yarn messages:compile`
-
-## Adding Translations
-
-Add entries to each locale's `.po` file in `frontend/src/locales/`:
-
-```po
-#: src/path/to/component.tsx:123
-msgid "Your English String"
-msgstr "Translated String"
-```
-
-## Supported Languages
-
-| Code | Language |
-|------|----------|
-| en | English (source - no translation needed) |
-| de | Deutsch |
-| es | Espanol |
-| fr | Francais |
-| pt | Portugues |
-| pt-br | Portugues do Brasil |
-| it | Italiano |
-| nl | Nederlands |
-| zh-cn | Simplified Chinese |
-| zh-hk | Traditional Chinese (HK) |
-| vi | Tieng Viet |
-| ru | Russian (currently untranslated) |
-
-## Troubleshooting
-
-- **Counts seem wrong**: Use `--clean` flag to remove obsolete entries
-- **Translation not appearing**: Run `yarn messages:compile` after adding
-- **Syntax errors**: Check for proper escaping of quotes in `.po` files
+see @../../../.agents/skills/translations/SKILL.md
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000000..2ee358d6f2
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,108 @@
+name: Backend Tests
+
+on:
+ push:
+ branches: [main, develop]
+ paths:
+ - 'backend/**'
+ - '.github/workflows/tests.yml'
+ pull_request:
+ paths:
+ - 'backend/**'
+ - '.github/workflows/tests.yml'
+
+jobs:
+ tests:
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ php-versions: ['8.2', '8.3', '8.4']
+
+ services:
+ # Postgres is started for the Feature suite. The Unit suite does not need
+ # a live connection — it only reads DB_DATABASE for the _test guard check
+ # in CreatesApplication — so it runs in parallel with Postgres warming up.
+ postgres:
+ image: postgres:15
+ env:
+ POSTGRES_DB: hievents_test
+ POSTGRES_USER: hievents
+ POSTGRES_PASSWORD: hievents
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U hievents -d hievents_test"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
+ # Job-level env. .env.testing supplies the rest, but the DB host on a CI
+ # runner is 127.0.0.1 (service container exposes its port on the runner),
+ # not the docker network alias used locally — override here.
+ env:
+ DB_HOST: 127.0.0.1
+ DB_PORT: 5432
+ DB_DATABASE: hievents_test
+ DB_USERNAME: hievents
+ DB_PASSWORD: hievents
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ - name: Set up PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-versions }}
+ extensions: mbstring, xml, ctype, iconv, intl, pdo, pdo_mysql, pdo_pgsql, pgsql, tokenizer
+ ini-values: post_max_size=256M, upload_max_filesize=256M
+ coverage: none
+
+ - name: Get Composer Cache Directory
+ id: composer-cache
+ run: echo "::set-output name=dir::$(composer config cache-files-dir)"
+
+ - name: Cache dependencies
+ uses: actions/cache@v3
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-composer-
+
+ - name: Create Laravel bootstrap cache directory
+ run: mkdir -p ./backend/bootstrap/cache && chmod -R 777 ./backend/bootstrap/cache
+
+ - name: Install dependencies
+ run: cd backend && composer install --prefer-dist --no-progress --no-interaction
+
+ - name: Stage .env for testing
+ # Laravel auto-loads .env.testing when APP_ENV=testing, but artisan
+ # commands run outside that flow read .env directly. Copy .env.testing
+ # to .env so both paths see the same config.
+ run: cp backend/.env.testing backend/.env
+
+ - name: Run Unit test suite
+ # Pure unit tests — no DB connection, no migrations. The CreatesApplication
+ # bootstrap detects the absence of DatabaseTransactions / RefreshDatabase
+ # traits and skips migrate:fresh entirely. Runs in parallel with the
+ # Postgres service container coming up.
+ run: cd backend && ./vendor/bin/phpunit --testsuite=Unit --no-coverage
+
+ - name: Wait for Postgres
+ run: |
+ for i in {1..30}; do
+ if pg_isready -h 127.0.0.1 -p 5432 -U hievents -d hievents_test; then
+ exit 0
+ fi
+ sleep 1
+ done
+ echo "Postgres did not become ready in time" >&2
+ exit 1
+
+ - name: Run Feature test suite
+ # Integration tests against the real PostgreSQL test database. The first
+ # test that boots Laravel triggers migrate:fresh once per process via
+ # CreatesApplication::ensureTestDatabaseIsMigrated.
+ run: cd backend && ./vendor/bin/phpunit --testsuite=Feature --no-coverage
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
deleted file mode 100644
index 5e4cdf1c6e..0000000000
--- a/.github/workflows/unit-tests.yml
+++ /dev/null
@@ -1,51 +0,0 @@
-name: Run Unit Tests
-
-on:
- push:
- branches: [main, develop]
- paths:
- - 'backend/**'
- pull_request:
- paths:
- - 'backend/**'
-
-jobs:
- run-tests:
- runs-on: ubuntu-latest
-
- strategy:
- matrix:
- php-versions: ['8.2', '8.3', '8.4']
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v3
-
- - name: Set up PHP
- uses: shivammathur/setup-php@v2
- with:
- php-version: ${{ matrix.php-versions }}
- extensions: mbstring, xml, ctype, iconv, intl, pdo, pdo_mysql, tokenizer
- ini-values: post_max_size=256M, upload_max_filesize=256M
- coverage: none
-
- - name: Get Composer Cache Directory
- id: composer-cache
- run: echo "::set-output name=dir::$(composer config cache-files-dir)"
-
- - name: Cache dependencies
- uses: actions/cache@v3
- with:
- path: ${{ steps.composer-cache.outputs.dir }}
- key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
- restore-keys: |
- ${{ runner.os }}-composer-
-
- - name: Create Laravel bootstrap cache directory
- run: mkdir -p ./backend/bootstrap/cache && chmod -R 777 ./backend/bootstrap/cache
-
- - name: Install dependencies
- run: cd backend && composer install --prefer-dist --no-progress --no-interaction
-
- - name: Run PHPUnit Tests
- run: cd backend && ./vendor/bin/phpunit tests/Unit --no-coverage
diff --git a/.gitignore b/.gitignore
index eda9758bce..111504f948 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,5 @@ prompts/
/plans/**
/plans
+
+.claude/worktrees/
diff --git a/CLAUDE.md b/CLAUDE.md
index f283207d7f..0b0c572958 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -93,6 +93,8 @@ cd docker/development
- **DON'T** use `RefreshDatabase` - use `DatabaseTransactions` instead
- Unit tests extend Laravel's TestCase, not PHPUnit's TestCase
- Use Mockery for mocking
+- Tests run against a dedicated `hievents_test` database, configured via `backend/.env.testing` and enforced by `phpunit.xml`. The local docker-compose creates this database automatically via `docker/development/pgsql-init/`. If your existing pgsql volume predates this script, create the DB once with: `docker compose -f docker-compose.dev.yml exec pgsql psql -U username -d backend -c 'CREATE DATABASE hievents_test OWNER username;'`
+- Database name **must end in `_test`**. Enforced globally by a `final` guard in `tests/TestCase.php::guardAgainstNonTestDatabase()` which runs on every test that boots Laravel — no per-test opt-in needed and no way to bypass.
### Frontend
diff --git a/backend/.env.testing b/backend/.env.testing
new file mode 100644
index 0000000000..e559763ef8
--- /dev/null
+++ b/backend/.env.testing
@@ -0,0 +1,43 @@
+# Auto-loaded by Laravel when APP_ENV=testing (i.e. whenever PHPUnit runs).
+# Safe to commit — contains only test-only credentials and fixed test secrets.
+# Real secrets must NEVER be added here.
+
+APP_NAME=Hi.Events
+APP_ENV=testing
+# Static, test-only AES-256 key. Do not reuse outside tests.
+APP_KEY=base64:rasMRv+Gm0oDMcBq+j9MvRgR3a6JYPTZjpRD4rGG2wA=
+APP_DEBUG=true
+APP_URL=http://localhost
+APP_FRONTEND_URL=http://localhost
+APP_LOG_QUERIES=false
+APP_SAAS_MODE_ENABLED=false
+
+LOG_CHANNEL=stderr
+LOG_LEVEL=debug
+
+# Database — must end in _test (BaseRepositoryTest enforces this).
+# CI exports overrides via the workflow; locally these defaults match the
+# docker-compose pgsql service.
+DB_CONNECTION=pgsql
+DB_HOST=pgsql
+DB_PORT=5432
+DB_DATABASE=hievents_test
+DB_USERNAME=username
+DB_PASSWORD=password
+
+# Stateless drivers — keep tests hermetic, no external dependencies.
+BROADCAST_DRIVER=log
+CACHE_DRIVER=array
+FILESYSTEM_PUBLIC_DISK=local
+FILESYSTEM_PRIVATE_DISK=local
+QUEUE_CONNECTION=sync
+SESSION_DRIVER=array
+SESSION_LIFETIME=120
+MAIL_MAILER=array
+
+# Fixed test JWT secret — do not reuse outside tests.
+JWT_SECRET=test-jwt-secret-not-for-production-use-only-in-tests-aaaaaaaaaa
+JWT_ALGO=HS256
+
+BCRYPT_ROUNDS=4
+TELESCOPE_ENABLED=false
diff --git a/backend/app/Console/Kernel.php b/backend/app/Console/Kernel.php
index 540f411478..b07bd8b5ec 100644
--- a/backend/app/Console/Kernel.php
+++ b/backend/app/Console/Kernel.php
@@ -6,6 +6,8 @@
use HiEvents\Jobs\Waitlist\ProcessExpiredWaitlistOffersJob;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
class Kernel extends ConsoleKernel
{
@@ -13,11 +15,18 @@ protected function schedule(Schedule $schedule): void
{
$schedule->job(new SendScheduledMessagesJob)->everyMinute()->withoutOverlapping();
$schedule->job(new ProcessExpiredWaitlistOffersJob)->everyMinute()->withoutOverlapping();
+
+ $schedule->call(function (): void {
+ $count = DB::table('failed_jobs')->count();
+ if ($count > 0) {
+ Log::warning('Failed jobs present in queue', ['count' => $count]);
+ }
+ })->everyFiveMinutes()->name('failed-jobs-monitor')->withoutOverlapping();
}
protected function commands(): void
{
- $this->load(__DIR__ . '/Commands');
+ $this->load(__DIR__.'/Commands');
include base_path('routes/console.php');
}
diff --git a/backend/app/DataTransferObjects/UpdateAdminAccountVatSettingDTO.php b/backend/app/DataTransferObjects/UpdateAdminOrganizerVatSettingDTO.php
similarity index 60%
rename from backend/app/DataTransferObjects/UpdateAdminAccountVatSettingDTO.php
rename to backend/app/DataTransferObjects/UpdateAdminOrganizerVatSettingDTO.php
index e7103610b0..2e64bebab2 100644
--- a/backend/app/DataTransferObjects/UpdateAdminAccountVatSettingDTO.php
+++ b/backend/app/DataTransferObjects/UpdateAdminOrganizerVatSettingDTO.php
@@ -2,13 +2,13 @@
namespace HiEvents\DataTransferObjects;
-class UpdateAdminAccountVatSettingDTO extends BaseDataObject
+class UpdateAdminOrganizerVatSettingDTO extends BaseDataObject
{
public function __construct(
- public readonly int $accountId,
- public readonly bool $vatRegistered,
+ public readonly int $organizerId,
+ public readonly bool $vatRegistered,
public readonly ?string $vatNumber = null,
- public readonly ?bool $vatValidated = null,
+ public readonly ?bool $vatValidated = null,
public readonly ?string $businessName = null,
public readonly ?string $businessAddress = null,
public readonly ?string $vatCountryCode = null,
diff --git a/backend/app/DataTransferObjects/UpdateAccountConfigurationDTO.php b/backend/app/DataTransferObjects/UpdateOrganizerConfigurationDTO.php
similarity index 58%
rename from backend/app/DataTransferObjects/UpdateAccountConfigurationDTO.php
rename to backend/app/DataTransferObjects/UpdateOrganizerConfigurationDTO.php
index 490085c8fa..4a6cfbada9 100644
--- a/backend/app/DataTransferObjects/UpdateAccountConfigurationDTO.php
+++ b/backend/app/DataTransferObjects/UpdateOrganizerConfigurationDTO.php
@@ -2,10 +2,10 @@
namespace HiEvents\DataTransferObjects;
-class UpdateAccountConfigurationDTO extends BaseDataObject
+class UpdateOrganizerConfigurationDTO extends BaseDataObject
{
public function __construct(
- public readonly int $accountId,
+ public readonly int $organizerId,
public readonly array $applicationFees,
)
{
diff --git a/backend/app/DomainObjects/AccountDomainObject.php b/backend/app/DomainObjects/AccountDomainObject.php
index 51920ed3d6..324638ad78 100644
--- a/backend/app/DomainObjects/AccountDomainObject.php
+++ b/backend/app/DomainObjects/AccountDomainObject.php
@@ -2,62 +2,10 @@
namespace HiEvents\DomainObjects;
-use HiEvents\DomainObjects\DTO\AccountApplicationFeeDTO;
-use HiEvents\DomainObjects\Enums\StripePlatform;
-use Illuminate\Support\Collection;
-
class AccountDomainObject extends Generated\AccountDomainObjectAbstract
{
- private ?AccountConfigurationDomainObject $configuration = null;
-
- /** @var Collection|null */
- private ?Collection $stripePlatforms = null;
-
- private ?AccountVatSettingDomainObject $accountVatSetting = null;
-
private ?AccountMessagingTierDomainObject $messagingTier = null;
- public function getApplicationFee(): AccountApplicationFeeDTO
- {
- /** @var AccountConfigurationDomainObject $applicationFee */
- $applicationFee = $this->getConfiguration();
-
- return new AccountApplicationFeeDTO(
- $applicationFee->getPercentageApplicationFee(),
- $applicationFee->getFixedApplicationFee()
- );
- }
-
- public function getConfiguration(): ?AccountConfigurationDomainObject
- {
- return $this->configuration;
- }
-
- public function setConfiguration(AccountConfigurationDomainObject $configuration): void
- {
- $this->configuration = $configuration;
- }
-
- public function getAccountStripePlatforms(): ?Collection
- {
- return $this->stripePlatforms;
- }
-
- public function setAccountStripePlatforms(Collection $stripePlatforms): void
- {
- $this->stripePlatforms = $stripePlatforms;
- }
-
- public function getAccountVatSetting(): ?AccountVatSettingDomainObject
- {
- return $this->accountVatSetting;
- }
-
- public function setAccountVatSetting(AccountVatSettingDomainObject $accountVatSetting): void
- {
- $this->accountVatSetting = $accountVatSetting;
- }
-
public function getMessagingTier(): ?AccountMessagingTierDomainObject
{
return $this->messagingTier;
@@ -67,58 +15,4 @@ public function setMessagingTier(AccountMessagingTierDomainObject $messagingTier
{
$this->messagingTier = $messagingTier;
}
-
- /**
- * Get the primary active Stripe platform for this account
- * Returns the platform with setup completed, preferring the most recent
- */
- public function getPrimaryStripePlatform(): ?AccountStripePlatformDomainObject
- {
- if (!$this->stripePlatforms || $this->stripePlatforms->isEmpty()) {
- return null;
- }
-
- return $this->stripePlatforms
- ->filter(fn($platform) => $platform->getStripeSetupCompletedAt() !== null)
- ->sortByDesc(fn($platform) => $platform->getCreatedAt())
- ->first();
- }
-
- /**
- * Get the Stripe platform for a specific platform type
- * Handles null platform for open-source installations
- */
- public function getStripePlatformByType(?StripePlatform $platformType): ?AccountStripePlatformDomainObject
- {
- if (!$this->stripePlatforms || $this->stripePlatforms->isEmpty()) {
- return null;
- }
-
- return $this->stripePlatforms
- ->filter(fn($platform) => $platform->getStripeConnectPlatform() === $platformType?->value)
- ->first();
- }
-
- public function getActiveStripeAccountId(): ?string
- {
- return $this->getPrimaryStripePlatform()?->getStripeAccountId();
- }
-
- public function getActiveStripePlatform(): ?StripePlatform
- {
- $primaryPlatform = $this->getPrimaryStripePlatform();
- if (!$primaryPlatform || !$primaryPlatform->getStripeConnectPlatform()) {
- return null;
- }
-
- return StripePlatform::fromString($primaryPlatform->getStripeConnectPlatform());
- }
-
- /**
- * Check if Stripe is set up and ready for payments
- */
- public function isStripeSetupComplete(): bool
- {
- return $this->getPrimaryStripePlatform() !== null;
- }
}
diff --git a/backend/app/DomainObjects/AttendeeDomainObject.php b/backend/app/DomainObjects/AttendeeDomainObject.php
index e6396e0f20..c95aa3f5b8 100644
--- a/backend/app/DomainObjects/AttendeeDomainObject.php
+++ b/backend/app/DomainObjects/AttendeeDomainObject.php
@@ -23,6 +23,8 @@ class AttendeeDomainObject extends Generated\AttendeeDomainObjectAbstract implem
/** @var Collection|null */
private ?Collection $checkIns = null;
+ private ?EventOccurrenceDomainObject $eventOccurrence = null;
+
public static function getDefaultSort(): string
{
return self::CREATED_AT;
@@ -71,6 +73,7 @@ public static function getAllowedFilterFields(): array
self::STATUS,
self::PRODUCT_ID,
self::PRODUCT_PRICE_ID,
+ self::EVENT_OCCURRENCE_ID,
];
}
@@ -138,4 +141,15 @@ public function getCheckIns(): ?Collection
{
return $this->checkIns;
}
+
+ public function setEventOccurrence(?EventOccurrenceDomainObject $eventOccurrence): AttendeeDomainObject
+ {
+ $this->eventOccurrence = $eventOccurrence;
+ return $this;
+ }
+
+ public function getEventOccurrence(): ?EventOccurrenceDomainObject
+ {
+ return $this->eventOccurrence;
+ }
}
diff --git a/backend/app/DomainObjects/CheckInListDomainObject.php b/backend/app/DomainObjects/CheckInListDomainObject.php
index ae55f3bbcf..3738c76fd1 100644
--- a/backend/app/DomainObjects/CheckInListDomainObject.php
+++ b/backend/app/DomainObjects/CheckInListDomainObject.php
@@ -7,12 +7,20 @@
use HiEvents\DomainObjects\SortingAndFiltering\AllowedSorts;
use Illuminate\Support\Collection;
+/**
+ * A `null` event_occurrence_id means the list applies to every occurrence of the
+ * event (the default for recurring events and the only meaningful value for
+ * single-occurrence events). A non-null value scopes the list to that one
+ * occurrence — only attendees on that occurrence can be checked in via the list.
+ */
class CheckInListDomainObject extends Generated\CheckInListDomainObjectAbstract implements IsSortable
{
private ?Collection $products = null;
private ?EventDomainObject $event = null;
+ private ?EventOccurrenceDomainObject $eventOccurrence = null;
+
private ?int $checkedInCount = null;
private ?int $totalAttendeesCount = null;
@@ -77,6 +85,18 @@ public function setEvent(?EventDomainObject $event): static
return $this;
}
+ public function getEventOccurrence(): ?EventOccurrenceDomainObject
+ {
+ return $this->eventOccurrence;
+ }
+
+ public function setEventOccurrence(?EventOccurrenceDomainObject $eventOccurrence): static
+ {
+ $this->eventOccurrence = $eventOccurrence;
+
+ return $this;
+ }
+
public function isExpired(string $timezone): bool
{
if ($this->getExpiresAt() === null) {
diff --git a/backend/app/DomainObjects/DTO/AccountApplicationFeeDTO.php b/backend/app/DomainObjects/DTO/AccountApplicationFeeDTO.php
deleted file mode 100644
index 2c36832e00..0000000000
--- a/backend/app/DomainObjects/DTO/AccountApplicationFeeDTO.php
+++ /dev/null
@@ -1,13 +0,0 @@
- __('Order Confirmation'),
self::ATTENDEE_TICKET => __('Attendee Ticket'),
+ self::OCCURRENCE_CANCELLATION => __('Date Cancellation'),
};
}
@@ -22,6 +24,16 @@ public function description(): string
return match ($this) {
self::ORDER_CONFIRMATION => __('Sent to the customer after placing an order'),
self::ATTENDEE_TICKET => __('Sent to each attendee with their ticket'),
+ self::OCCURRENCE_CANCELLATION => __('Sent to attendees when a scheduled date is cancelled'),
+ };
+ }
+
+ public function ctaUrlToken(): string
+ {
+ return match ($this) {
+ self::ORDER_CONFIRMATION => 'order.url',
+ self::ATTENDEE_TICKET => 'ticket.url',
+ self::OCCURRENCE_CANCELLATION => 'event.url',
};
}
}
\ No newline at end of file
diff --git a/backend/app/DomainObjects/Enums/EventCategory.php b/backend/app/DomainObjects/Enums/EventCategory.php
index 1e2a55f11e..680f8d70a1 100644
--- a/backend/app/DomainObjects/Enums/EventCategory.php
+++ b/backend/app/DomainObjects/Enums/EventCategory.php
@@ -8,7 +8,13 @@ enum EventCategory: string
// Community
case SOCIAL = 'SOCIAL';
+ case FAMILY = 'FAMILY';
+ case HOBBIES = 'HOBBIES';
case FOOD_DRINK = 'FOOD_DRINK';
+ case WELLNESS = 'WELLNESS';
+ case SPIRITUALITY = 'SPIRITUALITY';
+ case OUTDOORS = 'OUTDOORS';
+ case TOURS = 'TOURS';
case CHARITY = 'CHARITY';
// Creative & Culture
@@ -16,6 +22,8 @@ enum EventCategory: string
case ART = 'ART';
case COMEDY = 'COMEDY';
case THEATER = 'THEATER';
+ case FILM = 'FILM';
+ case DANCE = 'DANCE';
// Professional & Learning
case BUSINESS = 'BUSINESS';
@@ -26,6 +34,7 @@ enum EventCategory: string
// Leisure & Nightlife
case SPORTS = 'SPORTS';
case FESTIVAL = 'FESTIVAL';
+ case SEASONAL = 'SEASONAL';
case NIGHTLIFE = 'NIGHTLIFE';
// Catch-all
@@ -35,18 +44,27 @@ public function label(): string
{
return match ($this) {
self::SOCIAL => __('Social'),
+ self::FAMILY => __('Family'),
+ self::HOBBIES => __('Hobbies'),
self::FOOD_DRINK => __('Food & Drink'),
+ self::WELLNESS => __('Wellness'),
+ self::SPIRITUALITY => __('Spirituality'),
+ self::OUTDOORS => __('Outdoors'),
+ self::TOURS => __('Tours'),
self::CHARITY => __('Charity'),
self::MUSIC => __('Music'),
self::ART => __('Art'),
self::COMEDY => __('Comedy'),
self::THEATER => __('Theater'),
+ self::FILM => __('Film'),
+ self::DANCE => __('Dance'),
self::BUSINESS => __('Business'),
self::TECH => __('Tech'),
self::EDUCATION => __('Education'),
self::WORKSHOP => __('Workshop'),
self::SPORTS => __('Sports'),
self::FESTIVAL => __('Festival'),
+ self::SEASONAL => __('Seasonal'),
self::NIGHTLIFE => __('Nightlife'),
self::OTHER => __('Other'),
};
@@ -56,20 +74,29 @@ public function emoji(): string
{
return match ($this) {
self::SOCIAL => '🤝',
+ self::FAMILY => '👨👩👧👦',
+ self::HOBBIES => '🧩',
self::FOOD_DRINK => '🍽️',
+ self::WELLNESS => '🧘',
+ self::SPIRITUALITY => '🙏',
+ self::OUTDOORS => '🏞️',
+ self::TOURS => '🗺️',
self::CHARITY => '🎗️',
self::MUSIC => '🎵',
self::ART => '🎨',
self::COMEDY => '😂',
self::THEATER => '🎭',
+ self::FILM => '🎬',
+ self::DANCE => '💃',
self::BUSINESS => '💼',
self::TECH => '💻',
self::EDUCATION => '📚',
self::WORKSHOP => '🛠️',
self::SPORTS => '⚽',
- self::FESTIVAL => '🎉',
+ self::FESTIVAL => '🎪',
+ self::SEASONAL => '🎊',
self::NIGHTLIFE => '🪩',
- self::OTHER => '📝',
+ self::OTHER => '🤔',
};
}
}
diff --git a/backend/app/DomainObjects/Enums/EventType.php b/backend/app/DomainObjects/Enums/EventType.php
new file mode 100644
index 0000000000..4284a4ed80
--- /dev/null
+++ b/backend/app/DomainObjects/Enums/EventType.php
@@ -0,0 +1,11 @@
+ [
- 'asc' => __('Closest start date'),
- 'desc' => __('Furthest start date'),
- ],
- self::END_DATE => [
- 'asc' => __('Closest end date'),
- 'desc' => __('Furthest end date'),
- ],
self::CREATED_AT => [
'desc' => __('Newest first'),
'asc' => __('Oldest first'),
@@ -79,12 +73,12 @@ public static function getAllowedSorts(): AllowedSorts
public static function getDefaultSort(): string
{
- return self::START_DATE;
+ return self::CREATED_AT;
}
public static function getDefaultSortDirection(): string
{
- return 'asc';
+ return 'desc';
}
public function setProducts(Collection $products): self
@@ -178,58 +172,135 @@ public function getDescriptionPreview(): string
return StringHelper::previewFromHtml($this->getDescription());
}
+ public function setEventOccurrences(?Collection $eventOccurrences): self
+ {
+ $this->eventOccurrences = $eventOccurrences;
+ return $this;
+ }
+
+ public function getEventOccurrences(): ?Collection
+ {
+ return $this->eventOccurrences;
+ }
+
+ public function getStartDate(): ?string
+ {
+ if ($this->eventOccurrences === null || $this->eventOccurrences->isEmpty()) {
+ return null;
+ }
+
+ return $this->eventOccurrences->min(
+ fn(EventOccurrenceDomainObject $o) => $o->getStartDate()
+ );
+ }
+
+ public function getEndDate(): ?string
+ {
+ if ($this->eventOccurrences === null || $this->eventOccurrences->isEmpty()) {
+ return null;
+ }
+
+ $withEndDates = $this->eventOccurrences->filter(
+ fn(EventOccurrenceDomainObject $o) => $o->getEndDate() !== null
+ );
+
+ if ($withEndDates->isEmpty()) {
+ return $this->eventOccurrences->max(
+ fn(EventOccurrenceDomainObject $o) => $o->getStartDate()
+ );
+ }
+
+ return $withEndDates->max(
+ fn(EventOccurrenceDomainObject $o) => $o->getEndDate()
+ );
+ }
+
+ public function getNextOccurrenceStartDate(): ?string
+ {
+ if ($this->eventOccurrences === null || $this->eventOccurrences->isEmpty()) {
+ return null;
+ }
+
+ $now = Carbon::now();
+
+ $nextOccurrence = $this->eventOccurrences
+ ->filter(fn(EventOccurrenceDomainObject $o) => $o->getStatus() === EventOccurrenceStatus::ACTIVE->name)
+ ->filter(fn(EventOccurrenceDomainObject $o) => Carbon::parse($o->getStartDate(), 'UTC')->isFuture())
+ ->sortBy(fn(EventOccurrenceDomainObject $o) => $o->getStartDate())
+ ->first();
+
+ return $nextOccurrence?->getStartDate();
+ }
+
public function isEventInPast(): bool
{
- if ($this->getEndDate() === null) {
+ $endDate = $this->getEndDate();
+ if ($endDate === null) {
return false;
}
- $endDate = Carbon::parse($this->getEndDate());
- $endDate->setTimezone($this->getTimezone());
- return $endDate->isPast();
+ $parsed = Carbon::parse($endDate);
+ if ($this->getTimezone()) {
+ $parsed->setTimezone($this->getTimezone());
+ }
+
+ return $parsed->isPast();
}
public function isEventInFuture(): bool
{
- if ($this->getStartDate() === null) {
+ $startDate = $this->getStartDate();
+ if ($startDate === null) {
return false;
}
- $startDate = Carbon::parse($this->getStartDate());
- $startDate->setTimezone($this->getTimezone());
- return $startDate->isFuture();
+ $parsed = Carbon::parse($startDate);
+ if ($this->getTimezone()) {
+ $parsed->setTimezone($this->getTimezone());
+ }
+
+ return $parsed->isFuture();
}
public function isEventOngoing(): bool
{
- $startDate = Carbon::parse($this->getStartDate());
- $startDate->setTimezone($this->getTimezone());
-
- if ($this->getEndDate() === null) {
- return $startDate->isPast();
+ if ($this->eventOccurrences === null || $this->eventOccurrences->isEmpty()) {
+ return false;
}
- $endDate = Carbon::parse($this->getEndDate());
- $endDate->setTimezone($this->getTimezone());
+ foreach ($this->eventOccurrences as $occurrence) {
+ if ($occurrence->getStatus() !== EventOccurrenceStatus::ACTIVE->name) {
+ continue;
+ }
- return $startDate->isPast() && $endDate->isFuture();
+ $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;
}
public function getLifecycleStatus(): string
{
- if ($this->isEventInPast()) {
- return EventLifecycleStatus::ENDED->name;
+ if ($this->isEventOngoing()) {
+ return EventLifecycleStatus::ONGOING->name;
}
if ($this->isEventInFuture()) {
return EventLifecycleStatus::UPCOMING->name;
}
- if ($this->isEventOngoing()) {
- return EventLifecycleStatus::ONGOING->name;
- }
-
return EventLifecycleStatus::ENDED->name;
+
+ }
+
+ public function isRecurring(): bool
+ {
+ return $this->getType() === EventType::RECURRING->name;
}
public function getPromoCodes(): ?Collection
diff --git a/backend/app/DomainObjects/EventOccurrenceDailyStatisticDomainObject.php b/backend/app/DomainObjects/EventOccurrenceDailyStatisticDomainObject.php
new file mode 100644
index 0000000000..5de6d7ea22
--- /dev/null
+++ b/backend/app/DomainObjects/EventOccurrenceDailyStatisticDomainObject.php
@@ -0,0 +1,7 @@
+ [
+ 'asc' => __('Earliest first'),
+ 'desc' => __('Latest first'),
+ ],
+ ]
+ );
+ }
+
+ public static function getDefaultSort(): string
+ {
+ return self::START_DATE;
+ }
+
+ public static function getDefaultSortDirection(): string
+ {
+ return 'asc';
+ }
+
+ public function setEvent(?EventDomainObject $event): self
+ {
+ $this->event = $event;
+ return $this;
+ }
+
+ public function getEvent(): ?EventDomainObject
+ {
+ return $this->event;
+ }
+
+ public function setOrderItems(?Collection $orderItems): self
+ {
+ $this->orderItems = $orderItems;
+ return $this;
+ }
+
+ public function getOrderItems(): ?Collection
+ {
+ return $this->orderItems;
+ }
+
+ public function setAttendees(?Collection $attendees): self
+ {
+ $this->attendees = $attendees;
+ return $this;
+ }
+
+ public function getAttendees(): ?Collection
+ {
+ return $this->attendees;
+ }
+
+ public function setCheckInLists(?Collection $checkInLists): self
+ {
+ $this->checkInLists = $checkInLists;
+ return $this;
+ }
+
+ public function getCheckInLists(): ?Collection
+ {
+ return $this->checkInLists;
+ }
+
+ public function setPriceOverrides(?Collection $priceOverrides): self
+ {
+ $this->priceOverrides = $priceOverrides;
+ return $this;
+ }
+
+ public function getPriceOverrides(): ?Collection
+ {
+ return $this->priceOverrides;
+ }
+
+ public function setEventOccurrenceStatistics(?EventOccurrenceStatisticDomainObject $statistics): self
+ {
+ $this->eventOccurrenceStatistics = $statistics;
+ return $this;
+ }
+
+ public function getEventOccurrenceStatistics(): ?EventOccurrenceStatisticDomainObject
+ {
+ return $this->eventOccurrenceStatistics;
+ }
+
+ public function isActive(): bool
+ {
+ return $this->getStatus() === EventOccurrenceStatus::ACTIVE->name;
+ }
+
+ public function isCancelled(): bool
+ {
+ return $this->getStatus() === EventOccurrenceStatus::CANCELLED->name;
+ }
+
+ public function isSoldOut(): bool
+ {
+ return $this->getStatus() === EventOccurrenceStatus::SOLD_OUT->name;
+ }
+
+ public function isPast(): bool
+ {
+ $endDate = $this->getEndDate() ?? $this->getStartDate();
+ return Carbon::parse($endDate, 'UTC')->isPast();
+ }
+
+ public function isFuture(): bool
+ {
+ return Carbon::parse($this->getStartDate(), 'UTC')->isFuture();
+ }
+
+ public function getAvailableCapacity(): ?int
+ {
+ if ($this->getCapacity() === null) {
+ return null;
+ }
+
+ return max(0, $this->getCapacity() - $this->getUsedCapacity());
+ }
+}
diff --git a/backend/app/DomainObjects/EventOccurrenceStatisticDomainObject.php b/backend/app/DomainObjects/EventOccurrenceStatisticDomainObject.php
new file mode 100644
index 0000000000..90acec6eb5
--- /dev/null
+++ b/backend/app/DomainObjects/EventOccurrenceStatisticDomainObject.php
@@ -0,0 +1,7 @@
+ $this->attendee_id ?? null,
'event_id' => $this->event_id ?? null,
'order_id' => $this->order_id ?? null,
+ 'event_occurrence_id' => $this->event_occurrence_id ?? null,
'short_id' => $this->short_id ?? null,
'ip_address' => $this->ip_address ?? null,
'deleted_at' => $this->deleted_at ?? null,
@@ -117,6 +120,17 @@ public function getOrderId(): ?int
return $this->order_id;
}
+ public function setEventOccurrenceId(?int $event_occurrence_id): self
+ {
+ $this->event_occurrence_id = $event_occurrence_id;
+ return $this;
+ }
+
+ public function getEventOccurrenceId(): ?int
+ {
+ return $this->event_occurrence_id;
+ }
+
public function setShortId(string $short_id): self
{
$this->short_id = $short_id;
diff --git a/backend/app/DomainObjects/Generated/AttendeeDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/AttendeeDomainObjectAbstract.php
index be3ca97e0e..3ec48b8b8a 100644
--- a/backend/app/DomainObjects/Generated/AttendeeDomainObjectAbstract.php
+++ b/backend/app/DomainObjects/Generated/AttendeeDomainObjectAbstract.php
@@ -17,6 +17,7 @@ abstract class AttendeeDomainObjectAbstract extends \HiEvents\DomainObjects\Abst
final public const CHECKED_IN_BY = 'checked_in_by';
final public const CHECKED_OUT_BY = 'checked_out_by';
final public const PRODUCT_PRICE_ID = 'product_price_id';
+ final public const EVENT_OCCURRENCE_ID = 'event_occurrence_id';
final public const SHORT_ID = 'short_id';
final public const FIRST_NAME = 'first_name';
final public const LAST_NAME = 'last_name';
@@ -37,6 +38,7 @@ abstract class AttendeeDomainObjectAbstract extends \HiEvents\DomainObjects\Abst
protected ?int $checked_in_by = null;
protected ?int $checked_out_by = null;
protected int $product_price_id;
+ protected ?int $event_occurrence_id = null;
protected string $short_id;
protected string $first_name = '';
protected string $last_name = '';
@@ -60,6 +62,7 @@ public function toArray(): array
'checked_in_by' => $this->checked_in_by ?? null,
'checked_out_by' => $this->checked_out_by ?? null,
'product_price_id' => $this->product_price_id ?? null,
+ 'event_occurrence_id' => $this->event_occurrence_id ?? null,
'short_id' => $this->short_id ?? null,
'first_name' => $this->first_name ?? null,
'last_name' => $this->last_name ?? null,
@@ -152,6 +155,17 @@ public function getProductPriceId(): int
return $this->product_price_id;
}
+ public function setEventOccurrenceId(?int $event_occurrence_id): self
+ {
+ $this->event_occurrence_id = $event_occurrence_id;
+ return $this;
+ }
+
+ public function getEventOccurrenceId(): ?int
+ {
+ return $this->event_occurrence_id;
+ }
+
public function setShortId(string $short_id): self
{
$this->short_id = $short_id;
diff --git a/backend/app/DomainObjects/Generated/CheckInListDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/CheckInListDomainObjectAbstract.php
index 3ce9ebb1dc..86e0ed7558 100644
--- a/backend/app/DomainObjects/Generated/CheckInListDomainObjectAbstract.php
+++ b/backend/app/DomainObjects/Generated/CheckInListDomainObjectAbstract.php
@@ -12,6 +12,7 @@ abstract class CheckInListDomainObjectAbstract extends \HiEvents\DomainObjects\A
final public const PLURAL_NAME = 'check_in_lists';
final public const ID = 'id';
final public const EVENT_ID = 'event_id';
+ final public const EVENT_OCCURRENCE_ID = 'event_occurrence_id';
final public const SHORT_ID = 'short_id';
final public const NAME = 'name';
final public const DESCRIPTION = 'description';
@@ -20,9 +21,14 @@ abstract class CheckInListDomainObjectAbstract extends \HiEvents\DomainObjects\A
final public const DELETED_AT = 'deleted_at';
final public const CREATED_AT = 'created_at';
final public const UPDATED_AT = 'updated_at';
+ final public const PUBLIC_SHOW_ATTENDEE_NOTES = 'public_show_attendee_notes';
+ final public const PUBLIC_SHOW_QUESTION_ANSWERS = 'public_show_question_answers';
+ final public const PUBLIC_SHOW_ORDER_DETAILS = 'public_show_order_details';
+ final public const IS_SYSTEM_DEFAULT = 'is_system_default';
protected int $id;
protected int $event_id;
+ protected ?int $event_occurrence_id = null;
protected string $short_id;
protected string $name;
protected ?string $description = null;
@@ -31,12 +37,17 @@ abstract class CheckInListDomainObjectAbstract extends \HiEvents\DomainObjects\A
protected ?string $deleted_at = null;
protected ?string $created_at = null;
protected ?string $updated_at = null;
+ protected bool $public_show_attendee_notes = true;
+ protected bool $public_show_question_answers = true;
+ protected bool $public_show_order_details = true;
+ protected bool $is_system_default = false;
public function toArray(): array
{
return [
'id' => $this->id ?? null,
'event_id' => $this->event_id ?? null,
+ 'event_occurrence_id' => $this->event_occurrence_id ?? null,
'short_id' => $this->short_id ?? null,
'name' => $this->name ?? null,
'description' => $this->description ?? null,
@@ -45,6 +56,10 @@ public function toArray(): array
'deleted_at' => $this->deleted_at ?? null,
'created_at' => $this->created_at ?? null,
'updated_at' => $this->updated_at ?? null,
+ 'public_show_attendee_notes' => $this->public_show_attendee_notes ?? null,
+ 'public_show_question_answers' => $this->public_show_question_answers ?? null,
+ 'public_show_order_details' => $this->public_show_order_details ?? null,
+ 'is_system_default' => $this->is_system_default ?? null,
];
}
@@ -70,6 +85,17 @@ public function getEventId(): int
return $this->event_id;
}
+ public function setEventOccurrenceId(?int $event_occurrence_id): self
+ {
+ $this->event_occurrence_id = $event_occurrence_id;
+ return $this;
+ }
+
+ public function getEventOccurrenceId(): ?int
+ {
+ return $this->event_occurrence_id;
+ }
+
public function setShortId(string $short_id): self
{
$this->short_id = $short_id;
@@ -157,4 +183,48 @@ public function getUpdatedAt(): ?string
{
return $this->updated_at;
}
+
+ public function setPublicShowAttendeeNotes(bool $public_show_attendee_notes): self
+ {
+ $this->public_show_attendee_notes = $public_show_attendee_notes;
+ return $this;
+ }
+
+ public function getPublicShowAttendeeNotes(): bool
+ {
+ return $this->public_show_attendee_notes;
+ }
+
+ public function setPublicShowQuestionAnswers(bool $public_show_question_answers): self
+ {
+ $this->public_show_question_answers = $public_show_question_answers;
+ return $this;
+ }
+
+ public function getPublicShowQuestionAnswers(): bool
+ {
+ return $this->public_show_question_answers;
+ }
+
+ public function setPublicShowOrderDetails(bool $public_show_order_details): self
+ {
+ $this->public_show_order_details = $public_show_order_details;
+ return $this;
+ }
+
+ public function getPublicShowOrderDetails(): bool
+ {
+ return $this->public_show_order_details;
+ }
+
+ public function setIsSystemDefault(bool $is_system_default): self
+ {
+ $this->is_system_default = $is_system_default;
+ return $this;
+ }
+
+ public function getIsSystemDefault(): bool
+ {
+ return $this->is_system_default;
+ }
}
diff --git a/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php
index d40f62026b..8badec5cd6 100644
--- a/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php
+++ b/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php
@@ -15,8 +15,6 @@ abstract class EventDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac
final public const USER_ID = 'user_id';
final public const ORGANIZER_ID = 'organizer_id';
final public const TITLE = 'title';
- final public const START_DATE = 'start_date';
- final public const END_DATE = 'end_date';
final public const DESCRIPTION = 'description';
final public const STATUS = 'status';
final public const LOCATION_DETAILS = 'location_details';
@@ -30,14 +28,14 @@ abstract class EventDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac
final public const SHORT_ID = 'short_id';
final public const TICKET_QUANTITY_AVAILABLE = 'ticket_quantity_available';
final public const CATEGORY = 'category';
+ final public const TYPE = 'type';
+ final public const RECURRENCE_RULE = 'recurrence_rule';
protected int $id;
protected int $account_id;
protected int $user_id;
protected ?int $organizer_id = null;
protected string $title;
- protected ?string $start_date = null;
- protected ?string $end_date = null;
protected ?string $description = null;
protected ?string $status = null;
protected array|string|null $location_details = null;
@@ -51,6 +49,8 @@ abstract class EventDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac
protected string $short_id;
protected ?int $ticket_quantity_available = null;
protected string $category = 'OTHER';
+ protected string $type = 'SINGLE';
+ protected array|string|null $recurrence_rule = null;
public function toArray(): array
{
@@ -60,8 +60,6 @@ public function toArray(): array
'user_id' => $this->user_id ?? null,
'organizer_id' => $this->organizer_id ?? null,
'title' => $this->title ?? null,
- 'start_date' => $this->start_date ?? null,
- 'end_date' => $this->end_date ?? null,
'description' => $this->description ?? null,
'status' => $this->status ?? null,
'location_details' => $this->location_details ?? null,
@@ -75,6 +73,8 @@ public function toArray(): array
'short_id' => $this->short_id ?? null,
'ticket_quantity_available' => $this->ticket_quantity_available ?? null,
'category' => $this->category ?? null,
+ 'type' => $this->type ?? null,
+ 'recurrence_rule' => $this->recurrence_rule ?? null,
];
}
@@ -133,28 +133,6 @@ public function getTitle(): string
return $this->title;
}
- public function setStartDate(?string $start_date): self
- {
- $this->start_date = $start_date;
- return $this;
- }
-
- public function getStartDate(): ?string
- {
- return $this->start_date;
- }
-
- public function setEndDate(?string $end_date): self
- {
- $this->end_date = $end_date;
- return $this;
- }
-
- public function getEndDate(): ?string
- {
- return $this->end_date;
- }
-
public function setDescription(?string $description): self
{
$this->description = $description;
@@ -297,4 +275,26 @@ public function getCategory(): string
{
return $this->category;
}
+
+ public function setType(string $type): self
+ {
+ $this->type = $type;
+ return $this;
+ }
+
+ public function getType(): string
+ {
+ return $this->type;
+ }
+
+ public function setRecurrenceRule(array|string|null $recurrence_rule): self
+ {
+ $this->recurrence_rule = $recurrence_rule;
+ return $this;
+ }
+
+ public function getRecurrenceRule(): array|string|null
+ {
+ return $this->recurrence_rule;
+ }
}
diff --git a/backend/app/DomainObjects/Generated/EventOccurrenceDailyStatisticDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventOccurrenceDailyStatisticDomainObjectAbstract.php
new file mode 100644
index 0000000000..5a909a0c05
--- /dev/null
+++ b/backend/app/DomainObjects/Generated/EventOccurrenceDailyStatisticDomainObjectAbstract.php
@@ -0,0 +1,258 @@
+ $this->id ?? null,
+ 'event_id' => $this->event_id ?? null,
+ 'event_occurrence_id' => $this->event_occurrence_id ?? null,
+ 'date' => $this->date ?? null,
+ 'products_sold' => $this->products_sold ?? null,
+ 'attendees_registered' => $this->attendees_registered ?? null,
+ 'sales_total_gross' => $this->sales_total_gross ?? null,
+ 'sales_total_before_additions' => $this->sales_total_before_additions ?? null,
+ 'total_tax' => $this->total_tax ?? null,
+ 'total_fee' => $this->total_fee ?? null,
+ 'orders_created' => $this->orders_created ?? null,
+ 'orders_cancelled' => $this->orders_cancelled ?? null,
+ 'total_refunded' => $this->total_refunded ?? null,
+ 'version' => $this->version ?? null,
+ 'created_at' => $this->created_at ?? null,
+ 'updated_at' => $this->updated_at ?? null,
+ 'deleted_at' => $this->deleted_at ?? null,
+ ];
+ }
+
+ public function setId(int $id): self
+ {
+ $this->id = $id;
+ return $this;
+ }
+
+ public function getId(): int
+ {
+ return $this->id;
+ }
+
+ public function setEventId(int $event_id): self
+ {
+ $this->event_id = $event_id;
+ return $this;
+ }
+
+ public function getEventId(): int
+ {
+ return $this->event_id;
+ }
+
+ public function setEventOccurrenceId(int $event_occurrence_id): self
+ {
+ $this->event_occurrence_id = $event_occurrence_id;
+ return $this;
+ }
+
+ public function getEventOccurrenceId(): int
+ {
+ return $this->event_occurrence_id;
+ }
+
+ public function setDate(string $date): self
+ {
+ $this->date = $date;
+ return $this;
+ }
+
+ public function getDate(): string
+ {
+ return $this->date;
+ }
+
+ public function setProductsSold(int $products_sold): self
+ {
+ $this->products_sold = $products_sold;
+ return $this;
+ }
+
+ public function getProductsSold(): int
+ {
+ return $this->products_sold;
+ }
+
+ public function setAttendeesRegistered(int $attendees_registered): self
+ {
+ $this->attendees_registered = $attendees_registered;
+ return $this;
+ }
+
+ public function getAttendeesRegistered(): int
+ {
+ return $this->attendees_registered;
+ }
+
+ public function setSalesTotalGross(float $sales_total_gross): self
+ {
+ $this->sales_total_gross = $sales_total_gross;
+ return $this;
+ }
+
+ public function getSalesTotalGross(): float
+ {
+ return $this->sales_total_gross;
+ }
+
+ public function setSalesTotalBeforeAdditions(float $sales_total_before_additions): self
+ {
+ $this->sales_total_before_additions = $sales_total_before_additions;
+ return $this;
+ }
+
+ public function getSalesTotalBeforeAdditions(): float
+ {
+ return $this->sales_total_before_additions;
+ }
+
+ public function setTotalTax(float $total_tax): self
+ {
+ $this->total_tax = $total_tax;
+ return $this;
+ }
+
+ public function getTotalTax(): float
+ {
+ return $this->total_tax;
+ }
+
+ public function setTotalFee(float $total_fee): self
+ {
+ $this->total_fee = $total_fee;
+ return $this;
+ }
+
+ public function getTotalFee(): float
+ {
+ return $this->total_fee;
+ }
+
+ public function setOrdersCreated(int $orders_created): self
+ {
+ $this->orders_created = $orders_created;
+ return $this;
+ }
+
+ public function getOrdersCreated(): int
+ {
+ return $this->orders_created;
+ }
+
+ public function setOrdersCancelled(int $orders_cancelled): self
+ {
+ $this->orders_cancelled = $orders_cancelled;
+ return $this;
+ }
+
+ public function getOrdersCancelled(): int
+ {
+ return $this->orders_cancelled;
+ }
+
+ public function setTotalRefunded(float $total_refunded): self
+ {
+ $this->total_refunded = $total_refunded;
+ return $this;
+ }
+
+ public function getTotalRefunded(): float
+ {
+ return $this->total_refunded;
+ }
+
+ public function setVersion(int $version): self
+ {
+ $this->version = $version;
+ return $this;
+ }
+
+ public function getVersion(): int
+ {
+ return $this->version;
+ }
+
+ public function setCreatedAt(?string $created_at): self
+ {
+ $this->created_at = $created_at;
+ return $this;
+ }
+
+ public function getCreatedAt(): ?string
+ {
+ return $this->created_at;
+ }
+
+ public function setUpdatedAt(?string $updated_at): self
+ {
+ $this->updated_at = $updated_at;
+ return $this;
+ }
+
+ public function getUpdatedAt(): ?string
+ {
+ return $this->updated_at;
+ }
+
+ public function setDeletedAt(?string $deleted_at): self
+ {
+ $this->deleted_at = $deleted_at;
+ return $this;
+ }
+
+ public function getDeletedAt(): ?string
+ {
+ return $this->deleted_at;
+ }
+}
diff --git a/backend/app/DomainObjects/Generated/EventOccurrenceDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventOccurrenceDomainObjectAbstract.php
new file mode 100644
index 0000000000..4c6f465bf5
--- /dev/null
+++ b/backend/app/DomainObjects/Generated/EventOccurrenceDomainObjectAbstract.php
@@ -0,0 +1,202 @@
+ $this->id ?? null,
+ 'event_id' => $this->event_id ?? null,
+ 'short_id' => $this->short_id ?? null,
+ 'start_date' => $this->start_date ?? null,
+ 'end_date' => $this->end_date ?? null,
+ 'status' => $this->status ?? null,
+ 'capacity' => $this->capacity ?? null,
+ 'used_capacity' => $this->used_capacity ?? null,
+ 'label' => $this->label ?? null,
+ 'is_overridden' => $this->is_overridden ?? null,
+ 'created_at' => $this->created_at ?? null,
+ 'updated_at' => $this->updated_at ?? null,
+ 'deleted_at' => $this->deleted_at ?? null,
+ ];
+ }
+
+ public function setId(int $id): self
+ {
+ $this->id = $id;
+ return $this;
+ }
+
+ public function getId(): int
+ {
+ return $this->id;
+ }
+
+ public function setEventId(int $event_id): self
+ {
+ $this->event_id = $event_id;
+ return $this;
+ }
+
+ public function getEventId(): int
+ {
+ return $this->event_id;
+ }
+
+ public function setShortId(string $short_id): self
+ {
+ $this->short_id = $short_id;
+ return $this;
+ }
+
+ public function getShortId(): string
+ {
+ return $this->short_id;
+ }
+
+ public function setStartDate(string $start_date): self
+ {
+ $this->start_date = $start_date;
+ return $this;
+ }
+
+ public function getStartDate(): string
+ {
+ return $this->start_date;
+ }
+
+ public function setEndDate(?string $end_date): self
+ {
+ $this->end_date = $end_date;
+ return $this;
+ }
+
+ public function getEndDate(): ?string
+ {
+ return $this->end_date;
+ }
+
+ public function setStatus(string $status): self
+ {
+ $this->status = $status;
+ return $this;
+ }
+
+ public function getStatus(): string
+ {
+ return $this->status;
+ }
+
+ public function setCapacity(?int $capacity): self
+ {
+ $this->capacity = $capacity;
+ return $this;
+ }
+
+ public function getCapacity(): ?int
+ {
+ return $this->capacity;
+ }
+
+ public function setUsedCapacity(int $used_capacity): self
+ {
+ $this->used_capacity = $used_capacity;
+ return $this;
+ }
+
+ public function getUsedCapacity(): int
+ {
+ return $this->used_capacity;
+ }
+
+ public function setLabel(?string $label): self
+ {
+ $this->label = $label;
+ return $this;
+ }
+
+ public function getLabel(): ?string
+ {
+ return $this->label;
+ }
+
+ public function setIsOverridden(bool $is_overridden): self
+ {
+ $this->is_overridden = $is_overridden;
+ return $this;
+ }
+
+ public function getIsOverridden(): bool
+ {
+ return $this->is_overridden;
+ }
+
+ public function setCreatedAt(?string $created_at): self
+ {
+ $this->created_at = $created_at;
+ return $this;
+ }
+
+ public function getCreatedAt(): ?string
+ {
+ return $this->created_at;
+ }
+
+ public function setUpdatedAt(?string $updated_at): self
+ {
+ $this->updated_at = $updated_at;
+ return $this;
+ }
+
+ public function getUpdatedAt(): ?string
+ {
+ return $this->updated_at;
+ }
+
+ public function setDeletedAt(?string $deleted_at): self
+ {
+ $this->deleted_at = $deleted_at;
+ return $this;
+ }
+
+ public function getDeletedAt(): ?string
+ {
+ return $this->deleted_at;
+ }
+}
diff --git a/backend/app/DomainObjects/Generated/EventOccurrenceStatisticDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventOccurrenceStatisticDomainObjectAbstract.php
new file mode 100644
index 0000000000..458ef5732f
--- /dev/null
+++ b/backend/app/DomainObjects/Generated/EventOccurrenceStatisticDomainObjectAbstract.php
@@ -0,0 +1,244 @@
+ $this->id ?? null,
+ 'event_id' => $this->event_id ?? null,
+ 'event_occurrence_id' => $this->event_occurrence_id ?? null,
+ 'products_sold' => $this->products_sold ?? null,
+ 'attendees_registered' => $this->attendees_registered ?? null,
+ 'sales_total_gross' => $this->sales_total_gross ?? null,
+ 'sales_total_before_additions' => $this->sales_total_before_additions ?? null,
+ 'total_tax' => $this->total_tax ?? null,
+ 'total_fee' => $this->total_fee ?? null,
+ 'orders_created' => $this->orders_created ?? null,
+ 'orders_cancelled' => $this->orders_cancelled ?? null,
+ 'total_refunded' => $this->total_refunded ?? null,
+ 'version' => $this->version ?? null,
+ 'created_at' => $this->created_at ?? null,
+ 'updated_at' => $this->updated_at ?? null,
+ 'deleted_at' => $this->deleted_at ?? null,
+ ];
+ }
+
+ public function setId(int $id): self
+ {
+ $this->id = $id;
+ return $this;
+ }
+
+ public function getId(): int
+ {
+ return $this->id;
+ }
+
+ public function setEventId(int $event_id): self
+ {
+ $this->event_id = $event_id;
+ return $this;
+ }
+
+ public function getEventId(): int
+ {
+ return $this->event_id;
+ }
+
+ public function setEventOccurrenceId(int $event_occurrence_id): self
+ {
+ $this->event_occurrence_id = $event_occurrence_id;
+ return $this;
+ }
+
+ public function getEventOccurrenceId(): int
+ {
+ return $this->event_occurrence_id;
+ }
+
+ public function setProductsSold(int $products_sold): self
+ {
+ $this->products_sold = $products_sold;
+ return $this;
+ }
+
+ public function getProductsSold(): int
+ {
+ return $this->products_sold;
+ }
+
+ public function setAttendeesRegistered(int $attendees_registered): self
+ {
+ $this->attendees_registered = $attendees_registered;
+ return $this;
+ }
+
+ public function getAttendeesRegistered(): int
+ {
+ return $this->attendees_registered;
+ }
+
+ public function setSalesTotalGross(float $sales_total_gross): self
+ {
+ $this->sales_total_gross = $sales_total_gross;
+ return $this;
+ }
+
+ public function getSalesTotalGross(): float
+ {
+ return $this->sales_total_gross;
+ }
+
+ public function setSalesTotalBeforeAdditions(float $sales_total_before_additions): self
+ {
+ $this->sales_total_before_additions = $sales_total_before_additions;
+ return $this;
+ }
+
+ public function getSalesTotalBeforeAdditions(): float
+ {
+ return $this->sales_total_before_additions;
+ }
+
+ public function setTotalTax(float $total_tax): self
+ {
+ $this->total_tax = $total_tax;
+ return $this;
+ }
+
+ public function getTotalTax(): float
+ {
+ return $this->total_tax;
+ }
+
+ public function setTotalFee(float $total_fee): self
+ {
+ $this->total_fee = $total_fee;
+ return $this;
+ }
+
+ public function getTotalFee(): float
+ {
+ return $this->total_fee;
+ }
+
+ public function setOrdersCreated(int $orders_created): self
+ {
+ $this->orders_created = $orders_created;
+ return $this;
+ }
+
+ public function getOrdersCreated(): int
+ {
+ return $this->orders_created;
+ }
+
+ public function setOrdersCancelled(int $orders_cancelled): self
+ {
+ $this->orders_cancelled = $orders_cancelled;
+ return $this;
+ }
+
+ public function getOrdersCancelled(): int
+ {
+ return $this->orders_cancelled;
+ }
+
+ public function setTotalRefunded(float $total_refunded): self
+ {
+ $this->total_refunded = $total_refunded;
+ return $this;
+ }
+
+ public function getTotalRefunded(): float
+ {
+ return $this->total_refunded;
+ }
+
+ public function setVersion(int $version): self
+ {
+ $this->version = $version;
+ return $this;
+ }
+
+ public function getVersion(): int
+ {
+ return $this->version;
+ }
+
+ public function setCreatedAt(?string $created_at): self
+ {
+ $this->created_at = $created_at;
+ return $this;
+ }
+
+ public function getCreatedAt(): ?string
+ {
+ return $this->created_at;
+ }
+
+ public function setUpdatedAt(?string $updated_at): self
+ {
+ $this->updated_at = $updated_at;
+ return $this;
+ }
+
+ public function getUpdatedAt(): ?string
+ {
+ return $this->updated_at;
+ }
+
+ public function setDeletedAt(?string $deleted_at): self
+ {
+ $this->deleted_at = $deleted_at;
+ return $this;
+ }
+
+ public function getDeletedAt(): ?string
+ {
+ return $this->deleted_at;
+ }
+}
diff --git a/backend/app/DomainObjects/Generated/MessageDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/MessageDomainObjectAbstract.php
index 30fdcfcff0..582014db76 100644
--- a/backend/app/DomainObjects/Generated/MessageDomainObjectAbstract.php
+++ b/backend/app/DomainObjects/Generated/MessageDomainObjectAbstract.php
@@ -13,6 +13,7 @@ abstract class MessageDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr
final public const ID = 'id';
final public const EVENT_ID = 'event_id';
final public const SENT_BY_USER_ID = 'sent_by_user_id';
+ final public const EVENT_OCCURRENCE_ID = 'event_occurrence_id';
final public const SUBJECT = 'subject';
final public const MESSAGE = 'message';
final public const TYPE = 'type';
@@ -32,6 +33,7 @@ abstract class MessageDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr
protected int $id;
protected int $event_id;
protected int $sent_by_user_id;
+ protected ?int $event_occurrence_id = null;
protected string $subject;
protected string $message;
protected string $type;
@@ -54,6 +56,7 @@ public function toArray(): array
'id' => $this->id ?? null,
'event_id' => $this->event_id ?? null,
'sent_by_user_id' => $this->sent_by_user_id ?? null,
+ 'event_occurrence_id' => $this->event_occurrence_id ?? null,
'subject' => $this->subject ?? null,
'message' => $this->message ?? null,
'type' => $this->type ?? null,
@@ -105,6 +108,17 @@ public function getSentByUserId(): int
return $this->sent_by_user_id;
}
+ public function setEventOccurrenceId(?int $event_occurrence_id): self
+ {
+ $this->event_occurrence_id = $event_occurrence_id;
+ return $this;
+ }
+
+ public function getEventOccurrenceId(): ?int
+ {
+ return $this->event_occurrence_id;
+ }
+
public function setSubject(string $subject): self
{
$this->subject = $subject;
diff --git a/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php
index 076da8954c..b50ba6ecfc 100644
--- a/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php
+++ b/backend/app/DomainObjects/Generated/OrderItemDomainObjectAbstract.php
@@ -14,6 +14,7 @@ abstract class OrderItemDomainObjectAbstract extends \HiEvents\DomainObjects\Abs
final public const ORDER_ID = 'order_id';
final public const PRODUCT_ID = 'product_id';
final public const PRODUCT_PRICE_ID = 'product_price_id';
+ final public const EVENT_OCCURRENCE_ID = 'event_occurrence_id';
final public const TOTAL_BEFORE_ADDITIONS = 'total_before_additions';
final public const QUANTITY = 'quantity';
final public const ITEM_NAME = 'item_name';
@@ -30,6 +31,7 @@ abstract class OrderItemDomainObjectAbstract extends \HiEvents\DomainObjects\Abs
protected int $order_id;
protected int $product_id;
protected int $product_price_id;
+ protected ?int $event_occurrence_id = null;
protected float $total_before_additions;
protected int $quantity;
protected ?string $item_name = null;
@@ -49,6 +51,7 @@ public function toArray(): array
'order_id' => $this->order_id ?? null,
'product_id' => $this->product_id ?? null,
'product_price_id' => $this->product_price_id ?? null,
+ 'event_occurrence_id' => $this->event_occurrence_id ?? null,
'total_before_additions' => $this->total_before_additions ?? null,
'quantity' => $this->quantity ?? null,
'item_name' => $this->item_name ?? null,
@@ -107,6 +110,17 @@ public function getProductPriceId(): int
return $this->product_price_id;
}
+ public function setEventOccurrenceId(?int $event_occurrence_id): self
+ {
+ $this->event_occurrence_id = $event_occurrence_id;
+ return $this;
+ }
+
+ public function getEventOccurrenceId(): ?int
+ {
+ return $this->event_occurrence_id;
+ }
+
public function setTotalBeforeAdditions(float $total_before_additions): self
{
$this->total_before_additions = $total_before_additions;
diff --git a/backend/app/DomainObjects/Generated/OrganizerConfigurationDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrganizerConfigurationDomainObjectAbstract.php
new file mode 100644
index 0000000000..229df661c5
--- /dev/null
+++ b/backend/app/DomainObjects/Generated/OrganizerConfigurationDomainObjectAbstract.php
@@ -0,0 +1,146 @@
+ $this->id ?? null,
+ 'name' => $this->name ?? null,
+ 'is_system_default' => $this->is_system_default ?? null,
+ 'application_fees' => $this->application_fees ?? null,
+ 'bypass_application_fees' => $this->bypass_application_fees ?? null,
+ 'legacy_account_configuration_id' => $this->legacy_account_configuration_id ?? null,
+ 'created_at' => $this->created_at ?? null,
+ 'updated_at' => $this->updated_at ?? null,
+ 'deleted_at' => $this->deleted_at ?? null,
+ ];
+ }
+
+ public function setId(int $id): self
+ {
+ $this->id = $id;
+ return $this;
+ }
+
+ public function getId(): int
+ {
+ return $this->id;
+ }
+
+ public function setName(string $name): self
+ {
+ $this->name = $name;
+ return $this;
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function setIsSystemDefault(bool $is_system_default): self
+ {
+ $this->is_system_default = $is_system_default;
+ return $this;
+ }
+
+ public function getIsSystemDefault(): bool
+ {
+ return $this->is_system_default;
+ }
+
+ public function setApplicationFees(array|string|null $application_fees): self
+ {
+ $this->application_fees = $application_fees;
+ return $this;
+ }
+
+ public function getApplicationFees(): array|string|null
+ {
+ return $this->application_fees;
+ }
+
+ public function setBypassApplicationFees(bool $bypass_application_fees): self
+ {
+ $this->bypass_application_fees = $bypass_application_fees;
+ return $this;
+ }
+
+ public function getBypassApplicationFees(): bool
+ {
+ return $this->bypass_application_fees;
+ }
+
+ public function setLegacyAccountConfigurationId(?int $legacy_account_configuration_id): self
+ {
+ $this->legacy_account_configuration_id = $legacy_account_configuration_id;
+ return $this;
+ }
+
+ public function getLegacyAccountConfigurationId(): ?int
+ {
+ return $this->legacy_account_configuration_id;
+ }
+
+ public function setCreatedAt(?string $created_at): self
+ {
+ $this->created_at = $created_at;
+ return $this;
+ }
+
+ public function getCreatedAt(): ?string
+ {
+ return $this->created_at;
+ }
+
+ public function setUpdatedAt(?string $updated_at): self
+ {
+ $this->updated_at = $updated_at;
+ return $this;
+ }
+
+ public function getUpdatedAt(): ?string
+ {
+ return $this->updated_at;
+ }
+
+ public function setDeletedAt(?string $deleted_at): self
+ {
+ $this->deleted_at = $deleted_at;
+ return $this;
+ }
+
+ public function getDeletedAt(): ?string
+ {
+ return $this->deleted_at;
+ }
+}
diff --git a/backend/app/DomainObjects/Generated/OrganizerDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrganizerDomainObjectAbstract.php
index dc6d66aab5..6738021dfd 100644
--- a/backend/app/DomainObjects/Generated/OrganizerDomainObjectAbstract.php
+++ b/backend/app/DomainObjects/Generated/OrganizerDomainObjectAbstract.php
@@ -12,6 +12,7 @@ abstract class OrganizerDomainObjectAbstract extends \HiEvents\DomainObjects\Abs
final public const PLURAL_NAME = 'organizers';
final public const ID = 'id';
final public const ACCOUNT_ID = 'account_id';
+ final public const ORGANIZER_CONFIGURATION_ID = 'organizer_configuration_id';
final public const NAME = 'name';
final public const EMAIL = 'email';
final public const PHONE = 'phone';
@@ -26,6 +27,7 @@ abstract class OrganizerDomainObjectAbstract extends \HiEvents\DomainObjects\Abs
protected int $id;
protected int $account_id;
+ protected ?int $organizer_configuration_id = null;
protected string $name;
protected string $email;
protected ?string $phone = null;
@@ -43,6 +45,7 @@ public function toArray(): array
return [
'id' => $this->id ?? null,
'account_id' => $this->account_id ?? null,
+ 'organizer_configuration_id' => $this->organizer_configuration_id ?? null,
'name' => $this->name ?? null,
'email' => $this->email ?? null,
'phone' => $this->phone ?? null,
@@ -79,6 +82,17 @@ public function getAccountId(): int
return $this->account_id;
}
+ public function setOrganizerConfigurationId(?int $organizer_configuration_id): self
+ {
+ $this->organizer_configuration_id = $organizer_configuration_id;
+ return $this;
+ }
+
+ public function getOrganizerConfigurationId(): ?int
+ {
+ return $this->organizer_configuration_id;
+ }
+
public function setName(string $name): self
{
$this->name = $name;
diff --git a/backend/app/DomainObjects/Generated/OrganizerStripePlatformDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrganizerStripePlatformDomainObjectAbstract.php
new file mode 100644
index 0000000000..88fe91ce03
--- /dev/null
+++ b/backend/app/DomainObjects/Generated/OrganizerStripePlatformDomainObjectAbstract.php
@@ -0,0 +1,160 @@
+ $this->id ?? null,
+ 'organizer_id' => $this->organizer_id ?? null,
+ 'stripe_connect_account_type' => $this->stripe_connect_account_type ?? null,
+ 'stripe_connect_platform' => $this->stripe_connect_platform ?? null,
+ 'stripe_account_id' => $this->stripe_account_id ?? null,
+ 'stripe_setup_completed_at' => $this->stripe_setup_completed_at ?? null,
+ 'stripe_account_details' => $this->stripe_account_details ?? null,
+ 'created_at' => $this->created_at ?? null,
+ 'updated_at' => $this->updated_at ?? null,
+ 'deleted_at' => $this->deleted_at ?? null,
+ ];
+ }
+
+ public function setId(int $id): self
+ {
+ $this->id = $id;
+ return $this;
+ }
+
+ public function getId(): int
+ {
+ return $this->id;
+ }
+
+ public function setOrganizerId(int $organizer_id): self
+ {
+ $this->organizer_id = $organizer_id;
+ return $this;
+ }
+
+ public function getOrganizerId(): int
+ {
+ return $this->organizer_id;
+ }
+
+ public function setStripeConnectAccountType(?string $stripe_connect_account_type): self
+ {
+ $this->stripe_connect_account_type = $stripe_connect_account_type;
+ return $this;
+ }
+
+ public function getStripeConnectAccountType(): ?string
+ {
+ return $this->stripe_connect_account_type;
+ }
+
+ public function setStripeConnectPlatform(?string $stripe_connect_platform): self
+ {
+ $this->stripe_connect_platform = $stripe_connect_platform;
+ return $this;
+ }
+
+ public function getStripeConnectPlatform(): ?string
+ {
+ return $this->stripe_connect_platform;
+ }
+
+ public function setStripeAccountId(?string $stripe_account_id): self
+ {
+ $this->stripe_account_id = $stripe_account_id;
+ return $this;
+ }
+
+ public function getStripeAccountId(): ?string
+ {
+ return $this->stripe_account_id;
+ }
+
+ public function setStripeSetupCompletedAt(?string $stripe_setup_completed_at): self
+ {
+ $this->stripe_setup_completed_at = $stripe_setup_completed_at;
+ return $this;
+ }
+
+ public function getStripeSetupCompletedAt(): ?string
+ {
+ return $this->stripe_setup_completed_at;
+ }
+
+ public function setStripeAccountDetails(array|string|null $stripe_account_details): self
+ {
+ $this->stripe_account_details = $stripe_account_details;
+ return $this;
+ }
+
+ public function getStripeAccountDetails(): array|string|null
+ {
+ return $this->stripe_account_details;
+ }
+
+ public function setCreatedAt(?string $created_at): self
+ {
+ $this->created_at = $created_at;
+ return $this;
+ }
+
+ public function getCreatedAt(): ?string
+ {
+ return $this->created_at;
+ }
+
+ public function setUpdatedAt(?string $updated_at): self
+ {
+ $this->updated_at = $updated_at;
+ return $this;
+ }
+
+ public function getUpdatedAt(): ?string
+ {
+ return $this->updated_at;
+ }
+
+ public function setDeletedAt(?string $deleted_at): self
+ {
+ $this->deleted_at = $deleted_at;
+ return $this;
+ }
+
+ public function getDeletedAt(): ?string
+ {
+ return $this->deleted_at;
+ }
+}
diff --git a/backend/app/DomainObjects/Generated/OrganizerVatSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrganizerVatSettingDomainObjectAbstract.php
new file mode 100644
index 0000000000..6d5dbfac78
--- /dev/null
+++ b/backend/app/DomainObjects/Generated/OrganizerVatSettingDomainObjectAbstract.php
@@ -0,0 +1,230 @@
+ $this->id ?? null,
+ 'organizer_id' => $this->organizer_id ?? null,
+ 'vat_registered' => $this->vat_registered ?? null,
+ 'vat_number' => $this->vat_number ?? null,
+ 'vat_validated' => $this->vat_validated ?? null,
+ 'vat_validation_status' => $this->vat_validation_status ?? null,
+ 'vat_validation_error' => $this->vat_validation_error ?? null,
+ 'vat_validation_attempts' => $this->vat_validation_attempts ?? null,
+ 'vat_validation_date' => $this->vat_validation_date ?? null,
+ 'business_name' => $this->business_name ?? null,
+ 'business_address' => $this->business_address ?? null,
+ 'vat_country_code' => $this->vat_country_code ?? null,
+ 'created_at' => $this->created_at ?? null,
+ 'updated_at' => $this->updated_at ?? null,
+ 'deleted_at' => $this->deleted_at ?? null,
+ ];
+ }
+
+ public function setId(int $id): self
+ {
+ $this->id = $id;
+ return $this;
+ }
+
+ public function getId(): int
+ {
+ return $this->id;
+ }
+
+ public function setOrganizerId(int $organizer_id): self
+ {
+ $this->organizer_id = $organizer_id;
+ return $this;
+ }
+
+ public function getOrganizerId(): int
+ {
+ return $this->organizer_id;
+ }
+
+ public function setVatRegistered(bool $vat_registered): self
+ {
+ $this->vat_registered = $vat_registered;
+ return $this;
+ }
+
+ public function getVatRegistered(): bool
+ {
+ return $this->vat_registered;
+ }
+
+ public function setVatNumber(?string $vat_number): self
+ {
+ $this->vat_number = $vat_number;
+ return $this;
+ }
+
+ public function getVatNumber(): ?string
+ {
+ return $this->vat_number;
+ }
+
+ public function setVatValidated(bool $vat_validated): self
+ {
+ $this->vat_validated = $vat_validated;
+ return $this;
+ }
+
+ public function getVatValidated(): bool
+ {
+ return $this->vat_validated;
+ }
+
+ public function setVatValidationStatus(string $vat_validation_status): self
+ {
+ $this->vat_validation_status = $vat_validation_status;
+ return $this;
+ }
+
+ public function getVatValidationStatus(): string
+ {
+ return $this->vat_validation_status;
+ }
+
+ public function setVatValidationError(?string $vat_validation_error): self
+ {
+ $this->vat_validation_error = $vat_validation_error;
+ return $this;
+ }
+
+ public function getVatValidationError(): ?string
+ {
+ return $this->vat_validation_error;
+ }
+
+ public function setVatValidationAttempts(int $vat_validation_attempts): self
+ {
+ $this->vat_validation_attempts = $vat_validation_attempts;
+ return $this;
+ }
+
+ public function getVatValidationAttempts(): int
+ {
+ return $this->vat_validation_attempts;
+ }
+
+ public function setVatValidationDate(?string $vat_validation_date): self
+ {
+ $this->vat_validation_date = $vat_validation_date;
+ return $this;
+ }
+
+ public function getVatValidationDate(): ?string
+ {
+ return $this->vat_validation_date;
+ }
+
+ public function setBusinessName(?string $business_name): self
+ {
+ $this->business_name = $business_name;
+ return $this;
+ }
+
+ public function getBusinessName(): ?string
+ {
+ return $this->business_name;
+ }
+
+ public function setBusinessAddress(?string $business_address): self
+ {
+ $this->business_address = $business_address;
+ return $this;
+ }
+
+ public function getBusinessAddress(): ?string
+ {
+ return $this->business_address;
+ }
+
+ public function setVatCountryCode(?string $vat_country_code): self
+ {
+ $this->vat_country_code = $vat_country_code;
+ return $this;
+ }
+
+ public function getVatCountryCode(): ?string
+ {
+ return $this->vat_country_code;
+ }
+
+ public function setCreatedAt(?string $created_at): self
+ {
+ $this->created_at = $created_at;
+ return $this;
+ }
+
+ public function getCreatedAt(): ?string
+ {
+ return $this->created_at;
+ }
+
+ public function setUpdatedAt(?string $updated_at): self
+ {
+ $this->updated_at = $updated_at;
+ return $this;
+ }
+
+ public function getUpdatedAt(): ?string
+ {
+ return $this->updated_at;
+ }
+
+ public function setDeletedAt(?string $deleted_at): self
+ {
+ $this->deleted_at = $deleted_at;
+ return $this;
+ }
+
+ public function getDeletedAt(): ?string
+ {
+ return $this->deleted_at;
+ }
+}
diff --git a/backend/app/DomainObjects/Generated/ProductOccurrenceVisibilityDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ProductOccurrenceVisibilityDomainObjectAbstract.php
new file mode 100644
index 0000000000..be47ce38b7
--- /dev/null
+++ b/backend/app/DomainObjects/Generated/ProductOccurrenceVisibilityDomainObjectAbstract.php
@@ -0,0 +1,76 @@
+ $this->id ?? null,
+ 'event_occurrence_id' => $this->event_occurrence_id ?? null,
+ 'product_id' => $this->product_id ?? null,
+ 'created_at' => $this->created_at ?? null,
+ ];
+ }
+
+ public function setId(int $id): self
+ {
+ $this->id = $id;
+ return $this;
+ }
+
+ public function getId(): int
+ {
+ return $this->id;
+ }
+
+ public function setEventOccurrenceId(int $event_occurrence_id): self
+ {
+ $this->event_occurrence_id = $event_occurrence_id;
+ return $this;
+ }
+
+ public function getEventOccurrenceId(): int
+ {
+ return $this->event_occurrence_id;
+ }
+
+ public function setProductId(int $product_id): self
+ {
+ $this->product_id = $product_id;
+ return $this;
+ }
+
+ public function getProductId(): int
+ {
+ return $this->product_id;
+ }
+
+ public function setCreatedAt(string $created_at): self
+ {
+ $this->created_at = $created_at;
+ return $this;
+ }
+
+ public function getCreatedAt(): string
+ {
+ return $this->created_at;
+ }
+}
diff --git a/backend/app/DomainObjects/Generated/ProductPriceOccurrenceOverrideDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ProductPriceOccurrenceOverrideDomainObjectAbstract.php
new file mode 100644
index 0000000000..55a86b22b7
--- /dev/null
+++ b/backend/app/DomainObjects/Generated/ProductPriceOccurrenceOverrideDomainObjectAbstract.php
@@ -0,0 +1,104 @@
+ $this->id ?? null,
+ 'event_occurrence_id' => $this->event_occurrence_id ?? null,
+ 'product_price_id' => $this->product_price_id ?? null,
+ 'price' => $this->price ?? null,
+ 'created_at' => $this->created_at ?? null,
+ 'updated_at' => $this->updated_at ?? null,
+ ];
+ }
+
+ public function setId(int $id): self
+ {
+ $this->id = $id;
+ return $this;
+ }
+
+ public function getId(): int
+ {
+ return $this->id;
+ }
+
+ public function setEventOccurrenceId(int $event_occurrence_id): self
+ {
+ $this->event_occurrence_id = $event_occurrence_id;
+ return $this;
+ }
+
+ public function getEventOccurrenceId(): int
+ {
+ return $this->event_occurrence_id;
+ }
+
+ public function setProductPriceId(int $product_price_id): self
+ {
+ $this->product_price_id = $product_price_id;
+ return $this;
+ }
+
+ public function getProductPriceId(): int
+ {
+ return $this->product_price_id;
+ }
+
+ public function setPrice(float $price): self
+ {
+ $this->price = $price;
+ return $this;
+ }
+
+ public function getPrice(): float
+ {
+ return $this->price;
+ }
+
+ public function setCreatedAt(?string $created_at): self
+ {
+ $this->created_at = $created_at;
+ return $this;
+ }
+
+ public function getCreatedAt(): ?string
+ {
+ return $this->created_at;
+ }
+
+ public function setUpdatedAt(?string $updated_at): self
+ {
+ $this->updated_at = $updated_at;
+ return $this;
+ }
+
+ public function getUpdatedAt(): ?string
+ {
+ return $this->updated_at;
+ }
+}
diff --git a/backend/app/DomainObjects/Generated/WaitlistEntryDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/WaitlistEntryDomainObjectAbstract.php
index 7c310634e1..cf0175a573 100644
--- a/backend/app/DomainObjects/Generated/WaitlistEntryDomainObjectAbstract.php
+++ b/backend/app/DomainObjects/Generated/WaitlistEntryDomainObjectAbstract.php
@@ -14,6 +14,7 @@ abstract class WaitlistEntryDomainObjectAbstract extends \HiEvents\DomainObjects
final public const EVENT_ID = 'event_id';
final public const PRODUCT_PRICE_ID = 'product_price_id';
final public const ORDER_ID = 'order_id';
+ final public const EVENT_OCCURRENCE_ID = 'event_occurrence_id';
final public const EMAIL = 'email';
final public const FIRST_NAME = 'first_name';
final public const LAST_NAME = 'last_name';
@@ -34,6 +35,7 @@ abstract class WaitlistEntryDomainObjectAbstract extends \HiEvents\DomainObjects
protected int $event_id;
protected int $product_price_id;
protected ?int $order_id = null;
+ protected ?int $event_occurrence_id = null;
protected string $email;
protected string $first_name;
protected ?string $last_name = null;
@@ -57,6 +59,7 @@ public function toArray(): array
'event_id' => $this->event_id ?? null,
'product_price_id' => $this->product_price_id ?? null,
'order_id' => $this->order_id ?? null,
+ 'event_occurrence_id' => $this->event_occurrence_id ?? null,
'email' => $this->email ?? null,
'first_name' => $this->first_name ?? null,
'last_name' => $this->last_name ?? null,
@@ -119,6 +122,17 @@ public function getOrderId(): ?int
return $this->order_id;
}
+ public function setEventOccurrenceId(?int $event_occurrence_id): self
+ {
+ $this->event_occurrence_id = $event_occurrence_id;
+ return $this;
+ }
+
+ public function getEventOccurrenceId(): ?int
+ {
+ return $this->event_occurrence_id;
+ }
+
public function setEmail(string $email): self
{
$this->email = $email;
diff --git a/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php
index 660f7a66f5..8e301915fd 100644
--- a/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php
+++ b/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php
@@ -13,8 +13,8 @@ abstract class WebhookDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr
final public const ID = 'id';
final public const USER_ID = 'user_id';
final public const EVENT_ID = 'event_id';
- final public const ORGANIZER_ID = 'organizer_id';
final public const ACCOUNT_ID = 'account_id';
+ final public const ORGANIZER_ID = 'organizer_id';
final public const URL = 'url';
final public const EVENT_TYPES = 'event_types';
final public const LAST_RESPONSE_CODE = 'last_response_code';
@@ -29,8 +29,8 @@ abstract class WebhookDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr
protected int $id;
protected int $user_id;
protected ?int $event_id = null;
- protected ?int $organizer_id = null;
protected int $account_id;
+ protected ?int $organizer_id = null;
protected string $url;
protected array|string $event_types;
protected ?int $last_response_code = null;
@@ -48,8 +48,8 @@ public function toArray(): array
'id' => $this->id ?? null,
'user_id' => $this->user_id ?? null,
'event_id' => $this->event_id ?? null,
- 'organizer_id' => $this->organizer_id ?? null,
'account_id' => $this->account_id ?? null,
+ 'organizer_id' => $this->organizer_id ?? null,
'url' => $this->url ?? null,
'event_types' => $this->event_types ?? null,
'last_response_code' => $this->last_response_code ?? null,
@@ -96,26 +96,26 @@ public function getEventId(): ?int
return $this->event_id;
}
- public function setOrganizerId(?int $organizer_id): self
+ public function setAccountId(int $account_id): self
{
- $this->organizer_id = $organizer_id;
+ $this->account_id = $account_id;
return $this;
}
- public function getOrganizerId(): ?int
+ public function getAccountId(): int
{
- return $this->organizer_id;
+ return $this->account_id;
}
- public function setAccountId(int $account_id): self
+ public function setOrganizerId(?int $organizer_id): self
{
- $this->account_id = $account_id;
+ $this->organizer_id = $organizer_id;
return $this;
}
- public function getAccountId(): int
+ public function getOrganizerId(): ?int
{
- return $this->account_id;
+ return $this->organizer_id;
}
public function setUrl(string $url): self
diff --git a/backend/app/DomainObjects/OrderItemDomainObject.php b/backend/app/DomainObjects/OrderItemDomainObject.php
index 164b1d9c02..33b3db9e83 100644
--- a/backend/app/DomainObjects/OrderItemDomainObject.php
+++ b/backend/app/DomainObjects/OrderItemDomainObject.php
@@ -12,6 +12,8 @@ class OrderItemDomainObject extends Generated\OrderItemDomainObjectAbstract
public ?OrderDomainObject $order = null;
+ private ?EventOccurrenceDomainObject $eventOccurrence = null;
+
public function getTotalBeforeDiscount(): float
{
return Currency::round($this->getPriceBeforeDiscount() * $this->getQuantity());
@@ -52,4 +54,16 @@ public function setOrder(?OrderDomainObject $order): self
return $this;
}
+
+ public function getEventOccurrence(): ?EventOccurrenceDomainObject
+ {
+ return $this->eventOccurrence;
+ }
+
+ public function setEventOccurrence(?EventOccurrenceDomainObject $eventOccurrence): self
+ {
+ $this->eventOccurrence = $eventOccurrence;
+
+ return $this;
+ }
}
diff --git a/backend/app/DomainObjects/OrganizerConfigurationDomainObject.php b/backend/app/DomainObjects/OrganizerConfigurationDomainObject.php
new file mode 100644
index 0000000000..01e85760ac
--- /dev/null
+++ b/backend/app/DomainObjects/OrganizerConfigurationDomainObject.php
@@ -0,0 +1,21 @@
+getApplicationFees()['fixed'] ?? config('app.default_application_fee_fixed');
+ }
+
+ public function getPercentageApplicationFee(): float
+ {
+ return $this->getApplicationFees()['percentage'] ?? config('app.default_application_fee_percentage');
+ }
+
+ public function getApplicationFeeCurrency(): string
+ {
+ return $this->getApplicationFees()['currency'] ?? 'USD';
+ }
+}
diff --git a/backend/app/DomainObjects/OrganizerDomainObject.php b/backend/app/DomainObjects/OrganizerDomainObject.php
index 6164c5f2cd..3f432f5f83 100644
--- a/backend/app/DomainObjects/OrganizerDomainObject.php
+++ b/backend/app/DomainObjects/OrganizerDomainObject.php
@@ -2,6 +2,7 @@
namespace HiEvents\DomainObjects;
+use HiEvents\DomainObjects\Enums\StripePlatform;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
@@ -16,6 +17,13 @@ class OrganizerDomainObject extends Generated\OrganizerDomainObjectAbstract
private ?OrganizerSettingDomainObject $settings = null;
+ /** @var Collection|null */
+ private ?Collection $stripePlatforms = null;
+
+ private ?OrganizerVatSettingDomainObject $vatSetting = null;
+
+ private ?OrganizerConfigurationDomainObject $configuration = null;
+
public function getImages(): ?Collection
{
return $this->images;
@@ -52,8 +60,87 @@ public function setOrganizerSettings(?OrganizerSettingDomainObject $settings): s
return $this;
}
+ public function getOrganizerStripePlatforms(): ?Collection
+ {
+ return $this->stripePlatforms;
+ }
+
+ public function setOrganizerStripePlatforms(Collection $stripePlatforms): self
+ {
+ $this->stripePlatforms = $stripePlatforms;
+
+ return $this;
+ }
+
public function getSlug(): string
{
return Str::slug($this->name);
}
+
+ public function getPrimaryStripePlatform(): ?OrganizerStripePlatformDomainObject
+ {
+ if (!$this->stripePlatforms || $this->stripePlatforms->isEmpty()) {
+ return null;
+ }
+
+ return $this->stripePlatforms
+ ->filter(fn($platform) => $platform->getStripeSetupCompletedAt() !== null)
+ ->sortByDesc(fn($platform) => $platform->getCreatedAt())
+ ->first();
+ }
+
+ public function getStripePlatformByType(?StripePlatform $platformType): ?OrganizerStripePlatformDomainObject
+ {
+ if (!$this->stripePlatforms || $this->stripePlatforms->isEmpty()) {
+ return null;
+ }
+
+ return $this->stripePlatforms
+ ->filter(fn($platform) => $platform->getStripeConnectPlatform() === $platformType?->value)
+ ->first();
+ }
+
+ public function getActiveStripeAccountId(): ?string
+ {
+ return $this->getPrimaryStripePlatform()?->getStripeAccountId();
+ }
+
+ public function getActiveStripePlatform(): ?StripePlatform
+ {
+ $primaryPlatform = $this->getPrimaryStripePlatform();
+ if (!$primaryPlatform || !$primaryPlatform->getStripeConnectPlatform()) {
+ return null;
+ }
+
+ return StripePlatform::fromString($primaryPlatform->getStripeConnectPlatform());
+ }
+
+ public function isStripeSetupComplete(): bool
+ {
+ return $this->getPrimaryStripePlatform() !== null;
+ }
+
+ public function getOrganizerVatSetting(): ?OrganizerVatSettingDomainObject
+ {
+ return $this->vatSetting;
+ }
+
+ public function setOrganizerVatSetting(?OrganizerVatSettingDomainObject $vatSetting): self
+ {
+ $this->vatSetting = $vatSetting;
+
+ return $this;
+ }
+
+ public function getOrganizerConfiguration(): ?OrganizerConfigurationDomainObject
+ {
+ return $this->configuration;
+ }
+
+ public function setOrganizerConfiguration(?OrganizerConfigurationDomainObject $configuration): self
+ {
+ $this->configuration = $configuration;
+
+ return $this;
+ }
}
diff --git a/backend/app/DomainObjects/OrganizerStripePlatformDomainObject.php b/backend/app/DomainObjects/OrganizerStripePlatformDomainObject.php
new file mode 100644
index 0000000000..20df53404e
--- /dev/null
+++ b/backend/app/DomainObjects/OrganizerStripePlatformDomainObject.php
@@ -0,0 +1,7 @@
+order = $order;
+
return $this;
}
@@ -65,6 +70,7 @@ public function getOrder(): ?OrderDomainObject
public function setProductPrice(?ProductPriceDomainObject $productPrice): self
{
$this->productPrice = $productPrice;
+
return $this;
}
@@ -73,4 +79,15 @@ public function getProductPrice(): ?ProductPriceDomainObject
return $this->productPrice;
}
+ public function setEventOccurrence(?EventOccurrenceDomainObject $eventOccurrence): self
+ {
+ $this->eventOccurrence = $eventOccurrence;
+
+ return $this;
+ }
+
+ public function getEventOccurrence(): ?EventOccurrenceDomainObject
+ {
+ return $this->eventOccurrence;
+ }
}
diff --git a/backend/app/Events/CapacityChangedEvent.php b/backend/app/Events/CapacityChangedEvent.php
index f0894827a0..c0ff639f2f 100644
--- a/backend/app/Events/CapacityChangedEvent.php
+++ b/backend/app/Events/CapacityChangedEvent.php
@@ -12,6 +12,7 @@ public function __construct(
public ?int $productId = null,
public ?int $productPriceId = null,
public ?int $newCapacity = null,
+ public ?int $eventOccurrenceId = null,
)
{
}
diff --git a/backend/app/Events/OccurrenceCancelledEvent.php b/backend/app/Events/OccurrenceCancelledEvent.php
new file mode 100644
index 0000000000..1fd4b0c5ef
--- /dev/null
+++ b/backend/app/Events/OccurrenceCancelledEvent.php
@@ -0,0 +1,18 @@
+join(', ')
: '';
+ $occurrenceDate = $attendee->getEventOccurrence()?->getStartDate()
+ ? Carbon::parse($attendee->getEventOccurrence()->getStartDate())->format('Y-m-d H:i:s')
+ : '';
+
return array_merge([
$attendee->getId(),
$attendee->getFirstName(),
@@ -129,6 +134,7 @@ public function map($attendee): array
$attendee->getProductId(),
$ticketName,
$attendee->getEventId(),
+ $occurrenceDate,
$attendee->getPublicId(),
$attendee->getShortId(),
Carbon::parse($attendee->getCreatedAt())->format('Y-m-d H:i:s'),
diff --git a/backend/app/Exports/OrdersExport.php b/backend/app/Exports/OrdersExport.php
index 71ac6e1948..3c38b4da08 100644
--- a/backend/app/Exports/OrdersExport.php
+++ b/backend/app/Exports/OrdersExport.php
@@ -4,7 +4,9 @@
use Carbon\Carbon;
use HiEvents\DomainObjects\Enums\QuestionTypeEnum;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
+use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\DomainObjects\QuestionDomainObject;
use HiEvents\Resources\Order\OrderResource;
use HiEvents\Services\Domain\Question\QuestionAnswerFormatter;
@@ -20,16 +22,16 @@
class OrdersExport implements FromCollection, WithHeadings, WithMapping, WithStyles
{
private LengthAwarePaginator $orders;
+
private Collection $questions;
- public function __construct(private QuestionAnswerFormatter $questionAnswerFormatter)
- {
- }
+ public function __construct(private QuestionAnswerFormatter $questionAnswerFormatter) {}
public function withData(LengthAwarePaginator $orders, Collection $questions): OrdersExport
{
$this->orders = $orders;
$this->questions = $questions;
+
return $this;
}
@@ -40,7 +42,7 @@ public function collection(): AnonymousResourceCollection
public function headings(): array
{
- $questionTitles = $this->questions->map(fn($question) => $question->getTitle())->toArray();
+ $questionTitles = $this->questions->map(fn ($question) => $question->getTitle())->toArray();
return array_merge([
__('ID'),
@@ -58,6 +60,7 @@ public function headings(): array
__('Currency'),
__('Created At'),
__('Public ID'),
+ __('Occurrence Date'),
__('Payment Provider'),
__('Is Partially Refunded'),
__('Is Fully Refunded'),
@@ -71,14 +74,13 @@ public function headings(): array
}
/**
- * @param OrderDomainObject $order
- * @return array
+ * @param OrderDomainObject $order
*/
public function map($order): array
{
$answers = $this->questions->map(function (QuestionDomainObject $question) use ($order) {
$answer = $order->getQuestionAndAnswerViews()
- ->first(fn($qav) => $qav->getQuestionId() === $question->getId())?->getAnswer() ?? '';
+ ->first(fn ($qav) => $qav->getQuestionId() === $question->getId())?->getAnswer() ?? '';
return $this->questionAnswerFormatter->getAnswerAsText(
$answer,
@@ -86,6 +88,16 @@ public function map($order): array
);
});
+ // Orders can span multiple occurrences (series passes). List every distinct
+ // occurrence date, not just the first, so the export doesn't silently lose data.
+ $occurrenceDate = $order->getOrderItems()
+ ?->map(fn (OrderItemDomainObject $item) => $item->getEventOccurrence())
+ ?->filter()
+ ?->unique(fn (EventOccurrenceDomainObject $occ) => $occ->getId())
+ ?->sortBy(fn (EventOccurrenceDomainObject $occ) => $occ->getStartDate())
+ ?->map(fn (EventOccurrenceDomainObject $occ) => Carbon::parse($occ->getStartDate())->format('Y-m-d H:i:s'))
+ ?->implode(', ') ?? '';
+
return array_merge([
$order->getId(),
$order->getFirstName(),
@@ -102,6 +114,7 @@ public function map($order): array
$order->getCurrency(),
Carbon::parse($order->getCreatedAt())->format('Y-m-d H:i:s'),
$order->getPublicId(),
+ $occurrenceDate,
$order->getPaymentProvider(),
$order->isPartiallyRefunded(),
$order->isFullyRefunded(),
diff --git a/backend/app/Helper/IdHelper.php b/backend/app/Helper/IdHelper.php
index 5a124effb8..107002df21 100644
--- a/backend/app/Helper/IdHelper.php
+++ b/backend/app/Helper/IdHelper.php
@@ -13,6 +13,7 @@ class IdHelper
public const CHECK_IN_LIST_PREFIX = 'cil';
public const CHECK_IN_PREFIX = 'ci';
+ public const OCCURRENCE_PREFIX = 'oc';
public static function shortId(string $prefix, int $length = 13): string
{
diff --git a/backend/app/Http/Actions/Accounts/GetAccountAction.php b/backend/app/Http/Actions/Accounts/GetAccountAction.php
index 2bf9bf6686..26f0280632 100644
--- a/backend/app/Http/Actions/Accounts/GetAccountAction.php
+++ b/backend/app/Http/Actions/Accounts/GetAccountAction.php
@@ -4,11 +4,8 @@
namespace HiEvents\Http\Actions\Accounts;
-use HiEvents\DomainObjects\AccountConfigurationDomainObject;
-use HiEvents\DomainObjects\AccountStripePlatformDomainObject;
use HiEvents\DomainObjects\Enums\Role;
use HiEvents\Http\Actions\BaseAction;
-use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\AccountRepositoryInterface;
use HiEvents\Resources\Account\AccountResource;
use Illuminate\Http\JsonResponse;
@@ -26,13 +23,7 @@ public function __invoke(?int $accountId = null): JsonResponse
{
$this->minimumAllowedRole(Role::ORGANIZER);
- $account = $this->accountRepository
- ->loadRelation(new Relationship(
- domainObject: AccountConfigurationDomainObject::class,
- name: 'configuration',
- ))
- ->loadRelation(AccountStripePlatformDomainObject::class)
- ->findById($this->getAuthenticatedAccountId());
+ $account = $this->accountRepository->findById($this->getAuthenticatedAccountId());
return $this->resourceResponse(AccountResource::class, $account);
}
diff --git a/backend/app/Http/Actions/Accounts/Stripe/GetStripeConnectAccountsAction.php b/backend/app/Http/Actions/Accounts/Stripe/GetStripeConnectAccountsAction.php
deleted file mode 100644
index e1f607d926..0000000000
--- a/backend/app/Http/Actions/Accounts/Stripe/GetStripeConnectAccountsAction.php
+++ /dev/null
@@ -1,34 +0,0 @@
-isActionAuthorized($accountId, AccountDomainObject::class, Role::ADMIN);
-
- $result = $this->getStripeConnectAccountsHandler->handle($accountId);
-
- return $this->resourceResponse(
- resource: StripeConnectAccountsResponseResource::class,
- data: $result,
- );
- }
-}
diff --git a/backend/app/Http/Actions/Accounts/Vat/GetAccountVatSettingAction.php b/backend/app/Http/Actions/Accounts/Vat/GetAccountVatSettingAction.php
deleted file mode 100644
index 18e693c171..0000000000
--- a/backend/app/Http/Actions/Accounts/Vat/GetAccountVatSettingAction.php
+++ /dev/null
@@ -1,36 +0,0 @@
-minimumAllowedRole(Role::ORGANIZER);
-
- if ($accountId !== $this->getAuthenticatedAccountId()) {
- return $this->errorResponse(__('Unauthorized'));
- }
-
- $vatSetting = $this->handler->handle($accountId);
-
- if (!$vatSetting) {
- return $this->jsonResponse(['data' => null]);
- }
-
- return $this->resourceResponse(AccountVatSettingResource::class, $vatSetting);
- }
-}
diff --git a/backend/app/Http/Actions/Accounts/Vat/UpsertAccountVatSettingAction.php b/backend/app/Http/Actions/Accounts/Vat/UpsertAccountVatSettingAction.php
deleted file mode 100644
index 63280b6958..0000000000
--- a/backend/app/Http/Actions/Accounts/Vat/UpsertAccountVatSettingAction.php
+++ /dev/null
@@ -1,43 +0,0 @@
-minimumAllowedRole(Role::ADMIN);
-
- if ($accountId !== $this->getAuthenticatedAccountId()) {
- return $this->errorResponse(__('Unauthorized'));
- }
-
- $validated = $request->validate([
- 'vat_registered' => 'required|boolean',
- 'vat_number' => 'nullable|string|max:20',
- ]);
-
- $vatSetting = $this->handler->handle(new UpsertAccountVatSettingDTO(
- accountId: $accountId,
- vatRegistered: $validated['vat_registered'],
- vatNumber: $validated['vat_number'] ?? null,
- ));
-
- return $this->resourceResponse(AccountVatSettingResource::class, $vatSetting);
- }
-}
diff --git a/backend/app/Http/Actions/Admin/Accounts/AssignConfigurationAction.php b/backend/app/Http/Actions/Admin/Accounts/AssignConfigurationAction.php
deleted file mode 100644
index 73b0be38b1..0000000000
--- a/backend/app/Http/Actions/Admin/Accounts/AssignConfigurationAction.php
+++ /dev/null
@@ -1,34 +0,0 @@
-minimumAllowedRole(Role::SUPERADMIN);
-
- $validated = $request->validate([
- 'configuration_id' => 'required|integer|exists:account_configuration,id',
- ]);
-
- $this->handler->handle($accountId, (int) $validated['configuration_id']);
-
- return $this->jsonResponse([
- 'message' => __('Configuration assigned successfully.'),
- ]);
- }
-}
diff --git a/backend/app/Http/Actions/Admin/Accounts/UpdateAccountConfigurationAction.php b/backend/app/Http/Actions/Admin/Accounts/UpdateAccountConfigurationAction.php
deleted file mode 100644
index c7fe7d6095..0000000000
--- a/backend/app/Http/Actions/Admin/Accounts/UpdateAccountConfigurationAction.php
+++ /dev/null
@@ -1,43 +0,0 @@
-minimumAllowedRole(Role::SUPERADMIN);
-
- $validated = $request->validate([
- 'application_fees' => 'required|array',
- 'application_fees.fixed' => 'required|numeric|min:0',
- 'application_fees.percentage' => 'required|numeric|min:0|max:100',
- ]);
-
- $configuration = $this->handler->handle(new UpdateAccountConfigurationDTO(
- accountId: $accountId,
- applicationFees: $validated['application_fees'],
- ));
-
- return $this->resourceResponse(
- resource: AccountConfigurationResource::class,
- data: $configuration
- );
- }
-}
diff --git a/backend/app/Http/Actions/Admin/Configurations/CreateConfigurationAction.php b/backend/app/Http/Actions/Admin/Configurations/CreateConfigurationAction.php
index 6babb80c19..3c2364452e 100644
--- a/backend/app/Http/Actions/Admin/Configurations/CreateConfigurationAction.php
+++ b/backend/app/Http/Actions/Admin/Configurations/CreateConfigurationAction.php
@@ -6,8 +6,8 @@
use HiEvents\DomainObjects\Enums\Role;
use HiEvents\Http\Actions\BaseAction;
-use HiEvents\Repository\Interfaces\AccountConfigurationRepositoryInterface;
-use HiEvents\Resources\Account\AccountConfigurationResource;
+use HiEvents\Repository\Interfaces\OrganizerConfigurationRepositoryInterface;
+use HiEvents\Resources\Organizer\OrganizerConfigurationResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -15,7 +15,7 @@
class CreateConfigurationAction extends BaseAction
{
public function __construct(
- private readonly AccountConfigurationRepositoryInterface $repository,
+ private readonly OrganizerConfigurationRepositoryInterface $repository,
) {
}
@@ -40,7 +40,7 @@ public function __invoke(Request $request): JsonResponse
]);
return $this->jsonResponse(
- new AccountConfigurationResource($configuration),
+ new OrganizerConfigurationResource($configuration),
statusCode: Response::HTTP_CREATED,
wrapInData: true
);
diff --git a/backend/app/Http/Actions/Admin/Configurations/GetAllConfigurationsAction.php b/backend/app/Http/Actions/Admin/Configurations/GetAllConfigurationsAction.php
index 0ffcf213eb..c9581753c6 100644
--- a/backend/app/Http/Actions/Admin/Configurations/GetAllConfigurationsAction.php
+++ b/backend/app/Http/Actions/Admin/Configurations/GetAllConfigurationsAction.php
@@ -6,14 +6,14 @@
use HiEvents\DomainObjects\Enums\Role;
use HiEvents\Http\Actions\BaseAction;
-use HiEvents\Repository\Interfaces\AccountConfigurationRepositoryInterface;
-use HiEvents\Resources\Account\AccountConfigurationResource;
+use HiEvents\Repository\Interfaces\OrganizerConfigurationRepositoryInterface;
+use HiEvents\Resources\Organizer\OrganizerConfigurationResource;
use Illuminate\Http\JsonResponse;
class GetAllConfigurationsAction extends BaseAction
{
public function __construct(
- private readonly AccountConfigurationRepositoryInterface $repository,
+ private readonly OrganizerConfigurationRepositoryInterface $repository,
) {
}
@@ -24,7 +24,7 @@ public function __invoke(): JsonResponse
$configurations = $this->repository->all();
return $this->jsonResponse(
- AccountConfigurationResource::collection($configurations),
+ OrganizerConfigurationResource::collection($configurations),
wrapInData: true
);
}
diff --git a/backend/app/Http/Actions/Admin/Configurations/UpdateConfigurationAction.php b/backend/app/Http/Actions/Admin/Configurations/UpdateConfigurationAction.php
index 947a152c23..0af68f0022 100644
--- a/backend/app/Http/Actions/Admin/Configurations/UpdateConfigurationAction.php
+++ b/backend/app/Http/Actions/Admin/Configurations/UpdateConfigurationAction.php
@@ -6,15 +6,15 @@
use HiEvents\DomainObjects\Enums\Role;
use HiEvents\Http\Actions\BaseAction;
-use HiEvents\Repository\Interfaces\AccountConfigurationRepositoryInterface;
-use HiEvents\Resources\Account\AccountConfigurationResource;
+use HiEvents\Repository\Interfaces\OrganizerConfigurationRepositoryInterface;
+use HiEvents\Resources\Organizer\OrganizerConfigurationResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UpdateConfigurationAction extends BaseAction
{
public function __construct(
- private readonly AccountConfigurationRepositoryInterface $repository,
+ private readonly OrganizerConfigurationRepositoryInterface $repository,
) {
}
@@ -41,7 +41,7 @@ public function __invoke(Request $request, int $configurationId): JsonResponse
);
return $this->jsonResponse(
- new AccountConfigurationResource($configuration),
+ new OrganizerConfigurationResource($configuration),
wrapInData: true
);
}
diff --git a/backend/app/Http/Actions/Admin/Organizers/AssignOrganizerConfigurationAction.php b/backend/app/Http/Actions/Admin/Organizers/AssignOrganizerConfigurationAction.php
new file mode 100644
index 0000000000..667e61c823
--- /dev/null
+++ b/backend/app/Http/Actions/Admin/Organizers/AssignOrganizerConfigurationAction.php
@@ -0,0 +1,35 @@
+minimumAllowedRole(Role::SUPERADMIN);
+
+ $validated = $request->validate([
+ 'configuration_id' => 'required|integer|exists:organizer_configurations,id',
+ ]);
+
+ $this->handler->handle($organizerId, (int)$validated['configuration_id']);
+
+ return $this->jsonResponse([
+ 'message' => __('Configuration assigned successfully.'),
+ ]);
+ }
+}
diff --git a/backend/app/Http/Actions/Admin/Organizers/UpdateOrganizerConfigurationAction.php b/backend/app/Http/Actions/Admin/Organizers/UpdateOrganizerConfigurationAction.php
new file mode 100644
index 0000000000..f7c8335c06
--- /dev/null
+++ b/backend/app/Http/Actions/Admin/Organizers/UpdateOrganizerConfigurationAction.php
@@ -0,0 +1,44 @@
+minimumAllowedRole(Role::SUPERADMIN);
+
+ $validated = $request->validate([
+ 'application_fees' => 'required|array',
+ 'application_fees.fixed' => 'required|numeric|min:0',
+ 'application_fees.percentage' => 'required|numeric|min:0|max:100',
+ 'application_fees.currency' => 'nullable|string|size:3',
+ ]);
+
+ $configuration = $this->handler->handle(new UpdateOrganizerConfigurationDTO(
+ organizerId: $organizerId,
+ applicationFees: $validated['application_fees'],
+ ));
+
+ return $this->resourceResponse(
+ resource: OrganizerConfigurationResource::class,
+ data: $configuration,
+ );
+ }
+}
diff --git a/backend/app/Http/Actions/Admin/Accounts/UpdateAccountVatSettingAction.php b/backend/app/Http/Actions/Admin/Organizers/UpdateOrganizerVatSettingAction.php
similarity index 61%
rename from backend/app/Http/Actions/Admin/Accounts/UpdateAccountVatSettingAction.php
rename to backend/app/Http/Actions/Admin/Organizers/UpdateOrganizerVatSettingAction.php
index 030d758712..2445f5ede1 100644
--- a/backend/app/Http/Actions/Admin/Accounts/UpdateAccountVatSettingAction.php
+++ b/backend/app/Http/Actions/Admin/Organizers/UpdateOrganizerVatSettingAction.php
@@ -2,25 +2,25 @@
declare(strict_types=1);
-namespace HiEvents\Http\Actions\Admin\Accounts;
+namespace HiEvents\Http\Actions\Admin\Organizers;
-use HiEvents\DataTransferObjects\UpdateAdminAccountVatSettingDTO;
+use HiEvents\DataTransferObjects\UpdateAdminOrganizerVatSettingDTO;
use HiEvents\DomainObjects\Enums\Role;
use HiEvents\Http\Actions\BaseAction;
-use HiEvents\Resources\Account\AccountVatSettingResource;
-use HiEvents\Services\Application\Handlers\Admin\UpdateAdminAccountVatSettingHandler;
+use HiEvents\Resources\Organizer\Vat\OrganizerVatSettingResource;
+use HiEvents\Services\Application\Handlers\Admin\Organizer\UpdateAdminOrganizerVatSettingHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
-class UpdateAccountVatSettingAction extends BaseAction
+class UpdateOrganizerVatSettingAction extends BaseAction
{
public function __construct(
- private readonly UpdateAdminAccountVatSettingHandler $handler,
+ private readonly UpdateAdminOrganizerVatSettingHandler $handler,
)
{
}
- public function __invoke(Request $request, int $accountId): JsonResponse
+ public function __invoke(Request $request, int $organizerId): JsonResponse
{
$this->minimumAllowedRole(Role::SUPERADMIN);
@@ -33,8 +33,8 @@ public function __invoke(Request $request, int $accountId): JsonResponse
'vat_country_code' => 'nullable|string|max:2',
]);
- $vatSetting = $this->handler->handle(new UpdateAdminAccountVatSettingDTO(
- accountId: $accountId,
+ $vatSetting = $this->handler->handle(new UpdateAdminOrganizerVatSettingDTO(
+ organizerId: $organizerId,
vatRegistered: $validated['vat_registered'],
vatNumber: $validated['vat_number'] ?? null,
vatValidated: $validated['vat_validated'] ?? null,
@@ -44,8 +44,8 @@ public function __invoke(Request $request, int $accountId): JsonResponse
));
return $this->resourceResponse(
- resource: AccountVatSettingResource::class,
- data: $vatSetting
+ resource: OrganizerVatSettingResource::class,
+ data: $vatSetting,
);
}
}
diff --git a/backend/app/Http/Actions/Attendees/CreateAttendeeAction.php b/backend/app/Http/Actions/Attendees/CreateAttendeeAction.php
index 492b065108..6670131d4b 100644
--- a/backend/app/Http/Actions/Attendees/CreateAttendeeAction.php
+++ b/backend/app/Http/Actions/Attendees/CreateAttendeeAction.php
@@ -33,8 +33,11 @@ public function __invoke(CreateAttendeeRequest $request, int $eventId): JsonResp
try {
$attendee = $this->createAttendeeHandler->handle(CreateAttendeeDTO::fromArray(
- array_merge($request->validationData(), [
+ array_merge($request->validated(), [
'event_id' => $eventId,
+ 'override_capacity' => (bool) $request->validated('override_capacity', false),
+ 'client_ip' => $this->getClientIp($request),
+ 'client_user_agent' => $request->userAgent(),
])
));
} catch (NoTicketsAvailableException $exception) {
diff --git a/backend/app/Http/Actions/Attendees/ExportAttendeesAction.php b/backend/app/Http/Actions/Attendees/ExportAttendeesAction.php
index dce25e228a..323f9baeed 100644
--- a/backend/app/Http/Actions/Attendees/ExportAttendeesAction.php
+++ b/backend/app/Http/Actions/Attendees/ExportAttendeesAction.php
@@ -6,6 +6,7 @@
use HiEvents\DomainObjects\CheckInListDomainObject;
use HiEvents\DomainObjects\Enums\QuestionBelongsTo;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
@@ -15,6 +16,7 @@
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
use HiEvents\Repository\Interfaces\QuestionRepositoryInterface;
+use Illuminate\Http\Request;
use Maatwebsite\Excel\Facades\Excel;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -31,10 +33,12 @@ public function __construct(
/**
* @todo This should be passed off to a queue and moved to a service
*/
- public function __invoke(int $eventId): BinaryFileResponse
+ public function __invoke(Request $request, int $eventId): BinaryFileResponse
{
$this->isActionAuthorized($eventId, EventDomainObject::class);
+ $eventOccurrenceId = $request->input('event_occurrence_id') ? (int) $request->input('event_occurrence_id') : null;
+
$attendees = $this->attendeeRepository
->loadRelation(QuestionAndAnswerViewDomainObject::class)
->loadRelation(new Relationship(
@@ -65,7 +69,11 @@ public function __invoke(int $eventId): BinaryFileResponse
],
name: 'order'
))
- ->findByEventIdForExport($eventId);
+ ->loadRelation(new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ name: 'event_occurrence',
+ ))
+ ->findByEventIdForExport($eventId, $eventOccurrenceId);
$productQuestions = $this->questionRepository->findWhere([
'event_id' => $eventId,
diff --git a/backend/app/Http/Actions/Attendees/GetAttendeeActionPublic.php b/backend/app/Http/Actions/Attendees/GetAttendeeActionPublic.php
index 5be7ddf396..390c92eedd 100644
--- a/backend/app/Http/Actions/Attendees/GetAttendeeActionPublic.php
+++ b/backend/app/Http/Actions/Attendees/GetAttendeeActionPublic.php
@@ -2,6 +2,7 @@
namespace HiEvents\Http\Actions\Attendees;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\Generated\AttendeeDomainObjectAbstract;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
@@ -34,6 +35,10 @@ public function __invoke(int $eventId, string $attendeeShortId): JsonResponse|Re
domainObject: ProductPriceDomainObject::class,
),
], name: 'product'))
+ ->loadRelation(new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ name: 'event_occurrence',
+ ))
->findFirstWhere([
AttendeeDomainObjectAbstract::SHORT_ID => $attendeeShortId
]);
diff --git a/backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php b/backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php
index c9dc08d3ed..a0ad820dec 100644
--- a/backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php
+++ b/backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php
@@ -30,9 +30,13 @@ public function __invoke(UpsertCheckInListRequest $request, int $eventId): JsonR
name: $request->validated('name'),
description: $request->validated('description'),
eventId: $eventId,
- productIds: $request->validated('product_ids'),
+ productIds: $request->validated('product_ids') ?? [],
expiresAt: $request->validated('expires_at'),
activatesAt: $request->validated('activates_at'),
+ eventOccurrenceId: $request->validated('event_occurrence_id'),
+ publicShowAttendeeNotes: $request->validated('public_show_attendee_notes') ?? true,
+ publicShowQuestionAnswers: $request->validated('public_show_question_answers') ?? true,
+ publicShowOrderDetails: $request->validated('public_show_order_details') ?? true,
)
);
} catch (UnrecognizedProductIdException $exception) {
diff --git a/backend/app/Http/Actions/CheckInLists/DeleteCheckInListAction.php b/backend/app/Http/Actions/CheckInLists/DeleteCheckInListAction.php
index f707a0737a..2b0171d027 100644
--- a/backend/app/Http/Actions/CheckInLists/DeleteCheckInListAction.php
+++ b/backend/app/Http/Actions/CheckInLists/DeleteCheckInListAction.php
@@ -3,8 +3,10 @@
namespace HiEvents\Http\Actions\CheckInLists;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\Exceptions\ResourceConflictException;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Services\Application\Handlers\CheckInList\DeleteCheckInListHandler;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
class DeleteCheckInListAction extends BaseAction
@@ -15,14 +17,21 @@ public function __construct(
{
}
- public function __invoke(int $eventId, int $checkInListId): Response
+ public function __invoke(int $eventId, int $checkInListId): Response|JsonResponse
{
$this->isActionAuthorized($eventId, EventDomainObject::class);
- $this->deleteCheckInListHandler->handle(
- eventId: $eventId,
- checkInListId: $checkInListId,
- );
+ try {
+ $this->deleteCheckInListHandler->handle(
+ eventId: $eventId,
+ checkInListId: $checkInListId,
+ );
+ } catch (ResourceConflictException $e) {
+ return $this->errorResponse(
+ message: $e->getMessage(),
+ statusCode: Response::HTTP_CONFLICT,
+ );
+ }
return $this->noContentResponse();
}
diff --git a/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeeDetailPublicAction.php b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeeDetailPublicAction.php
new file mode 100644
index 0000000000..f20add554a
--- /dev/null
+++ b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeeDetailPublicAction.php
@@ -0,0 +1,57 @@
+handler->handle(
+ shortId: $checkInListShortId,
+ attendeePublicId: $attendeePublicId,
+ staffAccountId: $this->resolveStaffAccountId(),
+ );
+ } catch (CannotCheckInException $e) {
+ return $this->errorResponse(
+ message: $e->getMessage(),
+ statusCode: Response::HTTP_FORBIDDEN,
+ );
+ }
+
+ return $this->resourceResponse(
+ resource: AttendeeDetailPublicResource::class,
+ data: $detail,
+ );
+ }
+
+ /**
+ * The detail endpoint is public but should reveal all attendee fields to authenticated staff
+ * whose account matches the event's account. Returns null for anonymous / invalid tokens /
+ * any auth resolution failure — those callers get data filtered by the list's visibility flags.
+ */
+ private function resolveStaffAccountId(): ?int
+ {
+ try {
+ return $this->authUserService->getAuthenticatedAccountId();
+ } catch (Throwable) {
+ return null;
+ }
+ }
+}
diff --git a/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListStatsPublicAction.php b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListStatsPublicAction.php
new file mode 100644
index 0000000000..43466a9479
--- /dev/null
+++ b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListStatsPublicAction.php
@@ -0,0 +1,32 @@
+query('event_occurrence_id');
+ $occurrenceIdInt = is_numeric($occurrenceId) ? (int) $occurrenceId : null;
+
+ $stats = $this->getCheckInListStatsPublicHandler->handle($checkInListShortId, $occurrenceIdInt);
+
+ return $this->resourceResponse(
+ resource: CheckInListStatsPublicResource::class,
+ data: $stats,
+ );
+ }
+}
diff --git a/backend/app/Http/Actions/CheckInLists/UpdateCheckInListAction.php b/backend/app/Http/Actions/CheckInLists/UpdateCheckInListAction.php
index dceda8c893..0dcf666957 100644
--- a/backend/app/Http/Actions/CheckInLists/UpdateCheckInListAction.php
+++ b/backend/app/Http/Actions/CheckInLists/UpdateCheckInListAction.php
@@ -30,10 +30,14 @@ public function __invoke(UpsertCheckInListRequest $request, int $eventId, int $c
name: $request->validated('name'),
description: $request->validated('description'),
eventId: $eventId,
- productIds: $request->validated('product_ids'),
+ productIds: $request->validated('product_ids') ?? [],
expiresAt: $request->validated('expires_at'),
activatesAt: $request->validated('activates_at'),
id: $checkInListId,
+ eventOccurrenceId: $request->validated('event_occurrence_id'),
+ publicShowAttendeeNotes: $request->validated('public_show_attendee_notes') ?? true,
+ publicShowQuestionAnswers: $request->validated('public_show_question_answers') ?? true,
+ publicShowOrderDetails: $request->validated('public_show_order_details') ?? true,
)
);
} catch (UnrecognizedProductIdException $exception) {
diff --git a/backend/app/Http/Actions/EmailTemplates/BaseEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/BaseEmailTemplateAction.php
index c8648519fa..a39ed519f2 100644
--- a/backend/app/Http/Actions/EmailTemplates/BaseEmailTemplateAction.php
+++ b/backend/app/Http/Actions/EmailTemplates/BaseEmailTemplateAction.php
@@ -82,7 +82,7 @@ protected function handlePreviewRequest(Request $request, PreviewEmailTemplateHa
$cta = [
'label' => $validated['ctaLabel'],
- 'url_token' => $validated['template_type'] === 'order_confirmation' ? 'order.url' : 'ticket.url',
+ 'url_token' => EmailTemplateType::from($validated['template_type'])->ctaUrlToken(),
];
$preview = $handler->handle(
diff --git a/backend/app/Http/Actions/EmailTemplates/CreateEventEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/CreateEventEmailTemplateAction.php
index 8b52507ede..bb60bccebd 100644
--- a/backend/app/Http/Actions/EmailTemplates/CreateEventEmailTemplateAction.php
+++ b/backend/app/Http/Actions/EmailTemplates/CreateEventEmailTemplateAction.php
@@ -42,7 +42,7 @@ public function __invoke(Request $request, int $eventId): JsonResponse
try {
$cta = [
'label' => $validated['ctaLabel'],
- 'url_token' => $validated['template_type'] === 'order_confirmation' ? 'order.url' : 'ticket.url',
+ 'url_token' => EmailTemplateType::from($validated['template_type'])->ctaUrlToken(),
];
$template = $this->handler->handle(
diff --git a/backend/app/Http/Actions/EmailTemplates/CreateOrganizerEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/CreateOrganizerEmailTemplateAction.php
index c0e4411182..46bc1dd7ae 100644
--- a/backend/app/Http/Actions/EmailTemplates/CreateOrganizerEmailTemplateAction.php
+++ b/backend/app/Http/Actions/EmailTemplates/CreateOrganizerEmailTemplateAction.php
@@ -42,7 +42,7 @@ public function __invoke(Request $request, int $organizerId): JsonResponse
try {
$cta = [
'label' => $validated['ctaLabel'],
- 'url_token' => $validated['template_type'] === 'order_confirmation' ? 'order.url' : 'ticket.url',
+ 'url_token' => EmailTemplateType::from($validated['template_type'])->ctaUrlToken(),
];
$template = $this->handler->handle(
diff --git a/backend/app/Http/Actions/EmailTemplates/UpdateEventEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/UpdateEventEmailTemplateAction.php
index f7be0096e3..a7b1e30e48 100644
--- a/backend/app/Http/Actions/EmailTemplates/UpdateEventEmailTemplateAction.php
+++ b/backend/app/Http/Actions/EmailTemplates/UpdateEventEmailTemplateAction.php
@@ -10,6 +10,7 @@
use HiEvents\Exceptions\InvalidEmailTemplateException;
use HiEvents\Http\Resources\EmailTemplateResource;
use HiEvents\Http\ResponseCodes;
+use HiEvents\Repository\Interfaces\EmailTemplateRepositoryInterface;
use HiEvents\Services\Application\Handlers\EmailTemplate\DTO\UpsertEmailTemplateDTO;
use HiEvents\Services\Application\Handlers\EmailTemplate\UpdateEmailTemplateHandler;
use Illuminate\Http\JsonResponse;
@@ -20,7 +21,8 @@
class UpdateEventEmailTemplateAction extends BaseEmailTemplateAction
{
public function __construct(
- private readonly UpdateEmailTemplateHandler $handler
+ private readonly UpdateEmailTemplateHandler $handler,
+ private readonly EmailTemplateRepositoryInterface $emailTemplateRepository,
)
{
}
@@ -41,15 +43,18 @@ public function __invoke(Request $request, int $eventId, int $templateId): JsonR
$validated = $this->validateUpdateEmailTemplateRequest($request);
try {
+ $existingTemplate = $this->emailTemplateRepository->findById($templateId);
+ $templateType = EmailTemplateType::from($existingTemplate->getTemplateType());
+
$cta = [
'label' => $validated['ctaLabel'],
- 'url_token' => 'order.url', // This will be determined by template type during update
+ 'url_token' => $templateType->ctaUrlToken(),
];
-
+
$template = $this->handler->handle(
new UpsertEmailTemplateDTO(
account_id: $this->getAuthenticatedAccountId(),
- template_type: EmailTemplateType::ORDER_CONFIRMATION, // This will be ignored in update
+ template_type: $templateType,
subject: $validated['subject'],
body: $validated['body'],
organizer_id: null,
diff --git a/backend/app/Http/Actions/EmailTemplates/UpdateOrganizerEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/UpdateOrganizerEmailTemplateAction.php
index 13620b45a8..1ad128ca13 100644
--- a/backend/app/Http/Actions/EmailTemplates/UpdateOrganizerEmailTemplateAction.php
+++ b/backend/app/Http/Actions/EmailTemplates/UpdateOrganizerEmailTemplateAction.php
@@ -10,6 +10,7 @@
use HiEvents\Exceptions\InvalidEmailTemplateException;
use HiEvents\Http\Resources\EmailTemplateResource;
use HiEvents\Http\ResponseCodes;
+use HiEvents\Repository\Interfaces\EmailTemplateRepositoryInterface;
use HiEvents\Services\Application\Handlers\EmailTemplate\UpdateEmailTemplateHandler;
use HiEvents\Services\Application\Handlers\EmailTemplate\DTO\UpsertEmailTemplateDTO;
use Illuminate\Http\JsonResponse;
@@ -20,7 +21,8 @@
class UpdateOrganizerEmailTemplateAction extends BaseEmailTemplateAction
{
public function __construct(
- private readonly UpdateEmailTemplateHandler $handler
+ private readonly UpdateEmailTemplateHandler $handler,
+ private readonly EmailTemplateRepositoryInterface $emailTemplateRepository,
) {
}
@@ -40,15 +42,18 @@ public function __invoke(Request $request, int $organizerId, int $templateId): J
$validated = $this->validateUpdateEmailTemplateRequest($request);
try {
+ $existingTemplate = $this->emailTemplateRepository->findById($templateId);
+ $templateType = EmailTemplateType::from($existingTemplate->getTemplateType());
+
$cta = [
'label' => $validated['ctaLabel'],
- 'url_token' => 'order.url', // This will be determined by template type during update
+ 'url_token' => $templateType->ctaUrlToken(),
];
-
+
$template = $this->handler->handle(
new UpsertEmailTemplateDTO(
account_id: $this->getAuthenticatedAccountId(),
- template_type: EmailTemplateType::ORDER_CONFIRMATION, // This will be ignored in update
+ template_type: $templateType,
subject: $validated['subject'],
body: $validated['body'],
organizer_id: $organizerId,
diff --git a/backend/app/Http/Actions/EventOccurrences/BulkUpdateOccurrencesAction.php b/backend/app/Http/Actions/EventOccurrences/BulkUpdateOccurrencesAction.php
new file mode 100644
index 0000000000..ac08870f81
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/BulkUpdateOccurrencesAction.php
@@ -0,0 +1,58 @@
+isActionAuthorized($eventId, EventDomainObject::class);
+
+ $event = $this->eventRepository->findById($eventId);
+
+ $result = $this->handler->handle(
+ new BulkUpdateOccurrencesDTO(
+ event_id: $eventId,
+ action: BulkOccurrenceAction::from($request->validated('action')),
+ timezone: $event->getTimezone(),
+ start_time_shift: $request->validated('start_time_shift') !== null
+ ? (int) $request->validated('start_time_shift')
+ : null,
+ end_time_shift: $request->validated('end_time_shift') !== null
+ ? (int) $request->validated('end_time_shift')
+ : null,
+ capacity: $request->validated('capacity') !== null ? (int) $request->validated('capacity') : null,
+ clear_capacity: (bool) $request->validated('clear_capacity', false),
+ future_only: (bool) $request->validated('future_only', true),
+ skip_overridden: (bool) $request->validated('skip_overridden', true),
+ refund_orders: (bool) $request->validated('refund_orders', false),
+ occurrence_ids: $request->validated('occurrence_ids'),
+ apply_to_all: (bool) $request->validated('apply_to_all', false),
+ label: $request->validated('label'),
+ clear_label: (bool) $request->validated('clear_label', false),
+ duration_minutes: $request->validated('duration_minutes') !== null
+ ? (int) $request->validated('duration_minutes')
+ : null,
+ )
+ );
+
+ return $this->jsonResponse([
+ 'updated_count' => $result->updated_count,
+ 'updated_ids' => $result->updated_ids,
+ ]);
+ }
+}
diff --git a/backend/app/Http/Actions/EventOccurrences/CancelOccurrenceAction.php b/backend/app/Http/Actions/EventOccurrences/CancelOccurrenceAction.php
new file mode 100644
index 0000000000..17f3412d46
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/CancelOccurrenceAction.php
@@ -0,0 +1,33 @@
+isActionAuthorized($eventId, EventDomainObject::class);
+
+ $occurrence = $this->handler->handle(
+ eventId: $eventId,
+ occurrenceId: $occurrenceId,
+ refundOrders: (bool) $request->validated('refund_orders', false),
+ );
+
+ return $this->resourceResponse(
+ resource: EventOccurrenceResource::class,
+ data: $occurrence,
+ );
+ }
+}
diff --git a/backend/app/Http/Actions/EventOccurrences/CreateEventOccurrenceAction.php b/backend/app/Http/Actions/EventOccurrences/CreateEventOccurrenceAction.php
new file mode 100644
index 0000000000..9aec2e065b
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/CreateEventOccurrenceAction.php
@@ -0,0 +1,48 @@
+isActionAuthorized($eventId, EventDomainObject::class);
+
+ $event = $this->eventRepository->findById($eventId);
+ $timezone = $event->getTimezone();
+
+ $startDate = $request->validated('start_date');
+ $endDate = $request->validated('end_date');
+
+ $occurrence = $this->handler->handle(
+ new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: DateHelper::convertToUTC($startDate, $timezone),
+ end_date: $endDate ? DateHelper::convertToUTC($endDate, $timezone) : null,
+ capacity: $request->validated('capacity'),
+ label: $request->validated('label'),
+ is_overridden: true,
+ )
+ );
+
+ return $this->resourceResponse(
+ resource: EventOccurrenceResource::class,
+ data: $occurrence,
+ );
+ }
+}
diff --git a/backend/app/Http/Actions/EventOccurrences/DeleteEventOccurrenceAction.php b/backend/app/Http/Actions/EventOccurrences/DeleteEventOccurrenceAction.php
new file mode 100644
index 0000000000..b80cd03db2
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/DeleteEventOccurrenceAction.php
@@ -0,0 +1,26 @@
+isActionAuthorized($eventId, EventDomainObject::class);
+
+ $this->handler->handle($eventId, $occurrenceId);
+
+ return $this->deletedResponse();
+ }
+}
diff --git a/backend/app/Http/Actions/EventOccurrences/DeletePriceOverrideAction.php b/backend/app/Http/Actions/EventOccurrences/DeletePriceOverrideAction.php
new file mode 100644
index 0000000000..f371e5eb4f
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/DeletePriceOverrideAction.php
@@ -0,0 +1,26 @@
+isActionAuthorized($eventId, EventDomainObject::class);
+
+ $this->handler->handle($eventId, $occurrenceId, $overrideId);
+
+ return $this->deletedResponse();
+ }
+}
diff --git a/backend/app/Http/Actions/EventOccurrences/GenerateOccurrencesAction.php b/backend/app/Http/Actions/EventOccurrences/GenerateOccurrencesAction.php
new file mode 100644
index 0000000000..0099f4aa6a
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/GenerateOccurrencesAction.php
@@ -0,0 +1,43 @@
+isActionAuthorized($eventId, EventDomainObject::class);
+
+ try {
+ $occurrences = $this->handler->handle(
+ new GenerateOccurrencesDTO(
+ event_id: $eventId,
+ recurrence_rule: $request->validated('recurrence_rule'),
+ )
+ );
+ } catch (InvalidRecurrenceRuleException $e) {
+ throw ValidationException::withMessages([
+ 'recurrence_rule' => [$e->getMessage()],
+ ]);
+ }
+
+ return $this->resourceResponse(
+ resource: EventOccurrenceResource::class,
+ data: $occurrences,
+ );
+ }
+}
diff --git a/backend/app/Http/Actions/EventOccurrences/GetEventOccurrenceAction.php b/backend/app/Http/Actions/EventOccurrences/GetEventOccurrenceAction.php
new file mode 100644
index 0000000000..9d280e31c1
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/GetEventOccurrenceAction.php
@@ -0,0 +1,30 @@
+isActionAuthorized($eventId, EventDomainObject::class);
+
+ $occurrence = $this->handler->handle($eventId, $occurrenceId);
+
+ return $this->resourceResponse(
+ resource: EventOccurrenceResource::class,
+ data: $occurrence,
+ );
+ }
+}
diff --git a/backend/app/Http/Actions/EventOccurrences/GetEventOccurrencesAction.php b/backend/app/Http/Actions/EventOccurrences/GetEventOccurrencesAction.php
new file mode 100644
index 0000000000..322cbad145
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/GetEventOccurrencesAction.php
@@ -0,0 +1,40 @@
+isActionAuthorized($eventId, EventDomainObject::class);
+
+ // include_stats=false skips the per-row statistics relation for selector
+ // use cases (occurrence dropdowns) where the stats payload is wasted.
+ $includeStats = $request->boolean('include_stats', true);
+
+ $occurrences = $this->handler->handle(
+ $eventId,
+ QueryParamsDTO::fromArray($request->query->all()),
+ $includeStats,
+ );
+
+ return $this->filterableResourceResponse(
+ resource: EventOccurrenceResource::class,
+ data: $occurrences,
+ domainObject: EventOccurrenceDomainObject::class,
+ );
+ }
+}
diff --git a/backend/app/Http/Actions/EventOccurrences/GetPriceOverridesAction.php b/backend/app/Http/Actions/EventOccurrences/GetPriceOverridesAction.php
new file mode 100644
index 0000000000..c5c47cf323
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/GetPriceOverridesAction.php
@@ -0,0 +1,30 @@
+isActionAuthorized($eventId, EventDomainObject::class);
+
+ $overrides = $this->handler->handle($eventId, $occurrenceId);
+
+ return $this->resourceResponse(
+ resource: ProductPriceOccurrenceOverrideResource::class,
+ data: $overrides,
+ );
+ }
+}
diff --git a/backend/app/Http/Actions/EventOccurrences/GetProductVisibilityAction.php b/backend/app/Http/Actions/EventOccurrences/GetProductVisibilityAction.php
new file mode 100644
index 0000000000..9d9c2762f4
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/GetProductVisibilityAction.php
@@ -0,0 +1,30 @@
+isActionAuthorized($eventId, EventDomainObject::class);
+
+ $visibility = $this->handler->handle($eventId, $occurrenceId);
+
+ return $this->resourceResponse(
+ resource: ProductOccurrenceVisibilityResource::class,
+ data: $visibility,
+ );
+ }
+}
diff --git a/backend/app/Http/Actions/EventOccurrences/ReactivateOccurrenceAction.php b/backend/app/Http/Actions/EventOccurrences/ReactivateOccurrenceAction.php
new file mode 100644
index 0000000000..b5b902af8c
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/ReactivateOccurrenceAction.php
@@ -0,0 +1,32 @@
+isActionAuthorized($eventId, EventDomainObject::class);
+
+ $occurrence = $this->handler->handle(
+ eventId: $eventId,
+ occurrenceId: $occurrenceId,
+ );
+
+ return $this->resourceResponse(
+ resource: EventOccurrenceResource::class,
+ data: $occurrence,
+ );
+ }
+}
diff --git a/backend/app/Http/Actions/EventOccurrences/UpdateEventOccurrenceAction.php b/backend/app/Http/Actions/EventOccurrences/UpdateEventOccurrenceAction.php
new file mode 100644
index 0000000000..4cef0195b5
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/UpdateEventOccurrenceAction.php
@@ -0,0 +1,48 @@
+isActionAuthorized($eventId, EventDomainObject::class);
+
+ $event = $this->eventRepository->findById($eventId);
+ $timezone = $event->getTimezone();
+
+ $startDate = $request->validated('start_date');
+ $endDate = $request->validated('end_date');
+
+ $occurrence = $this->handler->handle(
+ $occurrenceId,
+ new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: DateHelper::convertToUTC($startDate, $timezone),
+ end_date: $endDate ? DateHelper::convertToUTC($endDate, $timezone) : null,
+ capacity: $request->validated('capacity'),
+ label: $request->validated('label'),
+ )
+ );
+
+ return $this->resourceResponse(
+ resource: EventOccurrenceResource::class,
+ data: $occurrence,
+ );
+ }
+}
diff --git a/backend/app/Http/Actions/EventOccurrences/UpdateProductVisibilityAction.php b/backend/app/Http/Actions/EventOccurrences/UpdateProductVisibilityAction.php
new file mode 100644
index 0000000000..eeffd8722f
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/UpdateProductVisibilityAction.php
@@ -0,0 +1,38 @@
+isActionAuthorized($eventId, EventDomainObject::class);
+
+ $visibility = $this->handler->handle(
+ new UpdateProductVisibilityDTO(
+ event_id: $eventId,
+ event_occurrence_id: $occurrenceId,
+ product_ids: $request->validated('product_ids'),
+ )
+ );
+
+ return $this->resourceResponse(
+ resource: ProductOccurrenceVisibilityResource::class,
+ data: $visibility,
+ );
+ }
+}
diff --git a/backend/app/Http/Actions/EventOccurrences/UpsertPriceOverrideAction.php b/backend/app/Http/Actions/EventOccurrences/UpsertPriceOverrideAction.php
new file mode 100644
index 0000000000..057b50d5bb
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/UpsertPriceOverrideAction.php
@@ -0,0 +1,39 @@
+isActionAuthorized($eventId, EventDomainObject::class);
+
+ $override = $this->handler->handle(
+ new UpsertPriceOverrideDTO(
+ event_id: $eventId,
+ event_occurrence_id: $occurrenceId,
+ product_price_id: $request->validated('product_price_id'),
+ price: (float) $request->validated('price'),
+ )
+ );
+
+ return $this->resourceResponse(
+ resource: ProductPriceOccurrenceOverrideResource::class,
+ data: $override,
+ );
+ }
+}
diff --git a/backend/app/Http/Actions/Events/DuplicateEventAction.php b/backend/app/Http/Actions/Events/DuplicateEventAction.php
index 160328fd27..5515a107af 100644
--- a/backend/app/Http/Actions/Events/DuplicateEventAction.php
+++ b/backend/app/Http/Actions/Events/DuplicateEventAction.php
@@ -39,6 +39,7 @@ public function __invoke(int $eventId, DuplicateEventRequest $request): JsonResp
duplicateTicketLogo: $request->validated('duplicate_ticket_logo'),
duplicateWebhooks: $request->validated('duplicate_webhooks'),
duplicateAffiliates: $request->validated('duplicate_affiliates'),
+ duplicateOccurrences: $request->validated('duplicate_occurrences') ?? true,
description: $request->validated('description'),
endDate: $request->validated('end_date'),
));
diff --git a/backend/app/Http/Actions/Events/GetEventAction.php b/backend/app/Http/Actions/Events/GetEventAction.php
index 5df8dd1ccb..afbfcb5655 100644
--- a/backend/app/Http/Actions/Events/GetEventAction.php
+++ b/backend/app/Http/Actions/Events/GetEventAction.php
@@ -5,6 +5,7 @@
namespace HiEvents\Http\Actions\Events;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\ImageDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\DomainObjects\ProductCategoryDomainObject;
@@ -33,6 +34,7 @@ public function __invoke(int $eventId): JsonResponse
$event = $this->eventRepository
->loadRelation(new Relationship(domainObject: OrganizerDomainObject::class, name: 'organizer'))
->loadRelation(new Relationship(ImageDomainObject::class))
+ ->loadRelation(new Relationship(EventOccurrenceDomainObject::class))
->loadRelation(
new Relationship(ProductCategoryDomainObject::class, [
new Relationship(ProductDomainObject::class, [
diff --git a/backend/app/Http/Actions/Events/GetEventPublicAction.php b/backend/app/Http/Actions/Events/GetEventPublicAction.php
index 6f909b2724..c920066e48 100644
--- a/backend/app/Http/Actions/Events/GetEventPublicAction.php
+++ b/backend/app/Http/Actions/Events/GetEventPublicAction.php
@@ -30,6 +30,7 @@ public function __invoke(int $eventId, Request $request): Response|JsonResponse
'ipAddress' => $this->getClientIp($request),
'promoCode' => strtolower($request->string('promo_code')),
'isAuthenticated' => $this->isUserAuthenticated(),
+ 'eventOccurrenceId' => $request->integer('event_occurrence_id') ?: null,
]));
if (!$this->canUserViewEvent($event)) {
diff --git a/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php b/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php
index aa45ebb860..33f91ea0ba 100644
--- a/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php
+++ b/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php
@@ -23,10 +23,12 @@ public function __invoke(int $eventId, Request $request): JsonResponse
$this->isActionAuthorized($eventId, EventDomainObject::class);
$dateRangePreset = $request->query('date_range', 'month');
+ $occurrenceIdQuery = $request->query('occurrence_id');
$stats = $this->eventStatsHandler->handle(EventStatsRequestDTO::fromArray([
'event_id' => $eventId,
'date_range_preset' => $dateRangePreset,
+ 'occurrence_id' => $occurrenceIdQuery !== null ? (int)$occurrenceIdQuery : null,
]));
return $this->resourceResponse(JsonResource::class, $stats);
diff --git a/backend/app/Http/Actions/Messages/SendMessageAction.php b/backend/app/Http/Actions/Messages/SendMessageAction.php
index 8c72b11049..a09b532919 100644
--- a/backend/app/Http/Actions/Messages/SendMessageAction.php
+++ b/backend/app/Http/Actions/Messages/SendMessageAction.php
@@ -29,20 +29,24 @@ public function __invoke(SendMessageRequest $request, int $eventId): JsonRespons
$user = $this->getAuthenticatedUser();
try {
+ $validated = $request->validated();
+
$message = $this->messageHandler->handle(SendMessageDTO::fromArray([
'event_id' => $eventId,
- 'subject' => $request->input('subject'),
- 'message' => $request->input('message'),
- 'type' => $request->input('message_type'),
- 'is_test' => $request->input('is_test'),
- 'order_id' => $request->input('order_id'),
- 'attendee_ids' => $request->input('attendee_ids'),
- 'product_ids' => $request->input('product_ids'),
- 'order_statuses' => $request->input('order_statuses'),
- 'send_copy_to_current_user' => $request->boolean('send_copy_to_current_user'),
+ 'subject' => $validated['subject'],
+ 'message' => $validated['message'],
+ 'type' => $validated['message_type'],
+ 'is_test' => (bool) ($validated['is_test'] ?? false),
+ 'order_id' => $validated['order_id'] ?? null,
+ 'attendee_ids' => $validated['attendee_ids'] ?? [],
+ 'product_ids' => $validated['product_ids'] ?? [],
+ 'order_statuses' => $validated['order_statuses'] ?? [],
+ 'send_copy_to_current_user' => (bool) ($validated['send_copy_to_current_user'] ?? false),
'sent_by_user_id' => $user->getId(),
'account_id' => $this->getAuthenticatedAccountId(),
- 'scheduled_at' => $request->input('scheduled_at'),
+ 'scheduled_at' => $validated['scheduled_at'] ?? null,
+ 'event_occurrence_id' => $validated['event_occurrence_id'] ?? null,
+ 'event_occurrence_ids' => $validated['event_occurrence_ids'] ?? null,
]));
} catch (AccountNotVerifiedException $e) {
return $this->errorResponse($e->getMessage(), Response::HTTP_UNAUTHORIZED);
diff --git a/backend/app/Http/Actions/Orders/ExportOrdersAction.php b/backend/app/Http/Actions/Orders/ExportOrdersAction.php
index 66043857d6..c6b62a286c 100644
--- a/backend/app/Http/Actions/Orders/ExportOrdersAction.php
+++ b/backend/app/Http/Actions/Orders/ExportOrdersAction.php
@@ -4,12 +4,17 @@
use HiEvents\DomainObjects\Enums\QuestionBelongsTo;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
+use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\DomainObjects\QuestionAndAnswerViewDomainObject;
use HiEvents\Exports\OrdersExport;
use HiEvents\Http\Actions\BaseAction;
+use HiEvents\Http\DTO\FilterFieldDTO;
use HiEvents\Http\DTO\QueryParamsDTO;
+use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Repository\Interfaces\QuestionRepositoryInterface;
+use Illuminate\Http\Request;
use Maatwebsite\Excel\Facades\Excel;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -23,16 +28,37 @@ public function __construct(
{
}
- public function __invoke(int $eventId): BinaryFileResponse
+ public function __invoke(Request $request, int $eventId): BinaryFileResponse
{
$this->isActionAuthorized($eventId, EventDomainObject::class);
+ $eventOccurrenceId = $request->input('event_occurrence_id') ? (int) $request->input('event_occurrence_id') : null;
+
+ $filterFields = collect();
+ if ($eventOccurrenceId !== null) {
+ $filterFields->push(new FilterFieldDTO(
+ field: 'event_occurrence_id',
+ operator: 'eq',
+ value: (string) $eventOccurrenceId,
+ ));
+ }
+
$orders = $this->orderRepository
->setMaxPerPage(10000)
->loadRelation(QuestionAndAnswerViewDomainObject::class)
+ ->loadRelation(new Relationship(
+ domainObject: OrderItemDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ name: 'event_occurrence',
+ ),
+ ],
+ ))
->findByEventId($eventId, new QueryParamsDTO(
page: 1,
per_page: 10000,
+ filter_fields: $filterFields->isNotEmpty() ? $filterFields : null,
));
$questions = $this->questionRepository->findWhere([
diff --git a/backend/app/Http/Actions/Orders/GetOrderAction.php b/backend/app/Http/Actions/Orders/GetOrderAction.php
index 6947037904..ff831f03da 100644
--- a/backend/app/Http/Actions/Orders/GetOrderAction.php
+++ b/backend/app/Http/Actions/Orders/GetOrderAction.php
@@ -4,6 +4,7 @@
use HiEvents\DomainObjects\AttendeeDomainObject;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract;
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\DomainObjects\QuestionAndAnswerViewDomainObject;
@@ -32,7 +33,15 @@ public function __invoke(int $eventId, int $orderId): JsonResponse
$this->isActionAuthorized($eventId, EventDomainObject::class);
$order = $this->orderRepository
- ->loadRelation(OrderItemDomainObject::class)
+ ->loadRelation(new Relationship(
+ domainObject: OrderItemDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ name: 'event_occurrence',
+ ),
+ ],
+ ))
->loadRelation(AttendeeDomainObject::class)
->loadRelation(new Relationship(domainObject: QuestionAndAnswerViewDomainObject::class, orderAndDirections: [
new OrderAndDirection(order: 'question_id'),
diff --git a/backend/app/Http/Actions/Orders/GetOrdersAction.php b/backend/app/Http/Actions/Orders/GetOrdersAction.php
index c8f9575dc9..16ab6e9e08 100644
--- a/backend/app/Http/Actions/Orders/GetOrdersAction.php
+++ b/backend/app/Http/Actions/Orders/GetOrdersAction.php
@@ -4,10 +4,12 @@
use HiEvents\DomainObjects\AttendeeDomainObject;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\InvoiceDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\Http\Actions\BaseAction;
+use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Resources\Order\OrderResource;
use Illuminate\Http\JsonResponse;
@@ -27,7 +29,15 @@ public function __invoke(Request $request, int $eventId): JsonResponse
$this->isActionAuthorized($eventId, EventDomainObject::class);
$orders = $this->orderRepository
- ->loadRelation(OrderItemDomainObject::class)
+ ->loadRelation(new Relationship(
+ domainObject: OrderItemDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ name: 'event_occurrence',
+ ),
+ ],
+ ))
->loadRelation(AttendeeDomainObject::class)
->loadRelation(InvoiceDomainObject::class)
->findByEventId($eventId, $this->getPaginationQueryParams($request));
diff --git a/backend/app/Http/Actions/Orders/Public/CreateOrderActionPublic.php b/backend/app/Http/Actions/Orders/Public/CreateOrderActionPublic.php
index d2de957f9a..23822973e2 100644
--- a/backend/app/Http/Actions/Orders/Public/CreateOrderActionPublic.php
+++ b/backend/app/Http/Actions/Orders/Public/CreateOrderActionPublic.php
@@ -20,21 +20,19 @@
class CreateOrderActionPublic extends BaseAction
{
public function __construct(
- private readonly CreateOrderHandler $orderHandler,
+ private readonly CreateOrderHandler $orderHandler,
private readonly OrderCreateRequestValidationService $orderCreateRequestValidationService,
- private readonly CheckoutSessionManagementService $sessionIdentifierService,
- private readonly LocaleService $localeService,
+ private readonly CheckoutSessionManagementService $sessionIdentifierService,
+ private readonly LocaleService $localeService,
- )
- {
- }
+ ) {}
/**
* @throws Throwable
*/
public function __invoke(CreateOrderRequest $request, int $eventId): JsonResponse
{
- $this->orderCreateRequestValidationService->validateRequestData($eventId, $request->all());
+ $validatedData = $this->orderCreateRequestValidationService->validateRequestData($eventId, $request->all());
$sessionId = $this->sessionIdentifierService->getSessionId();
$order = $this->orderHandler->handle(
@@ -43,7 +41,7 @@ public function __invoke(CreateOrderRequest $request, int $eventId): JsonRespons
'is_user_authenticated' => $this->isUserAuthenticated(),
'promo_code' => $request->input('promo_code'),
'affiliate_code' => $request->input('affiliate_code'),
- 'products' => ProductOrderDetailsDTO::collectionFromArray($request->input('products')),
+ 'products' => ProductOrderDetailsDTO::collectionFromArray($validatedData['products']),
'session_identifier' => $sessionId,
'order_locale' => $this->localeService->getLocaleOrDefault($request->getPreferredLanguage()),
])
@@ -51,7 +49,7 @@ public function __invoke(CreateOrderRequest $request, int $eventId): JsonRespons
$order->setSessionIdentifier($sessionId);
- $response = $this->resourceResponse(
+ $response = $this->resourceResponse(
resource: OrderResourcePublic::class,
data: $order,
statusCode: ResponseCodes::HTTP_CREATED,
diff --git a/backend/app/Http/Actions/Organizers/GetOrganizerAction.php b/backend/app/Http/Actions/Organizers/GetOrganizerAction.php
index 896c7cd0cc..e38dda8639 100644
--- a/backend/app/Http/Actions/Organizers/GetOrganizerAction.php
+++ b/backend/app/Http/Actions/Organizers/GetOrganizerAction.php
@@ -3,7 +3,10 @@
namespace HiEvents\Http\Actions\Organizers;
use HiEvents\DomainObjects\ImageDomainObject;
+use HiEvents\DomainObjects\OrganizerConfigurationDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
+use HiEvents\DomainObjects\OrganizerStripePlatformDomainObject;
+use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface;
use HiEvents\Resources\Organizer\OrganizerResource;
@@ -24,6 +27,11 @@ public function __invoke(int $organizerId): Response
$organizer = $this->organizerRepository
->loadRelation(ImageDomainObject::class)
+ ->loadRelation(OrganizerStripePlatformDomainObject::class)
+ ->loadRelation(new Relationship(
+ domainObject: OrganizerConfigurationDomainObject::class,
+ name: 'organizer_configuration',
+ ))
->findFirstWhere([
'id' => $organizerId,
'account_id' => $this->getAuthenticatedAccountId(),
diff --git a/backend/app/Http/Actions/Organizers/Stripe/CopyStripeConnectAccountAction.php b/backend/app/Http/Actions/Organizers/Stripe/CopyStripeConnectAccountAction.php
new file mode 100644
index 0000000000..0650f8f8e6
--- /dev/null
+++ b/backend/app/Http/Actions/Organizers/Stripe/CopyStripeConnectAccountAction.php
@@ -0,0 +1,62 @@
+isActionAuthorized($organizerId, OrganizerDomainObject::class, Role::ADMIN);
+ $this->isActionAuthorized($sourceOrganizerId, OrganizerDomainObject::class, Role::ADMIN);
+
+ try {
+ $result = $this->copyStripeConnectAccountHandler->handle(CopyStripeConnectAccountDTO::from([
+ 'targetOrganizerId' => $organizerId,
+ 'sourceOrganizerId' => $sourceOrganizerId,
+ 'accountId' => $this->getAuthenticatedAccountId(),
+ ]));
+ } catch (SaasModeEnabledException $e) {
+ return $this->errorResponse(
+ message: $e->getMessage(),
+ statusCode: Response::HTTP_FORBIDDEN,
+ );
+ } catch (ResourceNotFoundException $e) {
+ return $this->errorResponse(
+ message: $e->getMessage(),
+ statusCode: Response::HTTP_NOT_FOUND,
+ );
+ } catch (ResourceConflictException $e) {
+ return $this->errorResponse(
+ message: $e->getMessage(),
+ statusCode: Response::HTTP_CONFLICT,
+ );
+ }
+
+ return $this->resourceResponse(
+ resource: OrganizerStripeConnectAccountResponseResource::class,
+ data: $result,
+ );
+ }
+}
diff --git a/backend/app/Http/Actions/Accounts/Stripe/CreateStripeConnectAccountAction.php b/backend/app/Http/Actions/Organizers/Stripe/CreateStripeConnectAccountAction.php
similarity index 56%
rename from backend/app/Http/Actions/Accounts/Stripe/CreateStripeConnectAccountAction.php
rename to backend/app/Http/Actions/Organizers/Stripe/CreateStripeConnectAccountAction.php
index 224121ebfb..1809383500 100644
--- a/backend/app/Http/Actions/Accounts/Stripe/CreateStripeConnectAccountAction.php
+++ b/backend/app/Http/Actions/Organizers/Stripe/CreateStripeConnectAccountAction.php
@@ -1,17 +1,18 @@
isActionAuthorized($accountId, AccountDomainObject::class, Role::ADMIN);
+ $this->isActionAuthorized($organizerId, OrganizerDomainObject::class, Role::ADMIN);
try {
- $accountResult = $this->createStripeConnectAccountHandler->handle(CreateStripeConnectAccountDTO::from([
+ $result = $this->createStripeConnectAccountHandler->handle(CreateStripeConnectAccountDTO::from([
+ 'organizerId' => $organizerId,
'accountId' => $this->getAuthenticatedAccountId(),
'platform' => $request->has('platform')
? StripePlatform::from($request->get('platform'))
@@ -42,18 +44,23 @@ public function __invoke(int $accountId, Request $request): JsonResponse
} catch (CreateStripeConnectAccountLinksFailedException|CreateStripeConnectAccountFailedException $e) {
return $this->errorResponse(
message: $e->getMessage(),
- statusCode: Response::HTTP_INTERNAL_SERVER_ERROR
+ statusCode: Response::HTTP_INTERNAL_SERVER_ERROR,
);
} catch (SaasModeEnabledException $e) {
return $this->errorResponse(
message: $e->getMessage(),
- statusCode: Response::HTTP_FORBIDDEN
+ statusCode: Response::HTTP_FORBIDDEN,
+ );
+ } catch (ResourceNotFoundException $e) {
+ return $this->errorResponse(
+ message: $e->getMessage(),
+ statusCode: Response::HTTP_NOT_FOUND,
);
}
return $this->resourceResponse(
- resource: StripeConnectAccountResponseResource::class,
- data: $accountResult
+ resource: OrganizerStripeConnectAccountResponseResource::class,
+ data: $result,
);
}
}
diff --git a/backend/app/Http/Actions/Organizers/Stripe/GetStripeConnectAccountsAction.php b/backend/app/Http/Actions/Organizers/Stripe/GetStripeConnectAccountsAction.php
new file mode 100644
index 0000000000..115f870d6e
--- /dev/null
+++ b/backend/app/Http/Actions/Organizers/Stripe/GetStripeConnectAccountsAction.php
@@ -0,0 +1,44 @@
+isActionAuthorized($organizerId, OrganizerDomainObject::class, Role::ADMIN);
+
+ try {
+ $result = $this->getStripeConnectAccountsHandler->handle($organizerId, $this->getAuthenticatedAccountId());
+ } catch (ResourceNotFoundException $e) {
+ return $this->errorResponse(
+ message: $e->getMessage(),
+ statusCode: Response::HTTP_NOT_FOUND,
+ );
+ }
+
+ return $this->resourceResponse(
+ resource: OrganizerStripeConnectAccountsResponseResource::class,
+ data: $result,
+ );
+ }
+}
diff --git a/backend/app/Http/Actions/Organizers/Vat/GetOrganizerVatSettingAction.php b/backend/app/Http/Actions/Organizers/Vat/GetOrganizerVatSettingAction.php
new file mode 100644
index 0000000000..5e506dd477
--- /dev/null
+++ b/backend/app/Http/Actions/Organizers/Vat/GetOrganizerVatSettingAction.php
@@ -0,0 +1,33 @@
+isActionAuthorized($organizerId, OrganizerDomainObject::class);
+
+ $vatSetting = $this->handler->handle($organizerId);
+
+ if (!$vatSetting) {
+ return $this->jsonResponse(['data' => null]);
+ }
+
+ return $this->resourceResponse(OrganizerVatSettingResource::class, $vatSetting);
+ }
+}
diff --git a/backend/app/Http/Actions/Organizers/Vat/UpsertOrganizerVatSettingAction.php b/backend/app/Http/Actions/Organizers/Vat/UpsertOrganizerVatSettingAction.php
new file mode 100644
index 0000000000..8d97b4b9bf
--- /dev/null
+++ b/backend/app/Http/Actions/Organizers/Vat/UpsertOrganizerVatSettingAction.php
@@ -0,0 +1,51 @@
+isActionAuthorized($organizerId, OrganizerDomainObject::class, Role::ADMIN);
+
+ $validated = $request->validate([
+ 'vat_registered' => 'required|boolean',
+ 'vat_number' => 'nullable|string|max:20',
+ ]);
+
+ try {
+ $vatSetting = $this->handler->handle(new UpsertOrganizerVatSettingDTO(
+ organizerId: $organizerId,
+ accountId: $this->getAuthenticatedAccountId(),
+ vatRegistered: $validated['vat_registered'],
+ vatNumber: $validated['vat_number'] ?? null,
+ ));
+ } catch (ResourceNotFoundException $e) {
+ return $this->errorResponse(
+ message: $e->getMessage(),
+ statusCode: Response::HTTP_NOT_FOUND,
+ );
+ }
+
+ return $this->resourceResponse(OrganizerVatSettingResource::class, $vatSetting);
+ }
+}
diff --git a/backend/app/Http/Actions/Reports/GetReportAction.php b/backend/app/Http/Actions/Reports/GetReportAction.php
index 5fa1596ac3..2ad7248da3 100644
--- a/backend/app/Http/Actions/Reports/GetReportAction.php
+++ b/backend/app/Http/Actions/Reports/GetReportAction.php
@@ -29,7 +29,7 @@ public function __invoke(GetReportRequest $request, int $eventId, string $report
$this->validateDateRange($request);
if (!in_array($reportType, ReportTypes::valuesArray(), true)) {
- throw new BadRequestHttpException('Invalid report type.');
+ throw new BadRequestHttpException(__('Invalid report type.'));
}
$reportData = $this->reportHandler->handle(
@@ -38,6 +38,7 @@ public function __invoke(GetReportRequest $request, int $eventId, string $report
reportType: ReportTypes::from($reportType),
startDate: $request->validated('start_date'),
endDate: $request->validated('end_date'),
+ occurrenceId: $request->validated('occurrence_id') ? (int) $request->validated('occurrence_id') : null,
),
);
@@ -55,7 +56,9 @@ private function validateDateRange(GetReportRequest $request): void
$diffInDays = Carbon::parse($startDate)->diffInDays(Carbon::parse($endDate));
if ($diffInDays > 370) {
- throw ValidationException::withMessages(['start_date' => 'Date range must be less than 370 days.']);
+ throw ValidationException::withMessages([
+ 'start_date' => __('Date range must be less than 370 days.'),
+ ]);
}
}
}
diff --git a/backend/app/Http/Actions/SelfService/ResendAttendeeTicketPublicAction.php b/backend/app/Http/Actions/SelfService/ResendAttendeeTicketPublicAction.php
index 594f7c2121..db56cade26 100644
--- a/backend/app/Http/Actions/SelfService/ResendAttendeeTicketPublicAction.php
+++ b/backend/app/Http/Actions/SelfService/ResendAttendeeTicketPublicAction.php
@@ -2,20 +2,21 @@
namespace HiEvents\Http\Actions\SelfService;
+use HiEvents\Exceptions\ResourceConflictException;
use HiEvents\Exceptions\SelfServiceDisabledException;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Services\Application\Handlers\SelfService\DTO\ResendEmailPublicDTO;
use HiEvents\Services\Application\Handlers\SelfService\ResendAttendeeTicketPublicHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
+use Illuminate\Http\Response;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
class ResendAttendeeTicketPublicAction extends BaseAction
{
public function __construct(
private readonly ResendAttendeeTicketPublicHandler $handler
- ) {
- }
+ ) {}
public function __invoke(
Request $request,
@@ -39,6 +40,8 @@ public function __invoke(
return $this->errorResponse($e->getMessage(), $e->getCode());
} catch (ResourceNotFoundException $e) {
return $this->errorResponse($e->getMessage(), 404);
+ } catch (ResourceConflictException $e) {
+ return $this->errorResponse($e->getMessage(), Response::HTTP_CONFLICT);
}
}
}
diff --git a/backend/app/Http/Actions/Waitlist/Organizer/GetWaitlistStatsAction.php b/backend/app/Http/Actions/Waitlist/Organizer/GetWaitlistStatsAction.php
index ef72fe63ef..63c8a1f717 100644
--- a/backend/app/Http/Actions/Waitlist/Organizer/GetWaitlistStatsAction.php
+++ b/backend/app/Http/Actions/Waitlist/Organizer/GetWaitlistStatsAction.php
@@ -4,6 +4,7 @@
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\Http\Actions\BaseAction;
+use HiEvents\Http\Request\Waitlist\GetWaitlistStatsRequest;
use HiEvents\Services\Application\Handlers\Waitlist\GetWaitlistStatsHandler;
use Illuminate\Http\JsonResponse;
@@ -15,11 +16,15 @@ public function __construct(
{
}
- public function __invoke(int $eventId): JsonResponse
+ public function __invoke(GetWaitlistStatsRequest $request, int $eventId): JsonResponse
{
$this->isActionAuthorized($eventId, EventDomainObject::class);
+ $validated = $request->validated();
- $stats = $this->handler->handle($eventId);
+ $stats = $this->handler->handle(
+ $eventId,
+ isset($validated['event_occurrence_id']) ? (int) $validated['event_occurrence_id'] : null,
+ );
return $this->jsonResponse([
'total' => $stats->total,
diff --git a/backend/app/Http/Actions/Waitlist/Organizer/OfferWaitlistEntryAction.php b/backend/app/Http/Actions/Waitlist/Organizer/OfferWaitlistEntryAction.php
index 43b22a3bf7..8c26f93064 100644
--- a/backend/app/Http/Actions/Waitlist/Organizer/OfferWaitlistEntryAction.php
+++ b/backend/app/Http/Actions/Waitlist/Organizer/OfferWaitlistEntryAction.php
@@ -33,6 +33,7 @@ public function __invoke(OfferWaitlistEntryRequest $request, int $eventId): Json
product_price_id: $request->validated('product_price_id'),
entry_id: $request->validated('entry_id'),
quantity: $request->validated('quantity') ?? 1,
+ event_occurrence_id: $request->validated('event_occurrence_id'),
));
} catch (NoCapacityAvailableException $exception) {
throw ValidationException::withMessages([
diff --git a/backend/app/Http/Actions/Waitlist/Public/CreateWaitlistEntryActionPublic.php b/backend/app/Http/Actions/Waitlist/Public/CreateWaitlistEntryActionPublic.php
index 5dca378c5b..a0c7ad69fc 100644
--- a/backend/app/Http/Actions/Waitlist/Public/CreateWaitlistEntryActionPublic.php
+++ b/backend/app/Http/Actions/Waitlist/Public/CreateWaitlistEntryActionPublic.php
@@ -30,6 +30,7 @@ public function __invoke(CreateWaitlistEntryRequest $request, int $eventId): Jso
first_name: $request->validated('first_name'),
last_name: $request->validated('last_name'),
locale: $request->input('locale', 'en'),
+ event_occurrence_id: $request->validated('event_occurrence_id'),
));
} catch (ResourceConflictException $e) {
return $this->errorResponse(
diff --git a/backend/app/Http/Request/Attendee/CreateAttendeeRequest.php b/backend/app/Http/Request/Attendee/CreateAttendeeRequest.php
index c73fb80acf..3f0e3fd4d7 100644
--- a/backend/app/Http/Request/Attendee/CreateAttendeeRequest.php
+++ b/backend/app/Http/Request/Attendee/CreateAttendeeRequest.php
@@ -11,9 +11,12 @@ class CreateAttendeeRequest extends BaseRequest
{
public function rules(): array
{
+ $eventId = $this->route('event_id');
+
return [
'product_id' => ['int', 'required'],
- 'product_price_id' => ['int', 'nullable', 'required'],
+ 'event_occurrence_id' => ['int', 'nullable', Rule::exists('event_occurrences', 'id')->where('event_id', $eventId)->whereNull('deleted_at')],
+ 'product_price_id' => ['int', 'nullable'],
'email' => ['required', 'email'],
'first_name' => ['string', 'required', 'max:40'],
'last_name' => ['string', 'max:40'],
@@ -23,6 +26,10 @@ public function rules(): array
'taxes_and_fees.*.tax_or_fee_id' => ['required', 'int'],
'taxes_and_fees.*.amount' => ['required', ...RulesHelper::MONEY],
'locale' => ['required', Rule::in(Locale::getSupportedLocales())],
+ // Organiser opt-in: skip the occurrence-capacity ceiling. Status,
+ // sold-out, and product visibility checks still apply. Audited via
+ // OrderAuditAction::MANUAL_ATTENDEE_CAPACITY_OVERRIDE.
+ 'override_capacity' => ['boolean', 'sometimes'],
];
}
}
diff --git a/backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php b/backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php
index 06372e6760..1672c62514 100644
--- a/backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php
+++ b/backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php
@@ -4,17 +4,31 @@
use HiEvents\Http\Request\BaseRequest;
use HiEvents\Validators\Rules\RulesHelper;
+use Illuminate\Validation\Rule;
class UpsertCheckInListRequest extends BaseRequest
{
public function rules(): array
{
+ $eventId = $this->route('event_id');
+
return [
'name' => RulesHelper::REQUIRED_STRING,
- 'description' => ['nullable', 'string', 'max:255'],
+ 'description' => ['nullable', 'string', 'max:2000'],
'expires_at' => ['nullable', 'date'],
'activates_at' => ['nullable', 'date'],
- 'product_ids' => ['required', 'array', 'min:1'],
+ // Empty/absent = "covers every ticket on the event".
+ 'product_ids' => ['nullable', 'array'],
+ 'event_occurrence_id' => [
+ 'nullable',
+ 'integer',
+ Rule::exists('event_occurrences', 'id')
+ ->where('event_id', $eventId)
+ ->whereNull('deleted_at'),
+ ],
+ 'public_show_attendee_notes' => ['nullable', 'boolean'],
+ 'public_show_question_answers' => ['nullable', 'boolean'],
+ 'public_show_order_details' => ['nullable', 'boolean'],
];
}
@@ -32,7 +46,6 @@ public function withValidator($validator): void
public function messages(): array
{
return [
- 'product_ids.required' => __('Please select at least one product.'),
'expires_at.after' => __('The expiration date must be after the activation date.'),
'activates_at.before' => __('The activation date must be before the expiration date.'),
];
diff --git a/backend/app/Http/Request/Event/DuplicateEventRequest.php b/backend/app/Http/Request/Event/DuplicateEventRequest.php
index 26959d7aea..5a3c68cddb 100644
--- a/backend/app/Http/Request/Event/DuplicateEventRequest.php
+++ b/backend/app/Http/Request/Event/DuplicateEventRequest.php
@@ -24,6 +24,7 @@ public function rules(): array
'duplicate_webhooks' => ['boolean', 'required'],
'duplicate_affiliates' => ['boolean', 'required'],
'duplicate_ticket_logo' => ['boolean', 'required'],
+ 'duplicate_occurrences' => ['boolean', 'nullable'],
];
return array_merge($eventValidations, $duplicateValidations);
diff --git a/backend/app/Http/Request/EventOccurrence/BulkUpdateOccurrencesRequest.php b/backend/app/Http/Request/EventOccurrence/BulkUpdateOccurrencesRequest.php
new file mode 100644
index 0000000000..b0ff0d328e
--- /dev/null
+++ b/backend/app/Http/Request/EventOccurrence/BulkUpdateOccurrencesRequest.php
@@ -0,0 +1,54 @@
+ ['required', 'string', Rule::in(BulkOccurrenceAction::valuesArray())],
+ 'start_time_shift' => ['nullable', 'integer', 'min:-525600', 'max:525600'],
+ 'end_time_shift' => ['nullable', 'integer', 'min:-525600', 'max:525600'],
+ 'capacity' => ['nullable', 'integer', 'min:0'],
+ 'clear_capacity' => ['nullable', 'boolean'],
+ 'future_only' => ['nullable', 'boolean'],
+ 'skip_overridden' => ['nullable', 'boolean'],
+ 'refund_orders' => ['nullable', 'boolean'],
+ // Caller MUST either name the occurrences explicitly (occurrence_ids
+ // non-empty) or opt in to event-wide application via apply_to_all=true.
+ // Previously an absent/empty occurrence_ids silently meant "every
+ // matching occurrence" — a footgun the bulk-edit modal hit by accident.
+ 'apply_to_all' => ['nullable', 'boolean'],
+ 'occurrence_ids' => ['array'],
+ 'occurrence_ids.*' => ['integer'],
+ 'label' => ['nullable', 'string', 'max:255'],
+ 'clear_label' => ['nullable', 'boolean'],
+ 'duration_minutes' => ['nullable', 'integer', 'min:1'],
+ ];
+ }
+
+ public function withValidator(Validator $validator): void
+ {
+ $validator->after(function (Validator $validator) {
+ $applyToAll = (bool) $this->input('apply_to_all', false);
+ $occurrenceIds = $this->input('occurrence_ids');
+
+ if ($applyToAll) {
+ return;
+ }
+
+ if (! is_array($occurrenceIds) || count($occurrenceIds) === 0) {
+ $validator->errors()->add(
+ 'occurrence_ids',
+ __('Specify at least one occurrence_id, or set apply_to_all to true to update every matching occurrence.'),
+ );
+ }
+ });
+ }
+}
diff --git a/backend/app/Http/Request/EventOccurrence/CancelOccurrenceRequest.php b/backend/app/Http/Request/EventOccurrence/CancelOccurrenceRequest.php
new file mode 100644
index 0000000000..f05cfee539
--- /dev/null
+++ b/backend/app/Http/Request/EventOccurrence/CancelOccurrenceRequest.php
@@ -0,0 +1,15 @@
+ ['nullable', 'boolean'],
+ ];
+ }
+}
diff --git a/backend/app/Http/Request/EventOccurrence/GenerateOccurrencesRequest.php b/backend/app/Http/Request/EventOccurrence/GenerateOccurrencesRequest.php
new file mode 100644
index 0000000000..20915d1180
--- /dev/null
+++ b/backend/app/Http/Request/EventOccurrence/GenerateOccurrencesRequest.php
@@ -0,0 +1,68 @@
+ ['required', 'array'],
+ 'recurrence_rule.frequency' => ['required', 'string', 'in:daily,weekly,monthly,yearly'],
+ 'recurrence_rule.interval' => ['nullable', 'integer', 'min:1'],
+ 'recurrence_rule.range' => ['required', 'array'],
+ 'recurrence_rule.range.type' => ['required', 'string', 'in:count,until'],
+ 'recurrence_rule.range.count' => ['required_if:recurrence_rule.range.type,count', 'integer', 'min:1', 'max:1200'],
+ 'recurrence_rule.range.until' => ['required_if:recurrence_rule.range.type,until', 'date'],
+ 'recurrence_rule.range.start' => ['nullable', 'date'],
+ 'recurrence_rule.days_of_week' => ['required_if:recurrence_rule.frequency,weekly', 'array'],
+ 'recurrence_rule.days_of_week.*' => ['string', 'in:monday,tuesday,wednesday,thursday,friday,saturday,sunday'],
+ 'recurrence_rule.times_of_day' => ['nullable', 'array', 'max:24'],
+ 'recurrence_rule.times_of_day.*' => [function ($attribute, $value, $fail) {
+ if (is_string($value)) {
+ if (!preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $value)) {
+ $fail(__('Each time of day must be in HH:MM 24-hour format.'));
+ }
+ return;
+ }
+
+ // For array entries we validate `time` here rather than via the
+ // sibling `*.time` rule because Laravel's `required_if:foo,value`
+ // compares foo to the literal string "value" and has no built-in
+ // way to express "required when the parent is an array" — the
+ // sibling rule never fires for these entries.
+ if (is_array($value)) {
+ if (! isset($value['time']) || ! is_string($value['time'])) {
+ $fail(__('Each time of day object must include a time field.'));
+ return;
+ }
+ if (! preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $value['time'])) {
+ $fail(__('Each time of day must be in HH:MM 24-hour format.'));
+ }
+ return;
+ }
+
+ $fail(__('Each time of day must be a HH:MM string or an object with a time field.'));
+ }],
+ 'recurrence_rule.times_of_day.*.label' => ['nullable', 'string', 'max:255'],
+ 'recurrence_rule.times_of_day.*.duration_minutes' => ['nullable', 'integer', 'min:1', 'max:10080'],
+ 'recurrence_rule.duration_minutes' => ['nullable', 'integer', 'min:1', 'max:10080'],
+ 'recurrence_rule.default_capacity' => ['nullable', 'integer', 'min:0'],
+ 'recurrence_rule.excluded_dates' => ['nullable', 'array', 'max:1200'],
+ 'recurrence_rule.excluded_dates.*' => ['date'],
+ 'recurrence_rule.excluded_occurrences' => ['nullable', 'array', 'max:1200'],
+ 'recurrence_rule.excluded_occurrences.*' => ['date_format:Y-m-d H:i'],
+ 'recurrence_rule.additional_dates' => ['nullable', 'array', 'max:1200'],
+ 'recurrence_rule.additional_dates.*.date' => ['required', 'date'],
+ 'recurrence_rule.additional_dates.*.time' => ['nullable', 'string', 'date_format:H:i'],
+ 'recurrence_rule.monthly_pattern' => ['nullable', 'string', 'in:by_day_of_month,by_day_of_week'],
+ 'recurrence_rule.days_of_month' => ['nullable', 'array'],
+ 'recurrence_rule.days_of_month.*' => ['integer', 'min:1', 'max:31'],
+ 'recurrence_rule.day_of_week' => ['nullable', 'string', 'in:monday,tuesday,wednesday,thursday,friday,saturday,sunday'],
+ 'recurrence_rule.week_position' => ['nullable', 'integer', 'in:-1,1,2,3,4'],
+ 'recurrence_rule.month' => ['nullable', 'integer', 'min:1', 'max:12'],
+ ];
+ }
+}
diff --git a/backend/app/Http/Request/EventOccurrence/ReactivateOccurrenceRequest.php b/backend/app/Http/Request/EventOccurrence/ReactivateOccurrenceRequest.php
new file mode 100644
index 0000000000..e89545ef08
--- /dev/null
+++ b/backend/app/Http/Request/EventOccurrence/ReactivateOccurrenceRequest.php
@@ -0,0 +1,13 @@
+ ['required', 'array', 'min:1'],
+ 'product_ids.*' => ['integer', 'distinct'],
+ ];
+ }
+
+ public function messages(): array
+ {
+ return [
+ 'product_ids.min' => __('Select at least one product. To make a date inaccessible, cancel it from the schedule instead.'),
+ ];
+ }
+}
diff --git a/backend/app/Http/Request/EventOccurrence/UpsertEventOccurrenceRequest.php b/backend/app/Http/Request/EventOccurrence/UpsertEventOccurrenceRequest.php
new file mode 100644
index 0000000000..ea6005c304
--- /dev/null
+++ b/backend/app/Http/Request/EventOccurrence/UpsertEventOccurrenceRequest.php
@@ -0,0 +1,26 @@
+ ['required', 'date'],
+ 'end_date' => ['nullable', 'date', 'after:start_date'],
+ 'capacity' => ['nullable', 'integer', 'min:0'],
+ 'label' => ['nullable', 'string', 'max:255'],
+ ];
+ }
+}
diff --git a/backend/app/Http/Request/EventOccurrence/UpsertPriceOverrideRequest.php b/backend/app/Http/Request/EventOccurrence/UpsertPriceOverrideRequest.php
new file mode 100644
index 0000000000..e7cbdf7efc
--- /dev/null
+++ b/backend/app/Http/Request/EventOccurrence/UpsertPriceOverrideRequest.php
@@ -0,0 +1,16 @@
+ ['required', 'integer'],
+ 'price' => ['required', 'numeric', 'min:0', 'max:100000000'],
+ ];
+ }
+}
diff --git a/backend/app/Http/Request/Message/SendMessageRequest.php b/backend/app/Http/Request/Message/SendMessageRequest.php
index 5b12e009c6..52e382c930 100644
--- a/backend/app/Http/Request/Message/SendMessageRequest.php
+++ b/backend/app/Http/Request/Message/SendMessageRequest.php
@@ -5,37 +5,69 @@
use HiEvents\DomainObjects\Enums\MessageTypeEnum;
use HiEvents\DomainObjects\Status\OrderStatus;
use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
class SendMessageRequest extends FormRequest
{
public function rules(): array
{
+ $eventId = $this->route('event_id');
+
return [
'subject' => 'required|string|max:100',
'message' => 'required|string|max:8000',
'message_type' => [new In(MessageTypeEnum::valuesArray()), 'required'],
'is_test' => 'boolean',
- 'attendee_ids' => 'max:50,array|required_if:message_type,' . MessageTypeEnum::INDIVIDUAL_ATTENDEES->name,
+ 'send_copy_to_current_user' => 'boolean',
+ 'attendee_ids' => 'max:50,array|required_if:message_type,'.MessageTypeEnum::INDIVIDUAL_ATTENDEES->name,
'attendee_ids.*' => 'integer',
- 'product_ids' => ['array', 'required_if:message_type,' . MessageTypeEnum::TICKET_HOLDERS->name],
- 'order_id' => 'integer|required_if:message_type,' . MessageTypeEnum::ORDER_OWNER->name,
+ 'product_ids' => ['array', 'required_if:message_type,'.MessageTypeEnum::TICKET_HOLDERS->name],
+ 'order_id' => 'integer|required_if:message_type,'.MessageTypeEnum::ORDER_OWNER->name,
'product_ids.*' => 'integer',
'order_statuses.*' => [
- 'required_if:message_type,' . MessageTypeEnum::ORDER_OWNERS_WITH_PRODUCT->name,
+ 'required_if:message_type,'.MessageTypeEnum::ORDER_OWNERS_WITH_PRODUCT->name,
new In([OrderStatus::COMPLETED->name, OrderStatus::AWAITING_OFFLINE_PAYMENT->name]),
],
'scheduled_at' => 'nullable|date',
+ 'event_occurrence_id' => [
+ 'nullable',
+ 'integer',
+ Rule::exists('event_occurrences', 'id')
+ ->where('event_id', $eventId)
+ ->whereNull('deleted_at'),
+ ],
+ // Targets attendees across multiple occurrences (e.g. after a bulk
+ // reschedule). Mutually exclusive with event_occurrence_id.
+ 'event_occurrence_ids' => ['nullable', 'array', 'max:500'],
+ 'event_occurrence_ids.*' => [
+ 'integer',
+ Rule::exists('event_occurrences', 'id')
+ ->where('event_id', $eventId)
+ ->whereNull('deleted_at'),
+ ],
];
}
+ public function withValidator($validator): void
+ {
+ $validator->after(function ($validator) {
+ if ($this->filled('event_occurrence_id') && $this->filled('event_occurrence_ids')) {
+ $validator->errors()->add(
+ 'event_occurrence_ids',
+ __('Only one of event_occurrence_id or event_occurrence_ids may be provided.')
+ );
+ }
+ });
+ }
+
public function messages(): array
{
return [
- 'order_statuses.required_if' => 'The order statuses field is required when sending messages to order owners with a specific product.',
- 'subject.max' => 'The subject must be less than 100 characters.',
- 'attendee_ids.max' => 'You can only send a message to a maximum of 50 individual attendees at a time. ' .
- 'To message more attendees, you can send to attendees with a specific product, or to all event attendees.'
+ 'order_statuses.required_if' => __('The order statuses field is required when sending messages to order owners with a specific product.'),
+ 'subject.max' => __('The subject must be less than 100 characters.'),
+ 'attendee_ids.max' => __('You can only send a message to a maximum of 50 individual attendees at a time. To message more attendees, you can send to attendees with a specific product, or to all event attendees.'),
+ 'event_occurrence_ids.max' => __('You can only target up to 500 occurrences in a single message.'),
];
}
}
diff --git a/backend/app/Http/Request/Report/GetReportRequest.php b/backend/app/Http/Request/Report/GetReportRequest.php
index 458a9861df..4a822df311 100644
--- a/backend/app/Http/Request/Report/GetReportRequest.php
+++ b/backend/app/Http/Request/Report/GetReportRequest.php
@@ -3,14 +3,24 @@
namespace HiEvents\Http\Request\Report;
use HiEvents\Http\Request\BaseRequest;
+use Illuminate\Validation\Rule;
class GetReportRequest extends BaseRequest
{
public function rules(): array
{
+ $eventId = $this->route('event_id') ?? $this->route('eventId');
+
return [
'start_date' => 'date|before:end_date|required_with:end_date|nullable',
'end_date' => 'date|after:start_date|required_with:start_date|nullable',
+ 'occurrence_id' => [
+ 'integer',
+ 'nullable',
+ Rule::exists('event_occurrences', 'id')
+ ->where('event_id', $eventId)
+ ->whereNull('deleted_at'),
+ ],
];
}
}
diff --git a/backend/app/Http/Request/Waitlist/CreateWaitlistEntryRequest.php b/backend/app/Http/Request/Waitlist/CreateWaitlistEntryRequest.php
index 1fe7aea86a..3a4db5a598 100644
--- a/backend/app/Http/Request/Waitlist/CreateWaitlistEntryRequest.php
+++ b/backend/app/Http/Request/Waitlist/CreateWaitlistEntryRequest.php
@@ -3,13 +3,23 @@
namespace HiEvents\Http\Request\Waitlist;
use HiEvents\Http\Request\BaseRequest;
+use Illuminate\Validation\Rule;
class CreateWaitlistEntryRequest extends BaseRequest
{
public function rules(): array
{
+ $eventId = $this->route('event_id');
+
return [
'product_price_id' => ['required', 'integer', 'exists:product_prices,id'],
+ 'event_occurrence_id' => [
+ 'nullable',
+ 'integer',
+ Rule::exists('event_occurrences', 'id')
+ ->where('event_id', $eventId)
+ ->whereNull('deleted_at'),
+ ],
'email' => ['required', 'email', 'max:255'],
'first_name' => ['required', 'string', 'max:255'],
'last_name' => ['nullable', 'string', 'max:255'],
diff --git a/backend/app/Http/Request/Waitlist/GetWaitlistStatsRequest.php b/backend/app/Http/Request/Waitlist/GetWaitlistStatsRequest.php
new file mode 100644
index 0000000000..67d59a6f90
--- /dev/null
+++ b/backend/app/Http/Request/Waitlist/GetWaitlistStatsRequest.php
@@ -0,0 +1,24 @@
+route('event_id');
+
+ return [
+ 'event_occurrence_id' => [
+ 'nullable',
+ 'integer',
+ Rule::exists('event_occurrences', 'id')
+ ->where('event_id', $eventId)
+ ->whereNull('deleted_at'),
+ ],
+ ];
+ }
+}
diff --git a/backend/app/Http/Request/Waitlist/OfferWaitlistEntryRequest.php b/backend/app/Http/Request/Waitlist/OfferWaitlistEntryRequest.php
index f04e6d334e..5fa5dbe6ff 100644
--- a/backend/app/Http/Request/Waitlist/OfferWaitlistEntryRequest.php
+++ b/backend/app/Http/Request/Waitlist/OfferWaitlistEntryRequest.php
@@ -3,15 +3,25 @@
namespace HiEvents\Http\Request\Waitlist;
use HiEvents\Http\Request\BaseRequest;
+use Illuminate\Validation\Rule;
class OfferWaitlistEntryRequest extends BaseRequest
{
public function rules(): array
{
+ $eventId = $this->route('event_id');
+
return [
'product_price_id' => ['required_without:entry_id', 'integer', 'exists:product_prices,id'],
'entry_id' => ['required_without:product_price_id', 'integer', 'exists:waitlist_entries,id'],
'quantity' => ['sometimes', 'integer', 'min:1', 'max:50'],
+ 'event_occurrence_id' => [
+ 'nullable',
+ 'integer',
+ Rule::exists('event_occurrences', 'id')
+ ->where('event_id', $eventId)
+ ->whereNull('deleted_at'),
+ ],
];
}
}
diff --git a/backend/app/Jobs/Event/SendMessagesJob.php b/backend/app/Jobs/Event/SendMessagesJob.php
index 142f322fc2..fe6cc953ad 100644
--- a/backend/app/Jobs/Event/SendMessagesJob.php
+++ b/backend/app/Jobs/Event/SendMessagesJob.php
@@ -15,12 +15,9 @@ class SendMessagesJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
- private SendMessageDTO $messageData;
-
- public function __construct(SendMessageDTO $messageData)
- {
- $this->messageData = $messageData;
- }
+ public function __construct(
+ public readonly SendMessageDTO $messageData,
+ ) {}
/**
* @throws UnableToSendMessageException
diff --git a/backend/app/Jobs/Occurrence/BulkCancelOccurrencesJob.php b/backend/app/Jobs/Occurrence/BulkCancelOccurrencesJob.php
new file mode 100644
index 0000000000..cabd29586b
--- /dev/null
+++ b/backend/app/Jobs/Occurrence/BulkCancelOccurrencesJob.php
@@ -0,0 +1,132 @@
+onQueue('occurrences');
+ }
+
+ public function handle(
+ EventOccurrenceRepositoryInterface $occurrenceRepository,
+ RecurrenceRuleExclusionService $exclusionService,
+ CancelOccurrenceAttendeesService $cancelAttendeesService,
+ ): void {
+ $cancelledStartDates = [];
+ $failedIds = [];
+
+ foreach ($this->occurrenceIds as $occurrenceId) {
+ try {
+ // Each iteration runs in its own transaction so we can lock the occurrence
+ // row before checking its status. Without the lock, two concurrent bulk
+ // cancellations could both observe ACTIVE and dispatch the refund /
+ // notification side-effects twice.
+ $cancelledStartDate = DB::transaction(function () use ($occurrenceRepository, $cancelAttendeesService, $occurrenceId) {
+ $occurrence = $occurrenceRepository->findByIdLocked($occurrenceId);
+
+ if (
+ ! $occurrence
+ || $occurrence->getEventId() !== $this->eventId
+ || $occurrence->getStatus() === EventOccurrenceStatus::CANCELLED->name
+ ) {
+ return null;
+ }
+
+ $occurrenceRepository->updateWhere(
+ attributes: [
+ EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::CANCELLED->name,
+ ],
+ where: [EventOccurrenceDomainObjectAbstract::ID => $occurrenceId],
+ );
+
+ $cancelAttendeesService->cancelForOccurrence($this->eventId, $occurrenceId);
+
+ return $occurrence->getStartDate();
+ });
+
+ if ($cancelledStartDate === null) {
+ continue;
+ }
+
+ event(new OccurrenceCancelledEvent(
+ eventId: $this->eventId,
+ occurrenceId: $occurrenceId,
+ refundOrders: $this->refundOrders,
+ ));
+
+ event(new OccurrenceEvent(
+ type: DomainEventType::OCCURRENCE_CANCELLED,
+ occurrenceId: $occurrenceId,
+ ));
+
+ $cancelledStartDates[] = $cancelledStartDate;
+ } catch (\Throwable $e) {
+ $failedIds[] = $occurrenceId;
+ Log::error('Failed to cancel occurrence', [
+ 'event_id' => $this->eventId,
+ 'occurrence_id' => $occurrenceId,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ if (! empty($cancelledStartDates)) {
+ DB::transaction(fn () => $exclusionService->addExclusions($this->eventId, $cancelledStartDates));
+ }
+
+ $context = [
+ 'event_id' => $this->eventId,
+ 'cancelled_count' => count($cancelledStartDates),
+ 'failed_count' => count($failedIds),
+ 'failed_ids' => $failedIds,
+ 'refund_orders' => $this->refundOrders,
+ 'peak_memory_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 1),
+ ];
+
+ if (empty($failedIds)) {
+ Log::info('Bulk cancel occurrences completed', $context);
+ } else {
+ Log::warning('Bulk cancel occurrences completed with failures', $context);
+ }
+ }
+
+ public function failed(Throwable $exception): void
+ {
+ Log::critical('BulkCancelOccurrencesJob permanently failed after retries', [
+ 'event_id' => $this->eventId,
+ 'occurrence_ids' => $this->occurrenceIds,
+ 'refund_orders' => $this->refundOrders,
+ 'error' => $exception->getMessage(),
+ ]);
+ }
+}
diff --git a/backend/app/Jobs/Occurrence/RefundOccurrenceOrdersJob.php b/backend/app/Jobs/Occurrence/RefundOccurrenceOrdersJob.php
new file mode 100644
index 0000000000..1b8d280837
--- /dev/null
+++ b/backend/app/Jobs/Occurrence/RefundOccurrenceOrdersJob.php
@@ -0,0 +1,169 @@
+onQueue('occurrences');
+ }
+
+ public function uniqueId(): string
+ {
+ return "occurrence:{$this->occurrenceId}";
+ }
+
+ public function handle(
+ RefundOrderHandler $refundHandler,
+ OrderAuditLogRepositoryInterface $auditLogRepository,
+ ): void {
+ $orderIds = DB::table('order_items')
+ ->where(OrderItemDomainObjectAbstract::EVENT_OCCURRENCE_ID, $this->occurrenceId)
+ ->whereNull('deleted_at')
+ ->distinct()
+ ->pluck('order_id');
+
+ if ($orderIds->isEmpty()) {
+ return;
+ }
+
+ // Only refund orders that haven't already had a refund started. Skipping rows where
+ // refund_status is set guards against duplicate Stripe refunds on job retry when a
+ // previous attempt crashed between the Stripe API call and the refund_status write.
+ $refundableOrders = DB::table('orders')
+ ->whereIn('id', $orderIds)
+ ->where('status', OrderStatus::COMPLETED->name)
+ ->where('payment_status', OrderPaymentStatus::PAYMENT_RECEIVED->name)
+ ->whereNull('refund_status')
+ ->get(['id', 'total_gross', 'currency']);
+
+ if ($refundableOrders->isEmpty()) {
+ return;
+ }
+
+ $multiOccurrenceOrderIds = DB::table('order_items')
+ ->whereIn('order_id', $refundableOrders->pluck('id'))
+ ->whereNull('deleted_at')
+ ->select('order_id')
+ ->groupBy('order_id')
+ ->havingRaw('COUNT(DISTINCT event_occurrence_id) > 1')
+ ->pluck('order_id')
+ ->toArray();
+
+ foreach ($refundableOrders as $order) {
+ if (in_array($order->id, $multiOccurrenceOrderIds, true)) {
+ Log::warning('Skipping automatic refund for order spanning multiple occurrences', [
+ 'order_id' => $order->id,
+ 'event_id' => $this->eventId,
+ 'cancelled_occurrence_id' => $this->occurrenceId,
+ ]);
+
+ // Surface the skip on the order's audit log so admins see it in
+ // the order's history and can issue a manual partial refund.
+ // Wrapped: audit-log failure shouldn't derail the rest of the
+ // batch (other orders are still queued for refund/skip below).
+ try {
+ $auditLogRepository->create([
+ 'event_id' => $this->eventId,
+ 'order_id' => $order->id,
+ 'attendee_id' => null,
+ 'action' => OrderAuditAction::AUTOMATIC_REFUND_SKIPPED->value,
+ 'old_values' => null,
+ 'new_values' => [
+ 'cancelled_occurrence_id' => $this->occurrenceId,
+ 'reason' => 'order spans multiple occurrences',
+ ],
+ 'changed_fields' => null,
+ 'ip_address' => null,
+ 'user_agent' => null,
+ ]);
+ } catch (Throwable $e) {
+ Log::error('Failed to write refund-skipped audit log entry', [
+ 'order_id' => $order->id,
+ 'event_id' => $this->eventId,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+
+ continue;
+ }
+
+ try {
+ $refundHandler->handle(new RefundOrderDTO(
+ event_id: $this->eventId,
+ order_id: $order->id,
+ amount: (float) $order->total_gross,
+ notify_buyer: true,
+ cancel_order: true,
+ ));
+ } catch (Throwable $e) {
+ Log::error('Failed to refund order for cancelled occurrence', [
+ 'order_id' => $order->id,
+ 'event_id' => $this->eventId,
+ 'occurrence_id' => $this->occurrenceId,
+ 'error' => $e->getMessage(),
+ ]);
+
+ try {
+ $auditLogRepository->create([
+ 'event_id' => $this->eventId,
+ 'order_id' => $order->id,
+ 'attendee_id' => null,
+ 'action' => OrderAuditAction::AUTOMATIC_REFUND_FAILED->value,
+ 'old_values' => null,
+ 'new_values' => [
+ 'cancelled_occurrence_id' => $this->occurrenceId,
+ 'error' => $e->getMessage(),
+ ],
+ 'changed_fields' => null,
+ 'ip_address' => null,
+ 'user_agent' => null,
+ ]);
+ } catch (Throwable $auditError) {
+ Log::error('Failed to write refund-failed audit log entry', [
+ 'order_id' => $order->id,
+ 'event_id' => $this->eventId,
+ 'error' => $auditError->getMessage(),
+ ]);
+ }
+ }
+ }
+ }
+
+ public function failed(Throwable $exception): void
+ {
+ Log::critical('RefundOccurrenceOrdersJob permanently failed after retries', [
+ 'event_id' => $this->eventId,
+ 'occurrence_id' => $this->occurrenceId,
+ 'error' => $exception->getMessage(),
+ ]);
+ }
+}
diff --git a/backend/app/Jobs/Occurrence/SendOccurrenceCancellationEmailJob.php b/backend/app/Jobs/Occurrence/SendOccurrenceCancellationEmailJob.php
new file mode 100644
index 0000000000..e872cf2472
--- /dev/null
+++ b/backend/app/Jobs/Occurrence/SendOccurrenceCancellationEmailJob.php
@@ -0,0 +1,107 @@
+onQueue('occurrences');
+ }
+
+ public function handle(
+ EventRepositoryInterface $eventRepository,
+ EventOccurrenceRepositoryInterface $occurrenceRepository,
+ AttendeeRepositoryInterface $attendeeRepository,
+ Mailer $mailer,
+ MailBuilderService $mailBuilderService,
+ ): void {
+ $occurrence = $occurrenceRepository->findById($this->occurrenceId);
+
+ $event = $eventRepository
+ ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer'))
+ ->loadRelation(new Relationship(EventSettingDomainObject::class))
+ ->findById($this->eventId);
+
+ // Intentionally does NOT filter out CANCELLED attendees:
+ // CancelOccurrenceHandler now marks attendees as CANCELLED inside the
+ // same transaction that fires this job's event — a status filter here
+ // would exclude the very attendees we need to notify. Anyone tied to
+ // this occurrence by FK gets the cancellation email. Dedup by email
+ // address below handles shared-email attendees.
+ $attendees = $attendeeRepository->findWhere([
+ AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID => $this->occurrenceId,
+ ]);
+
+ if ($attendees->isEmpty()) {
+ return;
+ }
+
+ $sentEmails = [];
+
+ $attendees->each(function (AttendeeDomainObject $attendee) use ($mailer, $mailBuilderService, $event, $occurrence, &$sentEmails) {
+ if (in_array($attendee->getEmail(), $sentEmails, true)) {
+ return;
+ }
+
+ $sentEmails[] = $attendee->getEmail();
+
+ $mail = $mailBuilderService->buildOccurrenceCancellationMail(
+ event: $event,
+ occurrence: $occurrence,
+ organizer: $event->getOrganizer(),
+ eventSettings: $event->getEventSettings(),
+ refundOrders: $this->refundOrders,
+ );
+
+ $mailer
+ ->to($attendee->getEmail())
+ ->locale($attendee->getLocale())
+ ->send($mail);
+ });
+
+ Log::info('Sent occurrence cancellation emails', [
+ 'event_id' => $this->eventId,
+ 'occurrence_id' => $this->occurrenceId,
+ 'recipient_count' => count($sentEmails),
+ 'peak_memory_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 1),
+ ]);
+ }
+
+ public function failed(Throwable $exception): void
+ {
+ Log::critical('SendOccurrenceCancellationEmailJob permanently failed after retries', [
+ 'event_id' => $this->eventId,
+ 'occurrence_id' => $this->occurrenceId,
+ 'refund_orders' => $this->refundOrders,
+ 'error' => $exception->getMessage(),
+ ]);
+ }
+}
diff --git a/backend/app/Jobs/Order/Webhook/DispatchOccurrenceWebhookJob.php b/backend/app/Jobs/Order/Webhook/DispatchOccurrenceWebhookJob.php
new file mode 100644
index 0000000000..57b29a949a
--- /dev/null
+++ b/backend/app/Jobs/Order/Webhook/DispatchOccurrenceWebhookJob.php
@@ -0,0 +1,30 @@
+dispatchOccurrenceWebhook(
+ eventType: $this->eventType,
+ occurrenceId: $this->occurrenceId,
+ );
+ }
+}
diff --git a/backend/app/Jobs/Vat/ValidateVatNumberJob.php b/backend/app/Jobs/Vat/ValidateVatNumberJob.php
index 2d97a8917d..f6ce085b68 100644
--- a/backend/app/Jobs/Vat/ValidateVatNumberJob.php
+++ b/backend/app/Jobs/Vat/ValidateVatNumberJob.php
@@ -6,7 +6,7 @@
use DateTimeInterface;
use HiEvents\DomainObjects\Status\VatValidationStatus;
-use HiEvents\Repository\Interfaces\AccountVatSettingRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrganizerVatSettingRepositoryInterface;
use HiEvents\Services\Infrastructure\Vat\ViesValidationService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -27,22 +27,22 @@ class ValidateVatNumberJob implements ShouldQueue
public int $timeout = 15;
public function __construct(
- private readonly int $accountVatSettingId,
+ private readonly int $vatSettingId,
private readonly string $vatNumber,
) {}
public function handle(
- ViesValidationService $viesService,
- AccountVatSettingRepositoryInterface $repository,
- LoggerInterface $logger,
+ ViesValidationService $viesService,
+ OrganizerVatSettingRepositoryInterface $repository,
+ LoggerInterface $logger,
): void {
$logger->info('VAT validation job started', [
- 'account_vat_setting_id' => $this->accountVatSettingId,
+ 'organizer_vat_setting_id' => $this->vatSettingId,
'vat_number' => $this->maskVatNumber($this->vatNumber),
'attempt' => $this->attempts(),
]);
- $repository->updateFromArray($this->accountVatSettingId, [
+ $repository->updateFromArray($this->vatSettingId, [
'vat_validation_status' => VatValidationStatus::VALIDATING->value,
'vat_validation_attempts' => $this->attempts(),
]);
@@ -51,13 +51,13 @@ public function handle(
if ($result->valid) {
$logger->info('VAT validation successful', [
- 'account_vat_setting_id' => $this->accountVatSettingId,
+ 'organizer_vat_setting_id' => $this->vatSettingId,
'vat_number' => $this->maskVatNumber($this->vatNumber),
'business_name' => $result->businessName,
'attempt' => $this->attempts(),
]);
- $repository->updateFromArray($this->accountVatSettingId, [
+ $repository->updateFromArray($this->vatSettingId, [
'vat_validated' => true,
'vat_validation_status' => VatValidationStatus::VALID->value,
'vat_validation_date' => now(),
@@ -73,13 +73,13 @@ public function handle(
if ($result->isTransientError) {
$logger->warning('VAT validation transient error - will retry', [
- 'account_vat_setting_id' => $this->accountVatSettingId,
+ 'organizer_vat_setting_id' => $this->vatSettingId,
'vat_number' => $this->maskVatNumber($this->vatNumber),
'error' => $result->errorMessage,
'attempt' => $this->attempts(),
]);
- $repository->updateFromArray($this->accountVatSettingId, [
+ $repository->updateFromArray($this->vatSettingId, [
'vat_validation_status' => VatValidationStatus::PENDING->value,
'vat_validation_error' => $result->errorMessage,
'vat_validation_attempts' => $this->attempts(),
@@ -91,13 +91,13 @@ public function handle(
}
$logger->info('VAT validation failed - invalid VAT number', [
- 'account_vat_setting_id' => $this->accountVatSettingId,
+ 'organizer_vat_setting_id' => $this->vatSettingId,
'vat_number' => $this->maskVatNumber($this->vatNumber),
'error' => $result->errorMessage,
'attempt' => $this->attempts(),
]);
- $repository->updateFromArray($this->accountVatSettingId, [
+ $repository->updateFromArray($this->vatSettingId, [
'vat_validated' => false,
'vat_validation_status' => VatValidationStatus::INVALID->value,
'vat_validation_error' => $result->errorMessage,
@@ -108,17 +108,17 @@ public function handle(
public function failed(Throwable $exception): void
{
$logger = app(LoggerInterface::class);
- $repository = app(AccountVatSettingRepositoryInterface::class);
+ $repository = app(OrganizerVatSettingRepositoryInterface::class);
$logger->error('VAT validation job failed permanently', [
- 'account_vat_setting_id' => $this->accountVatSettingId,
+ 'organizer_vat_setting_id' => $this->vatSettingId,
'vat_number' => $this->maskVatNumber($this->vatNumber),
'error' => $exception->getMessage(),
'attempt' => $this->attempts(),
]);
try {
- $repository->updateFromArray($this->accountVatSettingId, [
+ $repository->updateFromArray($this->vatSettingId, [
'vat_validated' => false,
'vat_validation_status' => VatValidationStatus::FAILED->value,
'vat_validation_error' => __('Validation failed after multiple attempts: :error', [
@@ -128,7 +128,7 @@ public function failed(Throwable $exception): void
]);
} catch (Throwable $e) {
$logger->error('Failed to update VAT setting after job failure', [
- 'account_vat_setting_id' => $this->accountVatSettingId,
+ 'organizer_vat_setting_id' => $this->vatSettingId,
'error' => $e->getMessage(),
]);
}
@@ -137,21 +137,7 @@ public function failed(Throwable $exception): void
public function backoff(): array
{
return [
- 10, // 10s
- 10, // 10s
- 10, // 10s
- 10, // 10s
- 20, // 20s
- 30, // 30s
- 60, // 1m
- 120, // 2m
- 180, // 3m
- 300, // 5m
- 420, // 7m
- 600, // 10m
- 900, // 15m
- 1200, // 20m
- 1800, // 30m
+ 10, 10, 10, 10, 20, 30, 60, 120, 180, 300, 420, 600, 900, 1200, 1800,
];
}
diff --git a/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php b/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php
index 1c17ce4976..8121f9f513 100644
--- a/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php
+++ b/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php
@@ -78,6 +78,7 @@ public function handle(
direction: CapacityChangeDirection::INCREASED,
productId: $productPrice->getProductId(),
productPriceId: $entry->getProductPriceId(),
+ eventOccurrenceId: $entry->getEventOccurrenceId(),
));
} catch (Throwable $e) {
Log::error('Failed to process expired waitlist offer', [
diff --git a/backend/app/Jobs/Waitlist/SendWaitlistConfirmationEmailJob.php b/backend/app/Jobs/Waitlist/SendWaitlistConfirmationEmailJob.php
index f22fdaa9e4..95bc94d262 100644
--- a/backend/app/Jobs/Waitlist/SendWaitlistConfirmationEmailJob.php
+++ b/backend/app/Jobs/Waitlist/SendWaitlistConfirmationEmailJob.php
@@ -7,6 +7,7 @@
use HiEvents\DomainObjects\WaitlistEntryDomainObject;
use HiEvents\Mail\Waitlist\WaitlistConfirmationMail;
use HiEvents\Repository\Eloquent\Value\Relationship;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
@@ -25,17 +26,15 @@ class SendWaitlistConfirmationEmailJob implements ShouldQueue
public function __construct(
private readonly WaitlistEntryDomainObject $entry,
- )
- {
- }
+ ) {}
public function handle(
- EventRepositoryInterface $eventRepository,
+ EventRepositoryInterface $eventRepository,
ProductPriceRepositoryInterface $productPriceRepository,
- ProductRepositoryInterface $productRepository,
- Mailer $mailer,
- ): void
- {
+ ProductRepositoryInterface $productRepository,
+ EventOccurrenceRepositoryInterface $occurrenceRepository,
+ Mailer $mailer,
+ ): void {
$event = $eventRepository
->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer'))
->loadRelation(new Relationship(EventSettingDomainObject::class))
@@ -48,6 +47,10 @@ public function handle(
$product = $productRepository->findById($productPrice->getProductId());
}
+ $occurrence = $this->entry->getEventOccurrenceId() !== null
+ ? $occurrenceRepository->findById($this->entry->getEventOccurrenceId())
+ : null;
+
$mailer
->to($this->entry->getEmail())
->locale($this->entry->getLocale())
@@ -58,6 +61,7 @@ public function handle(
productPrice: $productPrice,
organizer: $event->getOrganizer(),
eventSettings: $event->getEventSettings(),
+ occurrence: $occurrence,
));
}
}
diff --git a/backend/app/Jobs/Waitlist/SendWaitlistOfferEmailJob.php b/backend/app/Jobs/Waitlist/SendWaitlistOfferEmailJob.php
index 31a52696aa..7f64d4d906 100644
--- a/backend/app/Jobs/Waitlist/SendWaitlistOfferEmailJob.php
+++ b/backend/app/Jobs/Waitlist/SendWaitlistOfferEmailJob.php
@@ -7,6 +7,7 @@
use HiEvents\DomainObjects\WaitlistEntryDomainObject;
use HiEvents\Mail\Waitlist\WaitlistOfferMail;
use HiEvents\Repository\Eloquent\Value\Relationship;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
@@ -25,20 +26,19 @@ class SendWaitlistOfferEmailJob implements ShouldQueue
public function __construct(
private readonly WaitlistEntryDomainObject $entry,
- private readonly string $orderShortId,
- private readonly string $sessionIdentifier,
- )
- {
+ private readonly string $orderShortId,
+ private readonly string $sessionIdentifier,
+ ) {
$this->afterCommit = true;
}
public function handle(
- EventRepositoryInterface $eventRepository,
+ EventRepositoryInterface $eventRepository,
ProductPriceRepositoryInterface $productPriceRepository,
- ProductRepositoryInterface $productRepository,
- Mailer $mailer,
- ): void
- {
+ ProductRepositoryInterface $productRepository,
+ EventOccurrenceRepositoryInterface $occurrenceRepository,
+ Mailer $mailer,
+ ): void {
$event = $eventRepository
->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer'))
->loadRelation(new Relationship(EventSettingDomainObject::class))
@@ -51,6 +51,10 @@ public function handle(
$product = $productRepository->findById($productPrice->getProductId());
}
+ $occurrence = $this->entry->getEventOccurrenceId() !== null
+ ? $occurrenceRepository->findById($this->entry->getEventOccurrenceId())
+ : null;
+
$mailer
->to($this->entry->getEmail())
->locale($this->entry->getLocale())
@@ -63,6 +67,7 @@ public function handle(
eventSettings: $event->getEventSettings(),
orderShortId: $this->orderShortId,
sessionIdentifier: $this->sessionIdentifier,
+ occurrence: $occurrence,
));
}
}
diff --git a/backend/app/Jobs/Waitlist/SendWaitlistOfferExpiredEmailJob.php b/backend/app/Jobs/Waitlist/SendWaitlistOfferExpiredEmailJob.php
index c47b3f63de..8bf0f3fb6f 100644
--- a/backend/app/Jobs/Waitlist/SendWaitlistOfferExpiredEmailJob.php
+++ b/backend/app/Jobs/Waitlist/SendWaitlistOfferExpiredEmailJob.php
@@ -7,6 +7,7 @@
use HiEvents\DomainObjects\WaitlistEntryDomainObject;
use HiEvents\Mail\Waitlist\WaitlistOfferExpiredMail;
use HiEvents\Repository\Eloquent\Value\Relationship;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
@@ -25,17 +26,15 @@ class SendWaitlistOfferExpiredEmailJob implements ShouldQueue
public function __construct(
private readonly WaitlistEntryDomainObject $entry,
- )
- {
- }
+ ) {}
public function handle(
- EventRepositoryInterface $eventRepository,
+ EventRepositoryInterface $eventRepository,
ProductPriceRepositoryInterface $productPriceRepository,
- ProductRepositoryInterface $productRepository,
- Mailer $mailer,
- ): void
- {
+ ProductRepositoryInterface $productRepository,
+ EventOccurrenceRepositoryInterface $occurrenceRepository,
+ Mailer $mailer,
+ ): void {
$event = $eventRepository
->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer'))
->loadRelation(new Relationship(EventSettingDomainObject::class))
@@ -48,6 +47,10 @@ public function handle(
$product = $productRepository->findById($productPrice->getProductId());
}
+ $occurrence = $this->entry->getEventOccurrenceId() !== null
+ ? $occurrenceRepository->findById($this->entry->getEventOccurrenceId())
+ : null;
+
$mailer
->to($this->entry->getEmail())
->locale($this->entry->getLocale())
@@ -58,6 +61,7 @@ public function handle(
productPrice: $productPrice,
organizer: $event->getOrganizer(),
eventSettings: $event->getEventSettings(),
+ occurrence: $occurrence,
));
}
}
diff --git a/backend/app/Listeners/Occurrence/RefundOccurrenceOrdersListener.php b/backend/app/Listeners/Occurrence/RefundOccurrenceOrdersListener.php
new file mode 100644
index 0000000000..bfc0269f0a
--- /dev/null
+++ b/backend/app/Listeners/Occurrence/RefundOccurrenceOrdersListener.php
@@ -0,0 +1,21 @@
+refundOrders) {
+ return;
+ }
+
+ dispatch(new RefundOccurrenceOrdersJob(
+ eventId: $event->eventId,
+ occurrenceId: $event->occurrenceId,
+ ));
+ }
+}
diff --git a/backend/app/Listeners/Occurrence/SendOccurrenceCancellationNotification.php b/backend/app/Listeners/Occurrence/SendOccurrenceCancellationNotification.php
new file mode 100644
index 0000000000..28686094dc
--- /dev/null
+++ b/backend/app/Listeners/Occurrence/SendOccurrenceCancellationNotification.php
@@ -0,0 +1,18 @@
+eventId,
+ occurrenceId: $event->occurrenceId,
+ refundOrders: $event->refundOrders,
+ ));
+ }
+}
diff --git a/backend/app/Listeners/Waitlist/CancelWaitlistEntriesOnOccurrenceCancelledListener.php b/backend/app/Listeners/Waitlist/CancelWaitlistEntriesOnOccurrenceCancelledListener.php
new file mode 100644
index 0000000000..fbc2ad4979
--- /dev/null
+++ b/backend/app/Listeners/Waitlist/CancelWaitlistEntriesOnOccurrenceCancelledListener.php
@@ -0,0 +1,40 @@
+waitlistEntryRepository->updateWhere(
+ attributes: [
+ 'status' => WaitlistEntryStatus::CANCELLED->name,
+ 'cancelled_at' => now(),
+ ],
+ where: [
+ 'event_id' => $event->eventId,
+ 'event_occurrence_id' => $event->occurrenceId,
+ ['status', 'in', [
+ WaitlistEntryStatus::WAITING->name,
+ WaitlistEntryStatus::OFFERED->name,
+ ]],
+ ],
+ );
+ }
+}
diff --git a/backend/app/Listeners/Waitlist/ProcessWaitlistOnCapacityAvailableListener.php b/backend/app/Listeners/Waitlist/ProcessWaitlistOnCapacityAvailableListener.php
index 6ee1809634..8af51276d7 100644
--- a/backend/app/Listeners/Waitlist/ProcessWaitlistOnCapacityAvailableListener.php
+++ b/backend/app/Listeners/Waitlist/ProcessWaitlistOnCapacityAvailableListener.php
@@ -11,16 +11,16 @@
use HiEvents\Services\Domain\Product\AvailableProductQuantitiesFetchService;
use HiEvents\Services\Domain\Waitlist\ProcessWaitlistService;
use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Support\Facades\Log;
+use Throwable;
class ProcessWaitlistOnCapacityAvailableListener implements ShouldQueue
{
public function __construct(
- private readonly EventRepositoryInterface $eventRepository,
- private readonly ProcessWaitlistService $processWaitlistService,
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly ProcessWaitlistService $processWaitlistService,
private readonly AvailableProductQuantitiesFetchService $availableQuantitiesService,
- )
- {
- }
+ ) {}
public function handle(CapacityChangedEvent $event): void
{
@@ -34,13 +34,14 @@ public function handle(CapacityChangedEvent $event): void
$eventSettings = $eventDomainObject->getEventSettings();
- if (!$eventSettings?->getWaitlistAutoProcess()) {
+ if (! $eventSettings?->getWaitlistAutoProcess()) {
return;
}
$quantities = $this->availableQuantitiesService->getAvailableProductQuantities(
$event->eventId,
ignoreCache: true,
+ eventOccurrenceId: $event->eventOccurrenceId,
);
foreach ($quantities->productQuantities as $productQuantity) {
@@ -60,9 +61,20 @@ public function handle(CapacityChangedEvent $event): void
quantity: $availableCount,
event: $eventDomainObject,
eventSettings: $eventSettings,
+ eventOccurrenceId: $event->eventOccurrenceId,
);
} catch (NoCapacityAvailableException) {
// Expected: no waiting entries or capacity consumed by pending offers
+ } catch (Throwable $e) {
+ // Unexpected — surface it (otherwise the listener silently fails and
+ // waitlist entries stall). Re-throw so the queue retries the listener.
+ Log::error('ProcessWaitlistOnCapacityAvailableListener failed', [
+ 'event_id' => $event->eventId,
+ 'product_id' => $event->productId,
+ 'price_id' => $productQuantity->price_id,
+ 'error' => $e->getMessage(),
+ ]);
+ throw $e;
}
}
}
diff --git a/backend/app/Listeners/Waitlist/ResolveWaitlistEntryOnOrderCompletedListener.php b/backend/app/Listeners/Waitlist/ResolveWaitlistEntryOnOrderCompletedListener.php
index 4dcf45554a..21e98a25fb 100644
--- a/backend/app/Listeners/Waitlist/ResolveWaitlistEntryOnOrderCompletedListener.php
+++ b/backend/app/Listeners/Waitlist/ResolveWaitlistEntryOnOrderCompletedListener.php
@@ -70,6 +70,7 @@ private function revertOfferedEntriesByOrderId(int $orderId): void
direction: CapacityChangeDirection::INCREASED,
productId: $productPrice->getProductId(),
productPriceId: $entry->getProductPriceId(),
+ eventOccurrenceId: $entry->getEventOccurrenceId(),
);
}
});
diff --git a/backend/app/Listeners/Webhook/WebhookEventListener.php b/backend/app/Listeners/Webhook/WebhookEventListener.php
index c3f93cfd43..a475a25c90 100644
--- a/backend/app/Listeners/Webhook/WebhookEventListener.php
+++ b/backend/app/Listeners/Webhook/WebhookEventListener.php
@@ -4,11 +4,13 @@
use HiEvents\Jobs\Order\Webhook\DispatchAttendeeWebhookJob;
use HiEvents\Jobs\Order\Webhook\DispatchCheckInWebhookJob;
+use HiEvents\Jobs\Order\Webhook\DispatchOccurrenceWebhookJob;
use HiEvents\Jobs\Order\Webhook\DispatchOrderWebhookJob;
use HiEvents\Jobs\Order\Webhook\DispatchProductWebhookJob;
use HiEvents\Services\Infrastructure\DomainEvents\Events\AttendeeEvent;
use HiEvents\Services\Infrastructure\DomainEvents\Events\BaseDomainEvent;
use HiEvents\Services\Infrastructure\DomainEvents\Events\CheckinEvent;
+use HiEvents\Services\Infrastructure\DomainEvents\Events\OccurrenceEvent;
use HiEvents\Services\Infrastructure\DomainEvents\Events\OrderEvent;
use HiEvents\Services\Infrastructure\DomainEvents\Events\ProductEvent;
use Illuminate\Config\Repository;
@@ -50,6 +52,12 @@ public function handle(BaseDomainEvent $event): void
eventType: $event->type,
)->onQueue($queueName);
break;
+ case OccurrenceEvent::class:
+ DispatchOccurrenceWebhookJob::dispatch(
+ occurrenceId: $event->occurrenceId,
+ eventType: $event->type,
+ )->onQueue($queueName);
+ break;
}
}
}
diff --git a/backend/app/Mail/Attendee/AttendeeTicketMail.php b/backend/app/Mail/Attendee/AttendeeTicketMail.php
index 46ae3d8abc..5fb72d4357 100644
--- a/backend/app/Mail/Attendee/AttendeeTicketMail.php
+++ b/backend/app/Mail/Attendee/AttendeeTicketMail.php
@@ -5,6 +5,7 @@
use Carbon\Carbon;
use HiEvents\DomainObjects\AttendeeDomainObject;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
@@ -33,6 +34,7 @@ public function __construct(
private readonly EventSettingDomainObject $eventSettings,
private readonly OrganizerDomainObject $organizer,
?RenderedEmailTemplateDTO $renderedTemplate = null,
+ private readonly ?EventOccurrenceDomainObject $occurrence = null,
)
{
parent::__construct();
@@ -73,6 +75,7 @@ public function content(): Content
'eventSettings' => $this->eventSettings,
'organizer' => $this->organizer,
'order' => $this->order,
+ 'occurrence' => $this->occurrence ?? $this->attendee->getEventOccurrence(),
'ticketUrl' => sprintf(
Url::getFrontEndUrlFromConfig(Url::ATTENDEE_TICKET),
$this->event->getId(),
@@ -84,11 +87,23 @@ public function content(): Content
public function attachments(): array
{
- $startDateTime = Carbon::parse($this->event->getStartDate(), $this->event->getTimezone());
- $endDateTime = $this->event->getEndDate() ? Carbon::parse($this->event->getEndDate(), $this->event->getTimezone()) : null;
+ $startDateRaw = $this->occurrence?->getStartDate() ?? $this->event->getStartDate();
+ $endDateRaw = $this->occurrence?->getEndDate() ?? $this->event->getEndDate();
+
+ $startDateTime = $startDateRaw ? Carbon::parse($startDateRaw, $this->event->getTimezone()) : null;
+ $endDateTime = $endDateRaw ? Carbon::parse($endDateRaw, $this->event->getTimezone()) : null;
+
+ if ($startDateTime === null) {
+ return [];
+ }
+
+ $eventTitle = $this->event->getTitle();
+ if ($this->occurrence?->getLabel()) {
+ $eventTitle .= ' - ' . $this->occurrence->getLabel();
+ }
$event = Event::create()
- ->name($this->event->getTitle())
+ ->name($eventTitle)
->uniqueIdentifier('event-' . $this->attendee->getId())
->startsAt($startDateTime)
->url($this->event->getEventUrl())
diff --git a/backend/app/Mail/Occurrence/OccurrenceCancellationMail.php b/backend/app/Mail/Occurrence/OccurrenceCancellationMail.php
new file mode 100644
index 0000000000..c5587ade6b
--- /dev/null
+++ b/backend/app/Mail/Occurrence/OccurrenceCancellationMail.php
@@ -0,0 +1,76 @@
+renderedTemplate = $renderedTemplate;
+ parent::__construct();
+ }
+
+ public function envelope(): Envelope
+ {
+ $subject = $this->renderedTemplate?->subject ?? __(':event on :date has been cancelled', [
+ 'event' => $this->event->getTitle(),
+ 'date' => $this->formattedDate,
+ ]);
+
+ return new Envelope(
+ replyTo: $this->eventSettings->getSupportEmail(),
+ subject: $subject,
+ );
+ }
+
+ public function content(): Content
+ {
+ if ($this->renderedTemplate) {
+ return new Content(
+ markdown: 'emails.custom-template',
+ with: [
+ 'renderedBody' => $this->renderedTemplate->body,
+ 'renderedCta' => $this->renderedTemplate->cta,
+ 'eventSettings' => $this->eventSettings,
+ ]
+ );
+ }
+
+ return new Content(
+ markdown: 'emails.occurrence.cancellation',
+ with: [
+ 'event' => $this->event,
+ 'occurrence' => $this->occurrence,
+ 'organizer' => $this->organizer,
+ 'eventSettings' => $this->eventSettings,
+ 'formattedDate' => $this->formattedDate,
+ 'refundOrders' => $this->refundOrders,
+ 'eventUrl' => sprintf(
+ Url::getFrontEndUrlFromConfig(Url::EVENT_HOMEPAGE),
+ $this->event->getId(),
+ $this->event->getSlug(),
+ ),
+ ]
+ );
+ }
+}
diff --git a/backend/app/Mail/Order/OrderSummary.php b/backend/app/Mail/Order/OrderSummary.php
index 4e6f1b838d..310a812a8e 100644
--- a/backend/app/Mail/Order/OrderSummary.php
+++ b/backend/app/Mail/Order/OrderSummary.php
@@ -4,6 +4,7 @@
use Barryvdh\DomPDF\Facade\Pdf;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\InvoiceDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
@@ -23,12 +24,13 @@ class OrderSummary extends BaseMail
private readonly ?RenderedEmailTemplateDTO $renderedTemplate;
public function __construct(
- private readonly OrderDomainObject $order,
- private readonly EventDomainObject $event,
- private readonly OrganizerDomainObject $organizer,
- private readonly EventSettingDomainObject $eventSettings,
- private readonly ?InvoiceDomainObject $invoice,
- ?RenderedEmailTemplateDTO $renderedTemplate = null,
+ private readonly OrderDomainObject $order,
+ private readonly EventDomainObject $event,
+ private readonly OrganizerDomainObject $organizer,
+ private readonly EventSettingDomainObject $eventSettings,
+ private readonly ?InvoiceDomainObject $invoice,
+ private readonly ?EventOccurrenceDomainObject $occurrence = null,
+ ?RenderedEmailTemplateDTO $renderedTemplate = null,
)
{
$this->renderedTemplate = $renderedTemplate;
@@ -67,6 +69,7 @@ public function content(): Content
'event' => $this->event,
'order' => $this->order,
'organizer' => $this->organizer,
+ 'occurrence' => $this->occurrence,
'orderUrl' => sprintf(
Url::getFrontEndUrlFromConfig(Url::ORDER_SUMMARY),
$this->event->getId(),
diff --git a/backend/app/Mail/Waitlist/WaitlistConfirmationMail.php b/backend/app/Mail/Waitlist/WaitlistConfirmationMail.php
index 1a29619cd4..22d33476a8 100644
--- a/backend/app/Mail/Waitlist/WaitlistConfirmationMail.php
+++ b/backend/app/Mail/Waitlist/WaitlistConfirmationMail.php
@@ -2,7 +2,9 @@
namespace HiEvents\Mail\Waitlist;
+use Carbon\Carbon;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
@@ -16,14 +18,14 @@
class WaitlistConfirmationMail extends BaseMail
{
public function __construct(
- private readonly WaitlistEntryDomainObject $entry,
- private readonly EventDomainObject $event,
- private readonly ?ProductDomainObject $product,
- private readonly ?ProductPriceDomainObject $productPrice,
- private readonly OrganizerDomainObject $organizer,
- private readonly EventSettingDomainObject $eventSettings,
- )
- {
+ private readonly WaitlistEntryDomainObject $entry,
+ private readonly EventDomainObject $event,
+ private readonly ?ProductDomainObject $product,
+ private readonly ?ProductPriceDomainObject $productPrice,
+ private readonly OrganizerDomainObject $organizer,
+ private readonly EventSettingDomainObject $eventSettings,
+ private readonly ?EventOccurrenceDomainObject $occurrence = null,
+ ) {
parent::__construct();
}
@@ -43,6 +45,7 @@ public function content(): Content
'entry' => $this->entry,
'event' => $this->event,
'productName' => $this->buildProductName(),
+ 'occurrenceDateFormatted' => $this->formatOccurrenceDate(),
'organizer' => $this->organizer,
'eventSettings' => $this->eventSettings,
'eventUrl' => sprintf(
@@ -54,16 +57,27 @@ public function content(): Content
);
}
+ private function formatOccurrenceDate(): ?string
+ {
+ if ($this->occurrence === null) {
+ return null;
+ }
+
+ return Carbon::parse($this->occurrence->getStartDate(), 'UTC')
+ ->setTimezone($this->event->getTimezone())
+ ->isoFormat('dddd, MMMM D · h:mm A');
+ }
+
private function buildProductName(): ?string
{
- if (!$this->product) {
+ if (! $this->product) {
return null;
}
$name = $this->product->getTitle();
if ($this->productPrice?->getLabel()) {
- $name .= ' - ' . $this->productPrice->getLabel();
+ $name .= ' - '.$this->productPrice->getLabel();
}
return $name;
diff --git a/backend/app/Mail/Waitlist/WaitlistOfferExpiredMail.php b/backend/app/Mail/Waitlist/WaitlistOfferExpiredMail.php
index 9d52672572..fe88042321 100644
--- a/backend/app/Mail/Waitlist/WaitlistOfferExpiredMail.php
+++ b/backend/app/Mail/Waitlist/WaitlistOfferExpiredMail.php
@@ -2,7 +2,9 @@
namespace HiEvents\Mail\Waitlist;
+use Carbon\Carbon;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
@@ -16,14 +18,14 @@
class WaitlistOfferExpiredMail extends BaseMail
{
public function __construct(
- private readonly WaitlistEntryDomainObject $entry,
- private readonly EventDomainObject $event,
- private readonly ?ProductDomainObject $product,
- private readonly ?ProductPriceDomainObject $productPrice,
- private readonly OrganizerDomainObject $organizer,
- private readonly EventSettingDomainObject $eventSettings,
- )
- {
+ private readonly WaitlistEntryDomainObject $entry,
+ private readonly EventDomainObject $event,
+ private readonly ?ProductDomainObject $product,
+ private readonly ?ProductPriceDomainObject $productPrice,
+ private readonly OrganizerDomainObject $organizer,
+ private readonly EventSettingDomainObject $eventSettings,
+ private readonly ?EventOccurrenceDomainObject $occurrence = null,
+ ) {
parent::__construct();
}
@@ -43,27 +45,39 @@ public function content(): Content
'entry' => $this->entry,
'event' => $this->event,
'productName' => $this->buildProductName(),
+ 'occurrenceDateFormatted' => $this->formatOccurrenceDate(),
'organizer' => $this->organizer,
'eventSettings' => $this->eventSettings,
'eventUrl' => sprintf(
Url::getFrontEndUrlFromConfig(Url::EVENT_HOMEPAGE),
$this->event->getId(),
$this->event->getSlug(),
- ) . '?clear_waitlist=true',
+ ).'?clear_waitlist=true',
]
);
}
+ private function formatOccurrenceDate(): ?string
+ {
+ if ($this->occurrence === null) {
+ return null;
+ }
+
+ return Carbon::parse($this->occurrence->getStartDate(), 'UTC')
+ ->setTimezone($this->event->getTimezone())
+ ->isoFormat('dddd, MMMM D · h:mm A');
+ }
+
private function buildProductName(): ?string
{
- if (!$this->product) {
+ if (! $this->product) {
return null;
}
$name = $this->product->getTitle();
if ($this->productPrice?->getLabel()) {
- $name .= ' - ' . $this->productPrice->getLabel();
+ $name .= ' - '.$this->productPrice->getLabel();
}
return $name;
diff --git a/backend/app/Mail/Waitlist/WaitlistOfferMail.php b/backend/app/Mail/Waitlist/WaitlistOfferMail.php
index 8cd81e2682..45f5bc1522 100644
--- a/backend/app/Mail/Waitlist/WaitlistOfferMail.php
+++ b/backend/app/Mail/Waitlist/WaitlistOfferMail.php
@@ -4,6 +4,7 @@
use Carbon\Carbon;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
@@ -17,16 +18,16 @@
class WaitlistOfferMail extends BaseMail
{
public function __construct(
- private readonly WaitlistEntryDomainObject $entry,
- private readonly EventDomainObject $event,
- private readonly ?ProductDomainObject $product,
- private readonly ?ProductPriceDomainObject $productPrice,
- private readonly OrganizerDomainObject $organizer,
- private readonly EventSettingDomainObject $eventSettings,
- private readonly string $orderShortId,
- private readonly string $sessionIdentifier,
- )
- {
+ private readonly WaitlistEntryDomainObject $entry,
+ private readonly EventDomainObject $event,
+ private readonly ?ProductDomainObject $product,
+ private readonly ?ProductPriceDomainObject $productPrice,
+ private readonly OrganizerDomainObject $organizer,
+ private readonly EventSettingDomainObject $eventSettings,
+ private readonly string $orderShortId,
+ private readonly string $sessionIdentifier,
+ private readonly ?EventOccurrenceDomainObject $occurrence = null,
+ ) {
parent::__construct();
}
@@ -46,6 +47,7 @@ public function content(): Content
'entry' => $this->entry,
'event' => $this->event,
'productName' => $this->buildProductName(),
+ 'occurrenceDateFormatted' => $this->formatOccurrenceDate(),
'organizer' => $this->organizer,
'eventSettings' => $this->eventSettings,
'offerExpiresAtFormatted' => $this->formatOfferExpiry(),
@@ -72,16 +74,27 @@ private function formatOfferExpiry(): ?string
return Carbon::parse($expiresAt)->isoFormat('MMMM D, YYYY [at] h:mm A (z)');
}
+ private function formatOccurrenceDate(): ?string
+ {
+ if ($this->occurrence === null) {
+ return null;
+ }
+
+ return Carbon::parse($this->occurrence->getStartDate(), 'UTC')
+ ->setTimezone($this->event->getTimezone())
+ ->isoFormat('dddd, MMMM D · h:mm A');
+ }
+
private function buildProductName(): ?string
{
- if (!$this->product) {
+ if (! $this->product) {
return null;
}
$name = $this->product->getTitle();
if ($this->productPrice?->getLabel()) {
- $name .= ' - ' . $this->productPrice->getLabel();
+ $name .= ' - '.$this->productPrice->getLabel();
}
return $name;
diff --git a/backend/app/Models/Account.php b/backend/app/Models/Account.php
index 9d470392bd..4bf14ddf4a 100644
--- a/backend/app/Models/Account.php
+++ b/backend/app/Models/Account.php
@@ -34,6 +34,11 @@ public function events(): HasMany
return $this->hasMany(Event::class);
}
+ public function organizers(): HasMany
+ {
+ return $this->hasMany(Organizer::class);
+ }
+
public function configuration(): BelongsTo
{
return $this->belongsTo(
diff --git a/backend/app/Models/Attendee.php b/backend/app/Models/Attendee.php
index 9888de64c4..e630c04965 100644
--- a/backend/app/Models/Attendee.php
+++ b/backend/app/Models/Attendee.php
@@ -28,6 +28,11 @@ public function product(): BelongsTo
return $this->belongsTo(Product::class);
}
+ public function event_occurrence(): BelongsTo
+ {
+ return $this->belongsTo(EventOccurrence::class, 'event_occurrence_id');
+ }
+
public function check_ins(): HasMany
{
return $this->hasMany(AttendeeCheckIn::class);
diff --git a/backend/app/Models/CheckInList.php b/backend/app/Models/CheckInList.php
index 0004d4bd35..70f1f02b76 100644
--- a/backend/app/Models/CheckInList.php
+++ b/backend/app/Models/CheckInList.php
@@ -22,4 +22,9 @@ public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
+
+ public function event_occurrence(): BelongsTo
+ {
+ return $this->belongsTo(EventOccurrence::class, 'event_occurrence_id');
+ }
}
diff --git a/backend/app/Models/Event.php b/backend/app/Models/Event.php
index 1d4741a68f..3d4ec316a9 100644
--- a/backend/app/Models/Event.php
+++ b/backend/app/Models/Event.php
@@ -81,6 +81,11 @@ public function affiliates(): HasMany
return $this->hasMany(Affiliate::class);
}
+ public function event_occurrences(): HasMany
+ {
+ return $this->hasMany(EventOccurrence::class);
+ }
+
public static function boot(): void
{
parent::boot();
@@ -96,10 +101,9 @@ static function (Event $event) {
protected function getCastMap(): array
{
return [
- EventDomainObjectAbstract::START_DATE => 'datetime',
- EventDomainObjectAbstract::END_DATE => 'datetime',
EventDomainObjectAbstract::ATTRIBUTES => 'array',
EventDomainObjectAbstract::LOCATION_DETAILS => 'array',
+ EventDomainObjectAbstract::RECURRENCE_RULE => 'array',
];
}
}
diff --git a/backend/app/Models/EventOccurrence.php b/backend/app/Models/EventOccurrence.php
new file mode 100644
index 0000000000..469261d175
--- /dev/null
+++ b/backend/app/Models/EventOccurrence.php
@@ -0,0 +1,66 @@
+belongsTo(Event::class);
+ }
+
+ public function order_items(): HasMany
+ {
+ return $this->hasMany(OrderItem::class, 'event_occurrence_id');
+ }
+
+ public function attendees(): HasMany
+ {
+ return $this->hasMany(Attendee::class, 'event_occurrence_id');
+ }
+
+ public function check_in_lists(): HasMany
+ {
+ return $this->hasMany(CheckInList::class, 'event_occurrence_id');
+ }
+
+ public function price_overrides(): HasMany
+ {
+ return $this->hasMany(ProductPriceOccurrenceOverride::class, 'event_occurrence_id');
+ }
+
+ public function event_occurrence_statistics(): HasOne
+ {
+ return $this->hasOne(EventOccurrenceStatistic::class, 'event_occurrence_id');
+ }
+
+ public function product_occurrence_visibility(): HasMany
+ {
+ return $this->hasMany(ProductOccurrenceVisibility::class, 'event_occurrence_id');
+ }
+
+ public function event_occurrence_daily_statistics(): HasMany
+ {
+ return $this->hasMany(EventOccurrenceDailyStatistic::class, 'event_occurrence_id');
+ }
+
+ protected function getCastMap(): array
+ {
+ return [
+ 'start_date' => 'datetime',
+ 'end_date' => 'datetime',
+ 'is_overridden' => 'boolean',
+ ];
+ }
+}
diff --git a/backend/app/Models/EventOccurrenceDailyStatistic.php b/backend/app/Models/EventOccurrenceDailyStatistic.php
new file mode 100644
index 0000000000..bc09c9bdc5
--- /dev/null
+++ b/backend/app/Models/EventOccurrenceDailyStatistic.php
@@ -0,0 +1,21 @@
+ 'float',
+ 'total_fee' => 'float',
+ 'sales_total_gross' => 'float',
+ 'sales_total_before_additions' => 'float',
+ 'total_refunded' => 'float',
+ ];
+ }
+}
diff --git a/backend/app/Models/EventOccurrenceStatistic.php b/backend/app/Models/EventOccurrenceStatistic.php
new file mode 100644
index 0000000000..0f898be680
--- /dev/null
+++ b/backend/app/Models/EventOccurrenceStatistic.php
@@ -0,0 +1,21 @@
+ 'float',
+ 'total_fee' => 'float',
+ 'sales_total_before_additions' => 'float',
+ 'sales_total_gross' => 'float',
+ 'total_refunded' => 'float',
+ ];
+ }
+}
diff --git a/backend/app/Models/Message.php b/backend/app/Models/Message.php
index 6f29ec7bff..d0e264cb92 100644
--- a/backend/app/Models/Message.php
+++ b/backend/app/Models/Message.php
@@ -2,6 +2,7 @@
namespace HiEvents\Models;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -20,6 +21,11 @@ public function outgoing_messages(): HasMany
return $this->hasMany(OutgoingMessage::class);
}
+ public function event_occurrence(): BelongsTo
+ {
+ return $this->belongsTo(EventOccurrence::class);
+ }
+
protected function getCastMap(): array
{
return [
diff --git a/backend/app/Models/OrderItem.php b/backend/app/Models/OrderItem.php
index f9a2d3630c..32472f38f5 100644
--- a/backend/app/Models/OrderItem.php
+++ b/backend/app/Models/OrderItem.php
@@ -43,4 +43,9 @@ public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
+
+ public function event_occurrence(): BelongsTo
+ {
+ return $this->belongsTo(EventOccurrence::class, 'event_occurrence_id');
+ }
}
diff --git a/backend/app/Models/Organizer.php b/backend/app/Models/Organizer.php
index 9eb57f2bdc..5488bac90e 100644
--- a/backend/app/Models/Organizer.php
+++ b/backend/app/Models/Organizer.php
@@ -3,6 +3,7 @@
namespace HiEvents\Models;
use HiEvents\Models\Traits\HasImages;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -26,4 +27,19 @@ public function webhooks(): HasMany
{
return $this->hasMany(Webhook::class);
}
+
+ public function organizer_stripe_platforms(): HasMany
+ {
+ return $this->hasMany(OrganizerStripePlatform::class);
+ }
+
+ public function organizer_vat_setting(): HasOne
+ {
+ return $this->hasOne(OrganizerVatSetting::class);
+ }
+
+ public function organizer_configuration(): BelongsTo
+ {
+ return $this->belongsTo(OrganizerConfiguration::class, 'organizer_configuration_id');
+ }
}
diff --git a/backend/app/Models/OrganizerConfiguration.php b/backend/app/Models/OrganizerConfiguration.php
new file mode 100644
index 0000000000..6285f04845
--- /dev/null
+++ b/backend/app/Models/OrganizerConfiguration.php
@@ -0,0 +1,23 @@
+ 'array',
+ ];
+ }
+
+ public function organizers(): HasMany
+ {
+ return $this->hasMany(Organizer::class);
+ }
+}
diff --git a/backend/app/Models/OrganizerStripePlatform.php b/backend/app/Models/OrganizerStripePlatform.php
new file mode 100644
index 0000000000..b0a3032168
--- /dev/null
+++ b/backend/app/Models/OrganizerStripePlatform.php
@@ -0,0 +1,31 @@
+ 'array',
+ 'stripe_setup_completed_at' => 'datetime',
+ ];
+ }
+
+ public function organizer(): BelongsTo
+ {
+ return $this->belongsTo(Organizer::class);
+ }
+}
diff --git a/backend/app/Models/OrganizerVatSetting.php b/backend/app/Models/OrganizerVatSetting.php
new file mode 100644
index 0000000000..77709c8510
--- /dev/null
+++ b/backend/app/Models/OrganizerVatSetting.php
@@ -0,0 +1,61 @@
+ 'boolean',
+ 'vat_validated' => 'boolean',
+ 'vat_validation_attempts' => 'integer',
+ 'vat_validation_date' => 'datetime',
+ ];
+ }
+
+ public function organizer(): BelongsTo
+ {
+ return $this->belongsTo(Organizer::class);
+ }
+}
diff --git a/backend/app/Models/ProductOccurrenceVisibility.php b/backend/app/Models/ProductOccurrenceVisibility.php
new file mode 100644
index 0000000000..7c09d0bad1
--- /dev/null
+++ b/backend/app/Models/ProductOccurrenceVisibility.php
@@ -0,0 +1,27 @@
+belongsTo(EventOccurrence::class, 'event_occurrence_id');
+ }
+
+ public function product(): BelongsTo
+ {
+ return $this->belongsTo(Product::class);
+ }
+
+ protected function getTimestampsEnabled(): bool
+ {
+ return false;
+ }
+}
diff --git a/backend/app/Models/ProductPriceOccurrenceOverride.php b/backend/app/Models/ProductPriceOccurrenceOverride.php
new file mode 100644
index 0000000000..79ba65b942
--- /dev/null
+++ b/backend/app/Models/ProductPriceOccurrenceOverride.php
@@ -0,0 +1,30 @@
+belongsTo(EventOccurrence::class, 'event_occurrence_id');
+ }
+
+ public function product_price(): BelongsTo
+ {
+ return $this->belongsTo(ProductPrice::class, 'product_price_id');
+ }
+
+ protected function getCastMap(): array
+ {
+ return [
+ 'price' => 'float',
+ ];
+ }
+}
diff --git a/backend/app/Models/WaitlistEntry.php b/backend/app/Models/WaitlistEntry.php
index 45f07495d9..1321f56151 100644
--- a/backend/app/Models/WaitlistEntry.php
+++ b/backend/app/Models/WaitlistEntry.php
@@ -21,6 +21,11 @@ public function product_price(): BelongsTo
return $this->belongsTo(ProductPrice::class);
}
+ public function event_occurrence(): BelongsTo
+ {
+ return $this->belongsTo(EventOccurrence::class);
+ }
+
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
diff --git a/backend/app/Providers/EventServiceProvider.php b/backend/app/Providers/EventServiceProvider.php
index 9d94df60a2..88fca68136 100644
--- a/backend/app/Providers/EventServiceProvider.php
+++ b/backend/app/Providers/EventServiceProvider.php
@@ -5,6 +5,7 @@
use HiEvents\Listeners\Webhook\WebhookEventListener;
use HiEvents\Services\Infrastructure\DomainEvents\Events\AttendeeEvent;
use HiEvents\Services\Infrastructure\DomainEvents\Events\CheckinEvent;
+use HiEvents\Services\Infrastructure\DomainEvents\Events\OccurrenceEvent;
use HiEvents\Services\Infrastructure\DomainEvents\Events\OrderEvent;
use HiEvents\Services\Infrastructure\DomainEvents\Events\ProductEvent;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
@@ -12,6 +13,9 @@
class EventServiceProvider extends ServiceProvider
{
+ protected $listen = [
+ ];
+
/**
* Map of listeners to the events they should handle.
*
@@ -23,6 +27,7 @@ class EventServiceProvider extends ServiceProvider
OrderEvent::class,
AttendeeEvent::class,
CheckinEvent::class,
+ OccurrenceEvent::class,
],
];
diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php
index 55f77ef5b6..750af972a4 100644
--- a/backend/app/Providers/RepositoryServiceProvider.php
+++ b/backend/app/Providers/RepositoryServiceProvider.php
@@ -8,9 +8,7 @@
use HiEvents\Repository\Eloquent\AccountConfigurationRepository;
use HiEvents\Repository\Eloquent\AccountMessagingTierRepository;
use HiEvents\Repository\Eloquent\AccountRepository;
-use HiEvents\Repository\Eloquent\AccountStripePlatformRepository;
use HiEvents\Repository\Eloquent\AccountUserRepository;
-use HiEvents\Repository\Eloquent\AccountVatSettingRepository;
use HiEvents\Repository\Eloquent\AffiliateRepository;
use HiEvents\Repository\Eloquent\AttendeeCheckInRepository;
use HiEvents\Repository\Eloquent\AttendeeRepository;
@@ -18,6 +16,9 @@
use HiEvents\Repository\Eloquent\CheckInListRepository;
use HiEvents\Repository\Eloquent\EmailTemplateRepository;
use HiEvents\Repository\Eloquent\EventDailyStatisticRepository;
+use HiEvents\Repository\Eloquent\EventOccurrenceRepository;
+use HiEvents\Repository\Eloquent\EventOccurrenceDailyStatisticRepository;
+use HiEvents\Repository\Eloquent\EventOccurrenceStatisticRepository;
use HiEvents\Repository\Eloquent\EventRepository;
use HiEvents\Repository\Eloquent\EventSettingsRepository;
use HiEvents\Repository\Eloquent\EventStatisticRepository;
@@ -32,10 +33,15 @@
use HiEvents\Repository\Eloquent\OrderRepository;
use HiEvents\Repository\Eloquent\OrganizerRepository;
use HiEvents\Repository\Eloquent\OrganizerSettingsRepository;
+use HiEvents\Repository\Eloquent\OrganizerStripePlatformRepository;
+use HiEvents\Repository\Eloquent\OrganizerVatSettingRepository;
+use HiEvents\Repository\Eloquent\OrganizerConfigurationRepository;
use HiEvents\Repository\Eloquent\OutgoingMessageRepository;
use HiEvents\Repository\Eloquent\PasswordResetRepository;
use HiEvents\Repository\Eloquent\PasswordResetTokenRepository;
use HiEvents\Repository\Eloquent\ProductCategoryRepository;
+use HiEvents\Repository\Eloquent\ProductOccurrenceVisibilityRepository;
+use HiEvents\Repository\Eloquent\ProductPriceOccurrenceOverrideRepository;
use HiEvents\Repository\Eloquent\ProductPriceRepository;
use HiEvents\Repository\Eloquent\ProductRepository;
use HiEvents\Repository\Eloquent\PromoCodeRepository;
@@ -55,9 +61,7 @@
use HiEvents\Repository\Interfaces\AccountConfigurationRepositoryInterface;
use HiEvents\Repository\Interfaces\AccountMessagingTierRepositoryInterface;
use HiEvents\Repository\Interfaces\AccountRepositoryInterface;
-use HiEvents\Repository\Interfaces\AccountStripePlatformRepositoryInterface;
use HiEvents\Repository\Interfaces\AccountUserRepositoryInterface;
-use HiEvents\Repository\Interfaces\AccountVatSettingRepositoryInterface;
use HiEvents\Repository\Interfaces\AffiliateRepositoryInterface;
use HiEvents\Repository\Interfaces\AttendeeCheckInRepositoryInterface;
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
@@ -65,6 +69,9 @@
use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface;
use HiEvents\Repository\Interfaces\EmailTemplateRepositoryInterface;
use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceDailyStatisticRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceStatisticRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface;
use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface;
@@ -79,10 +86,15 @@
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface;
use HiEvents\Repository\Interfaces\OrganizerSettingsRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrganizerStripePlatformRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrganizerVatSettingRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrganizerConfigurationRepositoryInterface;
use HiEvents\Repository\Interfaces\OutgoingMessageRepositoryInterface;
use HiEvents\Repository\Interfaces\PasswordResetRepositoryInterface;
use HiEvents\Repository\Interfaces\PasswordResetTokenRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductCategoryRepositoryInterface;
+use HiEvents\Repository\Interfaces\ProductOccurrenceVisibilityRepositoryInterface;
+use HiEvents\Repository\Interfaces\ProductPriceOccurrenceOverrideRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface;
@@ -148,11 +160,17 @@ class RepositoryServiceProvider extends ServiceProvider
OutgoingMessageRepositoryInterface::class => OutgoingMessageRepository::class,
OrganizerSettingsRepositoryInterface::class => OrganizerSettingsRepository::class,
EmailTemplateRepositoryInterface::class => EmailTemplateRepository::class,
- AccountStripePlatformRepositoryInterface::class => AccountStripePlatformRepository::class,
- AccountVatSettingRepositoryInterface::class => AccountVatSettingRepository::class,
+ OrganizerStripePlatformRepositoryInterface::class => OrganizerStripePlatformRepository::class,
+ OrganizerVatSettingRepositoryInterface::class => OrganizerVatSettingRepository::class,
+ OrganizerConfigurationRepositoryInterface::class => OrganizerConfigurationRepository::class,
TicketLookupTokenRepositoryInterface::class => TicketLookupTokenRepository::class,
AccountMessagingTierRepositoryInterface::class => AccountMessagingTierRepository::class,
WaitlistEntryRepositoryInterface::class => WaitlistEntryRepository::class,
+ EventOccurrenceRepositoryInterface::class => EventOccurrenceRepository::class,
+ EventOccurrenceStatisticRepositoryInterface::class => EventOccurrenceStatisticRepository::class,
+ EventOccurrenceDailyStatisticRepositoryInterface::class => EventOccurrenceDailyStatisticRepository::class,
+ ProductOccurrenceVisibilityRepositoryInterface::class => ProductOccurrenceVisibilityRepository::class,
+ ProductPriceOccurrenceOverrideRepositoryInterface::class => ProductPriceOccurrenceOverrideRepository::class,
];
public function register(): void
diff --git a/backend/app/Repository/DTO/CheckInListProductStatDTO.php b/backend/app/Repository/DTO/CheckInListProductStatDTO.php
new file mode 100644
index 0000000000..0d2a1fa67b
--- /dev/null
+++ b/backend/app/Repository/DTO/CheckInListProductStatDTO.php
@@ -0,0 +1,17 @@
+name;
+ // Joining organizers + organizer_stripe_platforms here would multiply
+ // each event row by (organizers per account × stripe platforms per organizer),
+ // silently inflating SUM(sales_total_gross) and SUM(orders_created).
+ // Use EXISTS for the stripe_connected check instead.
$query = DB::table('account_attributions as aa')
->select([
DB::raw("COALESCE(aa.{$groupColumn}, '(not set)') as attribution_value"),
DB::raw('COUNT(DISTINCT aa.account_id) as total_accounts'),
DB::raw('COUNT(DISTINCT e.id) as total_events'),
DB::raw("COUNT(DISTINCT CASE WHEN e.status = '{$liveStatus}' THEN e.id END) as live_events"),
- DB::raw('COUNT(DISTINCT CASE WHEN asp.stripe_setup_completed_at IS NOT NULL THEN aa.account_id END) as stripe_connected'),
+ DB::raw('COUNT(DISTINCT CASE WHEN EXISTS (SELECT 1 FROM organizers o2 JOIN organizer_stripe_platforms osp2 ON osp2.organizer_id = o2.id WHERE o2.account_id = aa.account_id AND o2.deleted_at IS NULL AND osp2.deleted_at IS NULL AND osp2.stripe_setup_completed_at IS NOT NULL) THEN aa.account_id END) as stripe_connected'),
DB::raw('COUNT(DISTINCT CASE WHEN a.is_manually_verified = true THEN aa.account_id END) as verified_accounts'),
DB::raw('COALESCE(SUM(es.sales_total_gross), 0) as total_revenue'),
DB::raw('COALESCE(SUM(es.orders_created), 0) as total_orders'),
])
->join('accounts as a', 'aa.account_id', '=', 'a.id')
- ->leftJoin('account_stripe_platforms as asp', function ($join) {
- $join->on('a.id', '=', 'asp.account_id')
- ->whereNull('asp.deleted_at');
- })
->leftJoin('events as e', function ($join) {
$join->on('a.id', '=', 'e.account_id')
->whereNull('e.deleted_at');
diff --git a/backend/app/Repository/Eloquent/AccountRepository.php b/backend/app/Repository/Eloquent/AccountRepository.php
index 54f136d31b..a7c5b01801 100644
--- a/backend/app/Repository/Eloquent/AccountRepository.php
+++ b/backend/app/Repository/Eloquent/AccountRepository.php
@@ -26,16 +26,16 @@ public function getDomainObject(): string
public function findByEventId(int $eventId): AccountDomainObject
{
- $account = $this
- ->model
- ->select('accounts.*')
- ->join('events', 'accounts.id', '=', 'events.account_id')
- ->where('events.id', $eventId)
- ->first();
-
- $this->resetModel();
+ return $this->runQuery(function () use ($eventId) {
+ $account = $this
+ ->model
+ ->select('accounts.*')
+ ->join('events', 'accounts.id', '=', 'events.account_id')
+ ->where('events.id', $eventId)
+ ->first();
- return $this->handleSingleResult($account, AccountDomainObject::class);
+ return $this->handleSingleResult($account, AccountDomainObject::class);
+ });
}
public function getAllAccountsWithCounts(?string $search, int $perPage): LengthAwarePaginator
@@ -69,13 +69,13 @@ public function getAccountWithDetails(int $accountId): Account
return $this->model
->withCount(['events', 'users'])
->with([
- 'configuration',
- 'account_vat_setting',
'messagingTier',
+ 'organizers.organizer_configuration',
+ 'organizers.organizer_vat_setting',
'users' => function ($query) {
$query->select('users.id', 'users.first_name', 'users.last_name', 'users.email')
->withPivot('role');
- }
+ },
])
->findOrFail($accountId);
}
diff --git a/backend/app/Repository/Eloquent/AccountStripePlatformRepository.php b/backend/app/Repository/Eloquent/AccountStripePlatformRepository.php
deleted file mode 100644
index d8f45cc0af..0000000000
--- a/backend/app/Repository/Eloquent/AccountStripePlatformRepository.php
+++ /dev/null
@@ -1,25 +0,0 @@
-
- */
-class AccountStripePlatformRepository extends BaseRepository implements AccountStripePlatformRepositoryInterface
-{
- protected function getModel(): string
- {
- return AccountStripePlatform::class;
- }
-
- public function getDomainObject(): string
- {
- return AccountStripePlatformDomainObject::class;
- }
-}
diff --git a/backend/app/Repository/Eloquent/AccountVatSettingRepository.php b/backend/app/Repository/Eloquent/AccountVatSettingRepository.php
deleted file mode 100644
index 6e9a7393fe..0000000000
--- a/backend/app/Repository/Eloquent/AccountVatSettingRepository.php
+++ /dev/null
@@ -1,28 +0,0 @@
-
- */
-class AccountVatSettingRepository extends BaseRepository implements AccountVatSettingRepositoryInterface
-{
- protected function getModel(): string
- {
- return AccountVatSetting::class;
- }
-
- public function getDomainObject(): string
- {
- return AccountVatSettingDomainObject::class;
- }
-
- public function findByAccountId(int $accountId): ?AccountVatSettingDomainObject
- {
- return $this->findFirstWhere(['account_id' => $accountId]);
- }
-}
diff --git a/backend/app/Repository/Eloquent/AttendeeRepository.php b/backend/app/Repository/Eloquent/AttendeeRepository.php
index 8f2ce62ff0..76faee35ed 100644
--- a/backend/app/Repository/Eloquent/AttendeeRepository.php
+++ b/backend/app/Repository/Eloquent/AttendeeRepository.php
@@ -4,6 +4,7 @@
use HiEvents\DomainObjects\AttendeeCheckInDomainObject;
use HiEvents\DomainObjects\AttendeeDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\Generated\AttendeeDomainObjectAbstract;
use HiEvents\DomainObjects\Status\AttendeeStatus;
use HiEvents\DomainObjects\Status\OrderStatus;
@@ -32,31 +33,37 @@ public function getDomainObject(): string
return AttendeeDomainObject::class;
}
- public function findByEventIdForExport(int $eventId): Collection
+ public function findByEventIdForExport(int $eventId, ?int $eventOccurrenceId = null): Collection
{
- $this->applyConditions([
- 'attendees.event_id' => $eventId,
- ]);
-
- $this->model->select('attendees.*');
- $this->model->join('orders', 'orders.id', '=', 'attendees.order_id');
- $this->model->whereIn('orders.status', [
- OrderStatus::AWAITING_OFFLINE_PAYMENT->name,
- OrderStatus::COMPLETED->name,
- OrderStatus::CANCELLED->name
- ]);
-
- $model = $this->model->limit(10000)->get();
- $this->resetModel();
-
- return $this->handleResults($model);
- }
+ return $this->runQuery(function () use ($eventId, $eventOccurrenceId) {
+ $conditions = [
+ 'attendees.event_id' => $eventId,
+ ];
+
+ if ($eventOccurrenceId !== null) {
+ $conditions['attendees.event_occurrence_id'] = $eventOccurrenceId;
+ }
+
+ $this->applyConditions($conditions);
+
+ $this->model->select('attendees.*');
+ $this->model->join('orders', 'orders.id', '=', 'attendees.order_id');
+ $this->model->whereIn('orders.status', [
+ OrderStatus::AWAITING_OFFLINE_PAYMENT->name,
+ OrderStatus::COMPLETED->name,
+ OrderStatus::CANCELLED->name,
+ ]);
+ $model = $this->model->limit(10000)->get();
+
+ return $this->handleResults($model);
+ });
+ }
public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator
{
$where = [
- ['attendees.event_id', '=', $eventId]
+ ['attendees.event_id', '=', $eventId],
];
if ($params->query) {
@@ -66,14 +73,14 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware
DB::raw(
sprintf(
"(%s||' '||%s)",
- 'attendees.' . AttendeeDomainObjectAbstract::FIRST_NAME,
- 'attendees.' . AttendeeDomainObjectAbstract::LAST_NAME,
+ 'attendees.'.AttendeeDomainObjectAbstract::FIRST_NAME,
+ 'attendees.'.AttendeeDomainObjectAbstract::LAST_NAME,
)
- ), 'ilike', '%' . $params->query . '%')
- ->orWhere('attendees.' . AttendeeDomainObjectAbstract::LAST_NAME, 'ilike', '%' . $params->query . '%')
- ->orWhere('attendees.' . AttendeeDomainObjectAbstract::FIRST_NAME, 'ilike', '%' . $params->query . '%')
- ->orWhere('attendees.' . AttendeeDomainObjectAbstract::PUBLIC_ID, 'ilike', '%' . $params->query . '%')
- ->orWhere('attendees.' . AttendeeDomainObjectAbstract::EMAIL, 'ilike', '%' . $params->query . '%');
+ ), 'ilike', '%'.$params->query.'%')
+ ->orWhere('attendees.'.AttendeeDomainObjectAbstract::LAST_NAME, 'ilike', '%'.$params->query.'%')
+ ->orWhere('attendees.'.AttendeeDomainObjectAbstract::FIRST_NAME, 'ilike', '%'.$params->query.'%')
+ ->orWhere('attendees.'.AttendeeDomainObjectAbstract::PUBLIC_ID, 'ilike', '%'.$params->query.'%')
+ ->orWhere('attendees.'.AttendeeDomainObjectAbstract::EMAIL, 'ilike', '%'.$params->query.'%');
};
}
@@ -93,7 +100,7 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware
->leftJoin('products', 'products.id', '=', 'attendees.product_id')
->orderBy('products.title', $sortDirection);
} else {
- $this->model = $this->model->orderBy('attendees.' . $sortBy, $sortDirection);
+ $this->model = $this->model->orderBy('attendees.'.$sortBy, $sortDirection);
}
return $this->paginateWhere(
@@ -113,26 +120,53 @@ public function getAttendeesByCheckInShortId(string $shortId, QueryParamsDTO $pa
DB::raw(
sprintf(
"(%s||' '||%s)",
- 'attendees.' . AttendeeDomainObjectAbstract::FIRST_NAME,
- 'attendees.' . AttendeeDomainObjectAbstract::LAST_NAME,
+ 'attendees.'.AttendeeDomainObjectAbstract::FIRST_NAME,
+ 'attendees.'.AttendeeDomainObjectAbstract::LAST_NAME,
)
- ), 'ilike', '%' . $params->query . '%')
- ->orWhere('attendees.' . AttendeeDomainObjectAbstract::LAST_NAME, 'ilike', '%' . $params->query . '%')
- ->orWhere('attendees.' . AttendeeDomainObjectAbstract::FIRST_NAME, 'ilike', '%' . $params->query . '%')
- ->orWhere('attendees.' . AttendeeDomainObjectAbstract::PUBLIC_ID, 'ilike', '%' . $params->query . '%')
- ->orWhere('attendees.' . AttendeeDomainObjectAbstract::EMAIL, 'ilike', '%' . $params->query . '%');
+ ), 'ilike', '%'.$params->query.'%')
+ ->orWhere('attendees.'.AttendeeDomainObjectAbstract::LAST_NAME, 'ilike', '%'.$params->query.'%')
+ ->orWhere('attendees.'.AttendeeDomainObjectAbstract::FIRST_NAME, 'ilike', '%'.$params->query.'%')
+ ->orWhere('attendees.'.AttendeeDomainObjectAbstract::PUBLIC_ID, 'ilike', '%'.$params->query.'%')
+ ->orWhere('attendees.'.AttendeeDomainObjectAbstract::EMAIL, 'ilike', '%'.$params->query.'%');
};
}
+ // "Empty attachments = all tickets": join the list via event_id and use
+ // EXISTS branches rather than an INNER JOIN on product_check_in_lists.
$this->model = $this->model->select('attendees.*')
->join('orders', 'orders.id', '=', 'attendees.order_id')
- ->join('product_check_in_lists', 'product_check_in_lists.product_id', '=', 'attendees.product_id')
- ->join('check_in_lists', 'check_in_lists.id', '=', 'product_check_in_lists.check_in_list_id')
- ->where('check_in_lists.short_id', $shortId)
- ->whereIn('attendees.status',[AttendeeStatus::ACTIVE->name, AttendeeStatus::CANCELLED->name, AttendeeStatus::AWAITING_PAYMENT->name])
+ ->join('check_in_lists', function ($join) use ($shortId) {
+ $join->on('check_in_lists.event_id', '=', 'attendees.event_id')
+ ->where('check_in_lists.short_id', '=', $shortId)
+ ->whereNull('check_in_lists.deleted_at');
+ })
+ ->where(function ($query) {
+ $query->whereExists(function ($sub) {
+ $sub->select(DB::raw(1))
+ ->from('product_check_in_lists as pcil')
+ ->whereColumn('pcil.check_in_list_id', 'check_in_lists.id')
+ ->whereColumn('pcil.product_id', 'attendees.product_id')
+ ->whereNull('pcil.deleted_at');
+ })->orWhereNotExists(function ($sub) {
+ $sub->select(DB::raw(1))
+ ->from('product_check_in_lists as pcil')
+ ->whereColumn('pcil.check_in_list_id', 'check_in_lists.id')
+ ->whereNull('pcil.deleted_at');
+ });
+ })
+ ->whereIn('attendees.status', [AttendeeStatus::ACTIVE->name, AttendeeStatus::CANCELLED->name, AttendeeStatus::AWAITING_PAYMENT->name])
->whereIn('orders.status', [OrderStatus::COMPLETED->name, OrderStatus::AWAITING_OFFLINE_PAYMENT->name]);
+ $occurrenceFilter = $params->filter_fields?->firstWhere('field', 'event_occurrence_id');
+ if ($occurrenceFilter) {
+ $this->model = $this->model->where(
+ 'attendees.event_occurrence_id',
+ $occurrenceFilter->value
+ );
+ }
+
$this->loadRelation(new Relationship(AttendeeCheckInDomainObject::class, name: 'check_ins'));
+ $this->loadRelation(new Relationship(EventOccurrenceDomainObject::class, name: 'event_occurrence'));
return $this->simplePaginateWhere(
where: $where,
diff --git a/backend/app/Repository/Eloquent/BaseRepository.php b/backend/app/Repository/Eloquent/BaseRepository.php
index f00f8717c9..5c3a93a034 100644
--- a/backend/app/Repository/Eloquent/BaseRepository.php
+++ b/backend/app/Repository/Eloquent/BaseRepository.php
@@ -6,6 +6,7 @@
use BadMethodCallException;
use Carbon\Carbon;
+use Closure;
use HiEvents\DomainObjects\Interfaces\DomainObjectInterface;
use HiEvents\DomainObjects\Interfaces\IsSortable;
use HiEvents\Http\DTO\QueryParamsDTO;
@@ -18,6 +19,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Eloquent\Relations\Relation;
+use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Foundation\Application;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
@@ -26,6 +28,7 @@
/**
* @template T of DomainObjectInterface
+ *
* @implements RepositoryInterface
*/
abstract class BaseRepository implements RepositoryInterface
@@ -50,20 +53,18 @@ public function __construct(Application $application, DatabaseManager $db)
/**
* Returns a FQCL of the model
- *
- * @return string
*/
abstract protected function getModel(): string;
/**
- * @param class-string $domainObjectClass
+ * @param class-string $domainObjectClass
*/
protected function validateSortColumn(?string $sortBy, string $domainObjectClass): string
{
$allowedColumns = array_keys($domainObjectClass::getAllowedSorts()->toArray());
$default = $domainObjectClass::getDefaultSort();
- if ($sortBy === null || !in_array($sortBy, $allowedColumns, true)) {
+ if ($sortBy === null || ! in_array($sortBy, $allowedColumns, true)) {
return $default;
}
@@ -86,61 +87,63 @@ public function setMaxPerPage(int $maxPerPage): static
public function all(array $columns = self::DEFAULT_COLUMNS): Collection
{
- $models = $this->model->all($columns);
- $this->resetModel();
-
- return $this->handleResults($models);
+ return $this->runQuery(
+ fn () => $this->handleResults($this->model->all($columns))
+ );
}
public function paginate(
- ?int $limit = null,
+ ?int $limit = null,
array $columns = self::DEFAULT_COLUMNS
- ): LengthAwarePaginator
- {
- $results = $this->model->paginate($this->getPaginationPerPage($limit), $columns);
- $this->resetModel();
-
- return $this->handleResults($results);
+ ): LengthAwarePaginator {
+ return $this->runQuery(
+ fn () => $this->handleResults(
+ $this->model->paginate($this->getPaginationPerPage($limit), $columns)
+ )
+ );
}
public function paginateWhere(
array $where,
- ?int $limit = null,
+ ?int $limit = null,
array $columns = self::DEFAULT_COLUMNS,
- ?int $page = null,
- ): LengthAwarePaginator
- {
- $this->applyConditions($where);
- $results = $this->model->paginate(
- perPage: $this->getPaginationPerPage($limit),
- columns: $columns,
- page: $page,
- );
- $this->resetModel();
+ ?int $page = null,
+ ): LengthAwarePaginator {
+ return $this->runQuery(function () use ($where, $limit, $columns, $page) {
+ $this->applyConditions($where);
- return $this->handleResults($results);
+ return $this->handleResults($this->model->paginate(
+ perPage: $this->getPaginationPerPage($limit),
+ columns: $columns,
+ page: $page,
+ ));
+ });
}
public function simplePaginateWhere(
array $where,
- ?int $limit = null,
+ ?int $limit = null,
array $columns = self::DEFAULT_COLUMNS,
- ): Paginator
- {
- $this->applyConditions($where);
- $results = $this->model->simplePaginate($this->getPaginationPerPage($limit), $columns);
- $this->resetModel();
+ ): Paginator {
+ return $this->runQuery(function () use ($where, $limit, $columns) {
+ $this->applyConditions($where);
- return $this->handleResults($results);
+ return $this->handleResults(
+ $this->model->simplePaginate($this->getPaginationPerPage($limit), $columns)
+ );
+ });
}
public function paginateEloquentRelation(
Relation $relation,
- ?int $limit = null,
- array $columns = self::DEFAULT_COLUMNS
- ): LengthAwarePaginator
- {
- return $this->handleResults($relation->paginate($this->getPaginationPerPage($limit), $columns));
+ ?int $limit = null,
+ array $columns = self::DEFAULT_COLUMNS
+ ): LengthAwarePaginator {
+ return $this->runQuery(
+ fn () => $this->handleResults(
+ $relation->paginate($this->getPaginationPerPage($limit), $columns)
+ )
+ );
}
/**
@@ -148,101 +151,99 @@ public function paginateEloquentRelation(
*/
public function findById(int $id, array $columns = self::DEFAULT_COLUMNS): DomainObjectInterface
{
- $model = $this->model->findOrFail($id, $columns);
- $this->resetModel();
-
- return $this->handleSingleResult($model);
+ return $this->runQuery(
+ fn () => $this->handleSingleResult($this->model->findOrFail($id, $columns))
+ );
}
public function findFirstByField(
- string $field,
+ string $field,
?string $value = null,
- array $columns = ['*']
- ): ?DomainObjectInterface
- {
- $model = $this->model->where($field, '=', $value)->first($columns);
- $this->resetModel();
-
- return $this->handleSingleResult($model);
+ array $columns = ['*']
+ ): ?DomainObjectInterface {
+ return $this->runQuery(
+ fn () => $this->handleSingleResult(
+ $this->model->where($field, '=', $value)->first($columns)
+ )
+ );
}
public function findFirst(int $id, array $columns = self::DEFAULT_COLUMNS): ?DomainObjectInterface
{
- $model = $this->model->findOrFail($id, $columns);
- $this->resetModel();
-
- return $this->handleSingleResult($model);
+ return $this->runQuery(
+ fn () => $this->handleSingleResult($this->model->findOrFail($id, $columns))
+ );
}
public function findWhere(
array $where,
array $columns = self::DEFAULT_COLUMNS,
array $orderAndDirections = [],
- ): Collection
- {
- $this->applyConditions($where);
+ ?int $limit = null,
+ ): Collection {
+ return $this->runQuery(function () use ($where, $columns, $orderAndDirections, $limit) {
+ $this->applyConditions($where);
- if ($orderAndDirections) {
foreach ($orderAndDirections as $orderAndDirection) {
$this->model = $this->model->orderBy(
$orderAndDirection->getOrder(),
$orderAndDirection->getDirection()
);
}
- }
- $model = $this->model->get($columns);
-
- $this->resetModel();
+ if ($limit !== null) {
+ $this->model = $this->model->limit($limit);
+ }
- return $this->handleResults($model);
+ return $this->handleResults($this->model->get($columns));
+ });
}
public function findFirstWhere(array $where, array $columns = self::DEFAULT_COLUMNS): ?DomainObjectInterface
{
- $this->applyConditions($where);
- $model = $this->model->first($columns);
- $this->resetModel();
+ return $this->runQuery(function () use ($where, $columns) {
+ $this->applyConditions($where);
- return $this->handleSingleResult($model);
+ return $this->handleSingleResult($this->model->first($columns));
+ });
}
public function findWhereIn(string $field, array $values, array $additionalWhere = [], array $columns = self::DEFAULT_COLUMNS): Collection
{
- if ($additionalWhere) {
- $this->applyConditions($additionalWhere);
- }
-
- $model = $this->model->whereIn($field, $values)->get($columns);
- $this->resetModel();
+ return $this->runQuery(function () use ($field, $values, $additionalWhere, $columns) {
+ if ($additionalWhere) {
+ $this->applyConditions($additionalWhere);
+ }
- return $this->handleResults($model);
+ return $this->handleResults($this->model->whereIn($field, $values)->get($columns));
+ });
}
public function create(array $attributes): DomainObjectInterface
{
- $model = $this->model->newInstance(collect($attributes)->toArray());
- $model->save();
- $this->resetModel();
+ return $this->runQuery(function () use ($attributes) {
+ $model = $this->model->newInstance(collect($attributes)->toArray());
+ $model->save();
- return $this->handleSingleResult($model);
+ return $this->handleSingleResult($model);
+ });
}
public function insert(array $inserts): bool
{
- // When doing a bulk insert Eloquent doesn't autofill the updated/created dates,
- // so we need to do it manually
- foreach ($inserts as $index => $insert) {
- if (!isset($insert['created_at'], $insert['updated_at'])) {
- $now = Carbon::now();
- $inserts[$index]['created_at'] = $now;
- $inserts[$index]['updated_at'] = $now;
+ return $this->runQuery(function () use ($inserts) {
+ // When doing a bulk insert Eloquent doesn't autofill the updated/created dates,
+ // so we need to do it manually
+ foreach ($inserts as $index => $insert) {
+ if (! isset($insert['created_at'], $insert['updated_at'])) {
+ $now = Carbon::now();
+ $inserts[$index]['created_at'] = $now;
+ $inserts[$index]['updated_at'] = $now;
+ }
}
- }
- $insert = $this->model->insert($inserts);
- $this->resetModel();
- return $insert;
+ return $this->model->insert($inserts);
+ });
}
public function updateFromDomainObject(int $id, DomainObjectInterface $domainObject): DomainObjectInterface
@@ -252,93 +253,103 @@ public function updateFromDomainObject(int $id, DomainObjectInterface $domainObj
public function updateFromArray(int $id, array $attributes): DomainObjectInterface
{
- $model = $this->model->findOrFail($id);
- $model->fill($attributes);
- $model->save();
- $this->resetModel();
+ return $this->runQuery(function () use ($id, $attributes) {
+ $model = $this->model->findOrFail($id);
+ $model->fill($attributes);
+ $model->save();
- return $this->handleSingleResult($model);
+ return $this->handleSingleResult($model);
+ });
}
public function updateWhere(array $attributes, array $where): int
{
- $this->applyConditions($where);
- $count = $this->model->update($attributes);
- $this->resetModel();
+ return $this->runQuery(function () use ($attributes, $where) {
+ $this->applyConditions($where);
- return $count;
+ return $this->model->update($attributes);
+ });
}
public function updateByIdWhere(int $id, array $attributes, array $where): DomainObjectInterface
{
- $model = $this->model->where($where)->findOrFail($id);
- $model->update($attributes);
- $this->resetModel();
+ return $this->runQuery(function () use ($id, $attributes, $where) {
+ $model = $this->model->where($where)->findOrFail($id);
+ $model->update($attributes);
- return $this->handleSingleResult($model);
+ return $this->handleSingleResult($model);
+ });
}
public function deleteById(int $id): bool
{
- return $this->model->findOrFail($id)->delete();
+ return $this->runQuery(
+ fn () => (bool) $this->model->findOrFail($id)->delete()
+ );
}
public function incrementEach(array $columns, array $additionalUpdates = [], ?array $where = null): int
{
- if ($where) {
- $this->applyConditions($where);
- }
-
- $count = $this->model->incrementEach($columns, $additionalUpdates);
- $this->resetModel();
+ return $this->runQuery(function () use ($columns, $additionalUpdates, $where) {
+ if ($where) {
+ $this->applyConditions($where);
+ }
- return $count;
+ // Eloquent\Builder's __call swallows incrementEach's int return value
+ // and hands back the Builder, so we route through the underlying
+ // QueryBuilder to get the affected-row count.
+ return $this->resolveBaseQuery()->incrementEach($columns, $additionalUpdates);
+ });
}
public function decrementEach(array $where, array $columns, array $extra = []): int
{
- $this->applyConditions($where);
- $count = $this->model->decrementEach($columns, $extra);
- $this->resetModel();
+ return $this->runQuery(function () use ($where, $columns, $extra) {
+ $this->applyConditions($where);
- return $count;
+ return $this->resolveBaseQuery()->decrementEach($columns, $extra);
+ });
}
public function increment(int|float $id, string $column, int|float $amount = 1): int
{
- return $this->model->findOrFail($id)->increment($column, $amount);
+ return $this->runQuery(
+ fn () => $this->model->findOrFail($id)->increment($column, $amount)
+ );
}
public function incrementWhere(array $where, string $column, int|float $amount = 1): int
{
- $this->applyConditions($where);
- $count = $this->model->increment($column, $amount);
- $this->resetModel();
+ return $this->runQuery(function () use ($where, $column, $amount) {
+ $this->applyConditions($where);
- return $count;
+ return $this->model->increment($column, $amount);
+ });
}
public function decrement(int|float $id, string $column, int|float $amount = 1): int
{
- return $this->model->findOrFail($id)?->decrement($column, $amount);
+ return $this->runQuery(
+ fn () => $this->model->findOrFail($id)->decrement($column, $amount)
+ );
}
public function deleteWhere(array $conditions): int
{
- $this->applyConditions($conditions);
- $deleted = $this->model->delete();
- $this->resetModel();
+ return $this->runQuery(function () use ($conditions) {
+ $this->applyConditions($conditions);
- return $deleted;
+ return $this->model->delete();
+ });
}
public function countWhere(array $conditions): int
{
- $this->applyConditions($conditions);
- $count = $this->model->count();
- $this->resetModel();
+ return $this->runQuery(function () use ($conditions) {
+ $this->applyConditions($conditions);
- return $count;
+ return $this->model->count();
+ });
}
public function loadRelation(string|Relationship $relationship): static
@@ -363,7 +374,7 @@ public function includeDeleted(): static
protected function applyConditions(array $where): void
{
foreach ($where as $field => $value) {
- if (is_callable($value) && !is_string($value)) {
+ if (is_callable($value) && ! is_string($value)) {
$this->model = $this->model->where($value);
} elseif (is_array($value)) {
[$field, $condition, $val] = $value;
@@ -406,6 +417,48 @@ protected function initModel(?string $model = null): Model
return $this->app->make($model ?: $this->getModel());
}
+ /**
+ * Execute a query callback and guarantee per-call state is reset afterwards,
+ * even if the callback throws. This is the single point at which the in-flight
+ * builder ($this->model) and the eager-load list ($this->eagerLoads) are cleared.
+ *
+ * The callback runs BEFORE reset, so hydration helpers that read $this->eagerLoads
+ * (e.g. handleEagerLoads()) still see the correct state.
+ *
+ * @template TReturn
+ *
+ * @param Closure(): TReturn $callback
+ * @return TReturn
+ */
+ protected function runQuery(Closure $callback): mixed
+ {
+ try {
+ return $callback();
+ } finally {
+ $this->resetState();
+ }
+ }
+
+ protected function resetState(): void
+ {
+ $model = $this->getModel();
+ $this->model = new $model;
+ $this->eagerLoads = [];
+ }
+
+ /**
+ * Resolve $this->model (which may be a fresh Model or an Eloquent Builder
+ * after applyConditions()) to the underlying query builder. Required for
+ * methods Eloquent\Builder::__call swallows the return value of, e.g.
+ * incrementEach() / decrementEach().
+ */
+ private function resolveBaseQuery(): QueryBuilder
+ {
+ return $this->model instanceof Builder
+ ? $this->model->getQuery()
+ : $this->model->newQuery()->getQuery();
+ }
+
protected function handleResults($results, ?string $domainObjectOverride = null)
{
$domainObjects = [];
@@ -428,10 +481,9 @@ protected function handleResults($results, ?string $domainObjectOverride = null)
protected function handleSingleResult(
?BaseModel $model,
- ?string $domainObjectOverride = null
- ): ?DomainObjectInterface
- {
- if (!$model) {
+ ?string $domainObjectOverride = null
+ ): ?DomainObjectInterface {
+ if (! $model) {
return null;
}
@@ -442,11 +494,10 @@ protected function applyFilterFields(
QueryParamsDTO $params,
array $allowedFilterFields = [],
?string $prefix = null,
- ): void
- {
+ ): void {
if ($params->filter_fields && $params->filter_fields->isNotEmpty()) {
$params->filter_fields->each(function ($filterField) use ($prefix, $allowedFilterFields) {
- if (!in_array($filterField->field, $allowedFilterFields, true)) {
+ if (! in_array($filterField->field, $allowedFilterFields, true)) {
return;
}
@@ -467,7 +518,7 @@ protected function applyFilterFields(
sprintf('Operator %s is not supported', $filterField->operator)
);
- $field = $prefix ? $prefix . '.' . $filterField->field : $filterField->field;
+ $field = $prefix ? $prefix.'.'.$filterField->field : $filterField->field;
// Special handling for IN operator
if ($operator === 'IN') {
@@ -491,10 +542,13 @@ protected function applyFilterFields(
}
}
+ /**
+ * @deprecated Use resetState() instead. Kept for backwards compatibility with
+ * subclass repositories that build custom queries on $this->model.
+ */
protected function resetModel(): void
{
- $model = $this->getModel();
- $this->model = new $model();
+ $this->resetState();
}
private function getPaginationPerPage(?int $perPage): int
@@ -503,30 +557,26 @@ private function getPaginationPerPage(?int $perPage): int
$perPage = self::DEFAULT_PAGINATE_LIMIT;
}
- return (int)min($perPage, $this->maxPerPage);
+ return (int) min($perPage, $this->maxPerPage);
}
/**
- * @param Model $model
- * @param string|null $domainObjectOverride A FQCN of a DO
- * @param array|null $relationships
- * @return DomainObjectInterface
+ * @param string|null $domainObjectOverride A FQCN of a DO
*
* @todo use hydrate method from AbstractDomainObject
*/
private function hydrateDomainObjectFromModel(
- Model $model,
+ Model $model,
?string $domainObjectOverride = null,
- ?array $relationships = null,
- ): DomainObjectInterface
- {
+ ?array $relationships = null,
+ ): DomainObjectInterface {
/** @var DomainObjectInterface $object */
$object = $domainObjectOverride ?: $this->getDomainObject();
- $object = new $object();
+ $object = new $object;
foreach ($model->attributesToArray() as $attribute => $value) {
- $method = 'set' . ucfirst(Str::camel($attribute));
- if (is_callable(array($object, $method))) {
+ $method = 'set'.Str::studly($attribute);
+ if (is_callable([$object, $method])) {
try {
$object->$method($value);
} catch (TypeError $e) {
@@ -538,7 +588,7 @@ private function hydrateDomainObjectFromModel(
var_export($value, true),
$e->getMessage()
),
- (int)$e->getCode(),
+ (int) $e->getCode(),
$e
);
}
@@ -554,24 +604,20 @@ private function hydrateDomainObjectFromModel(
/**
* This method will handle nested eager loading of relationships
*
- * @param Model $model
- * @param DomainObjectInterface $object
- * @param Relationship[]|null $relationships
- *
- * @return void
+ * @param Relationship[]|null $relationships
*/
private function handleEagerLoads(Model $model, DomainObjectInterface $object, ?array $relationships): void
{
$eagerLoads = $relationships ?: $this->eagerLoads;
foreach ($eagerLoads as $eagerLoad) {
- if (!$model->relationLoaded($eagerLoad->getName())) {
+ if (! $model->relationLoaded($eagerLoad->getName())) {
continue;
}
$relatedModels = $model->getRelation($eagerLoad->getName());
- $setterMethod = 'set' . Str::studly($eagerLoad->getName());
+ $setterMethod = 'set'.Str::studly($eagerLoad->getName());
- if (!is_callable([$object, $setterMethod])) {
+ if (! is_callable([$object, $setterMethod])) {
throw new BadMethodCallException(
sprintf(
'Method %s is not callable on %s. Does it exist?',
@@ -590,7 +636,7 @@ private function handleEagerLoads(Model $model, DomainObjectInterface $object, ?
);
});
$object->$setterMethod($relatedDomainObjects);
- } else if ($relatedModels instanceof BaseModel) {
+ } elseif ($relatedModels instanceof BaseModel) {
$relatedDomainObject = $this->hydrateDomainObjectFromModel(
$relatedModels,
$eagerLoad->getDomainObject(),
diff --git a/backend/app/Repository/Eloquent/CheckInListRepository.php b/backend/app/Repository/Eloquent/CheckInListRepository.php
index 46b37358da..b5e81d496c 100644
--- a/backend/app/Repository/Eloquent/CheckInListRepository.php
+++ b/backend/app/Repository/Eloquent/CheckInListRepository.php
@@ -8,6 +8,8 @@
use HiEvents\Http\DTO\QueryParamsDTO;
use HiEvents\Models\CheckInList;
use HiEvents\Repository\DTO\CheckedInAttendeesCountDTO;
+use HiEvents\Repository\DTO\CheckInListProductStatDTO;
+use HiEvents\Repository\DTO\CheckInListRecentCheckInDTO;
use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
@@ -28,26 +30,48 @@ public function getDomainObject(): string
return CheckInListDomainObject::class;
}
- public function getCheckedInAttendeeCountById(int $checkInListId): CheckedInAttendeesCountDTO
- {
+ public function getCheckedInAttendeeCountById(
+ int $checkInListId,
+ ?int $eventOccurrenceIdOverride = null,
+ ): CheckedInAttendeesCountDTO {
+ $clause = $this->buildOccurrenceFilterClauses($eventOccurrenceIdOverride);
+
+ // "Empty attachments = all tickets": valid_attendees joins the list via
+ // event_id and uses EXISTS/NOT EXISTS to express "attached, or list has
+ // no attachments".
$sql = <<checkInClause}
GROUP BY attendee_id, check_in_list_id
),
valid_attendees AS (
- SELECT a.id, pcil.check_in_list_id
+ SELECT a.id, cil.id AS check_in_list_id
FROM attendees a
- JOIN product_check_in_lists pcil ON a.product_id = pcil.product_id
JOIN orders o ON a.order_id = o.id
- JOIN check_in_lists cil ON pcil.check_in_list_id = cil.id
+ JOIN check_in_lists cil ON cil.event_id = a.event_id
+ AND cil.id = :check_in_list_id
+ AND cil.deleted_at IS NULL
JOIN event_settings es ON cil.event_id = es.event_id
WHERE a.deleted_at IS NULL
- AND pcil.deleted_at IS NULL
- AND pcil.check_in_list_id = :check_in_list_id
+ {$clause->attendeeClause}
+ AND (
+ EXISTS (
+ SELECT 1 FROM product_check_in_lists pcil
+ WHERE pcil.check_in_list_id = cil.id
+ AND pcil.product_id = a.product_id
+ AND pcil.deleted_at IS NULL
+ )
+ OR NOT EXISTS (
+ SELECT 1 FROM product_check_in_lists pcil
+ WHERE pcil.check_in_list_id = cil.id
+ AND pcil.deleted_at IS NULL
+ )
+ )
AND (
(es.allow_orders_awaiting_offline_payment_to_check_in = true AND a.status in ('ACTIVE', 'AWAITING_PAYMENT') AND o.status IN ('COMPLETED', 'AWAITING_OFFLINE_PAYMENT'))
OR
@@ -66,7 +90,10 @@ public function getCheckedInAttendeeCountById(int $checkInListId): CheckedInAtte
GROUP BY cil.id;
SQL;
- $query = $this->db->selectOne($sql, ['check_in_list_id' => $checkInListId]);
+ $query = $this->db->selectOne(
+ $sql,
+ array_merge(['check_in_list_id' => $checkInListId], $clause->bindings),
+ );
return new CheckedInAttendeesCountDTO(
checkInListId: $checkInListId,
@@ -75,28 +102,72 @@ public function getCheckedInAttendeeCountById(int $checkInListId): CheckedInAtte
);
}
+ /**
+ * Build the WHERE fragments and bindings that restrict stats queries to a
+ * specific event occurrence.
+ *
+ * - Override set: count only attendees/check-ins with matching event_occurrence_id.
+ * - Override null: auto-scope to the check-in list's own event_occurrence_id if
+ * set; otherwise count across all occurrences (unscoped "All occurrences" list).
+ */
+ private function buildOccurrenceFilterClauses(?int $override): object
+ {
+ if ($override !== null) {
+ return (object) [
+ 'attendeeClause' => 'AND a.event_occurrence_id = :occurrence_id',
+ 'checkInClause' => 'AND aci.event_occurrence_id = :occurrence_id',
+ 'bindings' => ['occurrence_id' => $override],
+ ];
+ }
+
+ // Auto-scope to the list's own occurrence when set. A null on the list
+ // means "All occurrences" — no row-level filter.
+ return (object) [
+ 'attendeeClause' => 'AND (cil.event_occurrence_id IS NULL OR a.event_occurrence_id = cil.event_occurrence_id)',
+ 'checkInClause' => 'AND (cil.event_occurrence_id IS NULL OR aci.event_occurrence_id = cil.event_occurrence_id)',
+ 'bindings' => [],
+ ];
+ }
+
public function getCheckedInAttendeeCountByIds(array $checkInListIds): Collection
{
$placeholders = implode(',', array_fill(0, count($checkInListIds), '?'));
+ // Bulk version: auto-scopes each list via cil.event_occurrence_id (no
+ // single override). Same "empty attachments = all tickets" rule applies.
$sql = <<buildOccurrenceFilterClauses($eventOccurrenceIdOverride);
+
+ // For the product breakdown, "empty attachments" returns a row for every
+ // product on the event.
+ $sql = <<checkInClause}
+ GROUP BY aci.attendee_id, aci.check_in_list_id
+ ),
+ valid_attendees AS (
+ SELECT a.id, a.product_id, cil.id AS check_in_list_id
+ FROM attendees a
+ JOIN orders o ON a.order_id = o.id
+ JOIN check_in_lists cil ON cil.event_id = a.event_id
+ AND cil.id = :check_in_list_id
+ AND cil.deleted_at IS NULL
+ JOIN event_settings es ON cil.event_id = es.event_id
+ WHERE a.deleted_at IS NULL
+ {$clause->attendeeClause}
+ AND (
+ EXISTS (
+ SELECT 1 FROM product_check_in_lists pcil
+ WHERE pcil.check_in_list_id = cil.id
+ AND pcil.product_id = a.product_id
+ AND pcil.deleted_at IS NULL
+ )
+ OR NOT EXISTS (
+ SELECT 1 FROM product_check_in_lists pcil
+ WHERE pcil.check_in_list_id = cil.id
+ AND pcil.deleted_at IS NULL
+ )
+ )
+ AND (
+ (es.allow_orders_awaiting_offline_payment_to_check_in = true AND a.status IN ('ACTIVE', 'AWAITING_PAYMENT') AND o.status IN ('COMPLETED', 'AWAITING_OFFLINE_PAYMENT'))
+ OR
+ (es.allow_orders_awaiting_offline_payment_to_check_in = false AND a.status = 'ACTIVE' AND o.status = 'COMPLETED')
+ )
+ )
+ SELECT
+ p.id AS product_id,
+ p.title AS product_title,
+ COUNT(va.id) AS total_attendees,
+ COUNT(DISTINCT vci.attendee_id) AS checked_in_attendees
+ FROM products p
+ JOIN check_in_lists cil ON cil.id = :check_in_list_id
+ LEFT JOIN valid_attendees va ON va.product_id = p.id
+ LEFT JOIN valid_check_ins vci ON vci.attendee_id = va.id
+ WHERE p.deleted_at IS NULL
+ AND (
+ EXISTS (
+ SELECT 1 FROM product_check_in_lists pcil
+ WHERE pcil.check_in_list_id = cil.id
+ AND pcil.product_id = p.id
+ AND pcil.deleted_at IS NULL
+ )
+ OR (
+ p.event_id = cil.event_id
+ AND NOT EXISTS (
+ SELECT 1 FROM product_check_in_lists pcil
+ WHERE pcil.check_in_list_id = cil.id
+ AND pcil.deleted_at IS NULL
+ )
+ )
+ )
+ GROUP BY p.id, p.title
+ ORDER BY p.title;
+ SQL;
+
+ $rows = $this->db->select(
+ $sql,
+ array_merge(['check_in_list_id' => $checkInListId], $clause->bindings),
+ );
+
+ return collect($rows)->map(
+ static fn($row) => new CheckInListProductStatDTO(
+ productId: (int)$row->product_id,
+ productTitle: $row->product_title,
+ totalAttendees: (int)$row->total_attendees,
+ checkedInAttendees: (int)$row->checked_in_attendees,
+ )
+ );
+ }
+
+ public function getRecentCheckInsById(
+ int $checkInListId,
+ int $limit,
+ ?int $eventOccurrenceIdOverride = null,
+ ): Collection {
+ $clause = $this->buildOccurrenceFilterClauses($eventOccurrenceIdOverride);
+
+ $sql = <<checkInClause}
+ ORDER BY aci.created_at DESC
+ LIMIT :row_limit;
+ SQL;
+
+ $rows = $this->db->select($sql, array_merge([
+ 'check_in_list_id' => $checkInListId,
+ 'row_limit' => $limit,
+ ], $clause->bindings));
+
+ return collect($rows)->map(
+ static fn($row) => new CheckInListRecentCheckInDTO(
+ attendeePublicId: $row->attendee_public_id,
+ firstName: $row->first_name ?? '',
+ lastName: $row->last_name ?? '',
+ productTitle: $row->product_title,
+ checkedInAt: (string)$row->checked_in_at,
+ )
+ );
+ }
+
public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator
{
$where = [
diff --git a/backend/app/Repository/Eloquent/EventOccurrenceDailyStatisticRepository.php b/backend/app/Repository/Eloquent/EventOccurrenceDailyStatisticRepository.php
new file mode 100644
index 0000000000..b0dea88966
--- /dev/null
+++ b/backend/app/Repository/Eloquent/EventOccurrenceDailyStatisticRepository.php
@@ -0,0 +1,23 @@
+
+ */
+class EventOccurrenceDailyStatisticRepository extends BaseRepository implements EventOccurrenceDailyStatisticRepositoryInterface
+{
+ protected function getModel(): string
+ {
+ return EventOccurrenceDailyStatistic::class;
+ }
+
+ public function getDomainObject(): string
+ {
+ return EventOccurrenceDailyStatisticDomainObject::class;
+ }
+}
diff --git a/backend/app/Repository/Eloquent/EventOccurrenceRepository.php b/backend/app/Repository/Eloquent/EventOccurrenceRepository.php
new file mode 100644
index 0000000000..dbaad29b98
--- /dev/null
+++ b/backend/app/Repository/Eloquent/EventOccurrenceRepository.php
@@ -0,0 +1,67 @@
+where('id', $id)
+ ->lockForUpdate()
+ ->first();
+
+ if ($model === null) {
+ return null;
+ }
+
+ return $this->handleSingleResult($model);
+ }
+
+ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator
+ {
+ $this->model = $this->model->newQuery()->orderBy(
+ column: $this->validateSortColumn($params->sort_by, EventOccurrenceDomainObject::class),
+ direction: $this->validateSortDirection($params->sort_direction, EventOccurrenceDomainObject::class),
+ );
+
+ if (!empty($params->filter_fields)) {
+ $this->applyFilterFields($params, EventOccurrenceDomainObject::getAllowedFilterFields());
+
+ $timePeriod = $params->filter_fields->firstWhere('field', 'time_period');
+ if ($timePeriod) {
+ $now = now()->toDateTimeString();
+ if ($timePeriod->value === 'upcoming') {
+ $this->model = $this->model->where('start_date', '>=', $now);
+ } elseif ($timePeriod->value === 'past') {
+ $this->model = $this->model->where('start_date', '<', $now);
+ }
+ }
+ }
+
+ return $this->paginateWhere(
+ where: [
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
+ ],
+ limit: $params->per_page,
+ page: $params->page,
+ );
+ }
+}
diff --git a/backend/app/Repository/Eloquent/EventOccurrenceStatisticRepository.php b/backend/app/Repository/Eloquent/EventOccurrenceStatisticRepository.php
new file mode 100644
index 0000000000..065bde84f6
--- /dev/null
+++ b/backend/app/Repository/Eloquent/EventOccurrenceStatisticRepository.php
@@ -0,0 +1,23 @@
+
+ */
+class EventOccurrenceStatisticRepository extends BaseRepository implements EventOccurrenceStatisticRepositoryInterface
+{
+ protected function getModel(): string
+ {
+ return EventOccurrenceStatistic::class;
+ }
+
+ public function getDomainObject(): string
+ {
+ return EventOccurrenceStatisticDomainObject::class;
+ }
+}
diff --git a/backend/app/Repository/Eloquent/EventRepository.php b/backend/app/Repository/Eloquent/EventRepository.php
index 51773cc94b..9dbbaacbc3 100644
--- a/backend/app/Repository/Eloquent/EventRepository.php
+++ b/backend/app/Repository/Eloquent/EventRepository.php
@@ -17,6 +17,7 @@
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
+use Illuminate\Support\Facades\DB;
/**
* @extends BaseRepository
@@ -68,9 +69,15 @@ public function findEvents(array $where, QueryParamsDTO $params): LengthAwarePag
$where[] = static function (Builder $builder) {
$builder
->where(EventDomainObjectAbstract::STATUS, '!=', EventStatus::ARCHIVED->getName())
- ->where(function ($query) {
- $query->whereNull(EventDomainObjectAbstract::END_DATE)
- ->orWhere(EventDomainObjectAbstract::END_DATE, '>=', now());
+ ->whereExists(function ($query) {
+ $query->select(DB::raw(1))
+ ->from('event_occurrences')
+ ->whereColumn('event_occurrences.event_id', 'events.id')
+ ->whereNull('event_occurrences.deleted_at')
+ ->where(function ($q) {
+ $q->whereNull('event_occurrences.end_date')
+ ->orWhere('event_occurrences.end_date', '>=', now());
+ });
});
};
@@ -100,19 +107,26 @@ public function getUpcomingEventsForAdmin(int $perPage): LengthAwarePaginator
return $this->handleResults($this->model
->select('events.*')
->with(['account', 'organizer'])
- ->where(EventDomainObjectAbstract::START_DATE, '>=', $now)
- ->where(EventDomainObjectAbstract::START_DATE, '<=', $next24Hours)
+ ->whereExists(function ($query) use ($now, $next24Hours) {
+ $query->select(DB::raw(1))
+ ->from('event_occurrences')
+ ->whereColumn('event_occurrences.event_id', 'events.id')
+ ->whereNull('event_occurrences.deleted_at')
+ ->where('event_occurrences.start_date', '>=', $now)
+ ->where('event_occurrences.start_date', '<=', $next24Hours)
+ ->where('event_occurrences.status', 'ACTIVE');
+ })
->whereIn(EventDomainObjectAbstract::STATUS, [
EventStatus::LIVE->name,
])
- ->orderBy(EventDomainObjectAbstract::START_DATE, 'asc')
+ ->orderBy(EventDomainObjectAbstract::CREATED_AT, 'desc')
->paginate($perPage));
}
public function getAllEventsForAdmin(
?string $search = null,
int $perPage = 20,
- ?string $sortBy = 'start_date',
+ ?string $sortBy = 'created_at',
?string $sortDirection = 'desc'
): LengthAwarePaginator {
$this->model = $this->model
@@ -128,8 +142,8 @@ public function getAllEventsForAdmin(
});
}
- $allowedSortColumns = ['start_date', 'end_date', 'title', 'created_at'];
- $sortColumn = in_array($sortBy, $allowedSortColumns, true) ? $sortBy : 'start_date';
+ $allowedSortColumns = ['title', 'created_at', 'updated_at'];
+ $sortColumn = in_array($sortBy, $allowedSortColumns, true) ? $sortBy : 'created_at';
$sortDir = in_array(strtolower($sortDirection), ['asc', 'desc']) ? $sortDirection : 'desc';
$this->model = $this->model->orderBy($sortColumn, $sortDir);
@@ -148,7 +162,6 @@ public function getSitemapEvents(int $page, int $perPage): LengthAwarePaginator
'events.' . EventDomainObjectAbstract::ID,
'events.' . EventDomainObjectAbstract::TITLE,
'events.' . EventDomainObjectAbstract::UPDATED_AT,
- 'events.' . EventDomainObjectAbstract::START_DATE,
])
->join('event_settings', 'events.id', '=', 'event_settings.event_id')
->where('events.' . EventDomainObjectAbstract::STATUS, EventStatus::LIVE->name)
@@ -158,6 +171,16 @@ public function getSitemapEvents(int $page, int $perPage): LengthAwarePaginator
->paginate($perPage, ['*'], 'page', $page));
}
+ public function findByIdLocked(int $id): EventDomainObject
+ {
+ $model = Event::query()
+ ->where('id', $id)
+ ->lockForUpdate()
+ ->firstOrFail();
+
+ return $this->handleSingleResult($model);
+ }
+
public function getSitemapEventCount(): int
{
return $this->model
diff --git a/backend/app/Repository/Eloquent/OrderItemRepository.php b/backend/app/Repository/Eloquent/OrderItemRepository.php
index 72384aa8b3..e188432c30 100644
--- a/backend/app/Repository/Eloquent/OrderItemRepository.php
+++ b/backend/app/Repository/Eloquent/OrderItemRepository.php
@@ -3,6 +3,7 @@
namespace HiEvents\Repository\Eloquent;
use HiEvents\DomainObjects\OrderItemDomainObject;
+use HiEvents\DomainObjects\Status\OrderStatus;
use HiEvents\Models\OrderItem;
use HiEvents\Repository\Interfaces\OrderItemRepositoryInterface;
@@ -20,4 +21,15 @@ public function getDomainObject(): string
{
return OrderItemDomainObject::class;
}
+
+ public function getReservedQuantityForOccurrence(int $occurrenceId): int
+ {
+ return (int) OrderItem::query()
+ ->join('orders', 'orders.id', '=', 'order_items.order_id')
+ ->where('order_items.event_occurrence_id', $occurrenceId)
+ ->where('orders.status', OrderStatus::RESERVED->name)
+ ->where('orders.reserved_until', '>', now())
+ ->whereNull('orders.deleted_at')
+ ->sum('order_items.quantity');
+ }
}
diff --git a/backend/app/Repository/Eloquent/OrderRepository.php b/backend/app/Repository/Eloquent/OrderRepository.php
index f33c2629ee..e25f0edb89 100644
--- a/backend/app/Repository/Eloquent/OrderRepository.php
+++ b/backend/app/Repository/Eloquent/OrderRepository.php
@@ -4,7 +4,9 @@
namespace HiEvents\Repository\Eloquent;
+use HiEvents\DomainObjects\AccountDomainObject;
use HiEvents\DomainObjects\AttendeeDomainObject;
+use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
@@ -19,8 +21,6 @@
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
-use HiEvents\DomainObjects\EventDomainObject;
-use HiEvents\DomainObjects\AccountDomainObject;
/**
* @extends BaseRepository
@@ -45,15 +45,22 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware
OrderDomainObjectAbstract::FIRST_NAME,
OrderDomainObjectAbstract::LAST_NAME
)
- ), 'ilike', '%' . $params->query . '%')
- ->orWhere(OrderDomainObjectAbstract::LAST_NAME, 'ilike', '%' . $params->query . '%')
- ->orWhere(OrderDomainObjectAbstract::PUBLIC_ID, 'ilike', '%' . $params->query . '%')
- ->orWhere(OrderDomainObjectAbstract::EMAIL, 'ilike', '%' . $params->query . '%');
+ ), 'ilike', '%'.$params->query.'%')
+ ->orWhere(OrderDomainObjectAbstract::LAST_NAME, 'ilike', '%'.$params->query.'%')
+ ->orWhere(OrderDomainObjectAbstract::PUBLIC_ID, 'ilike', '%'.$params->query.'%')
+ ->orWhere(OrderDomainObjectAbstract::EMAIL, 'ilike', '%'.$params->query.'%');
};
}
- if (!empty($params->filter_fields)) {
+ if (! empty($params->filter_fields)) {
$this->applyFilterFields($params, OrderDomainObject::getAllowedFilterFields());
+
+ $occurrenceFilter = $params->filter_fields->firstWhere('field', 'event_occurrence_id');
+ if ($occurrenceFilter) {
+ $this->model = $this->model->whereHas('order_items', function (Builder $query) use ($occurrenceFilter) {
+ $query->where('order_items.event_occurrence_id', $occurrenceFilter->value);
+ });
+ }
}
$this->model = $this->model->orderBy(
@@ -85,14 +92,14 @@ public function findByOrganizerId(int $organizerId, int $accountId, QueryParamsD
OrderDomainObjectAbstract::FIRST_NAME,
OrderDomainObjectAbstract::LAST_NAME
)
- ), 'ilike', '%' . $params->query . '%')
- ->orWhere(OrderDomainObjectAbstract::LAST_NAME, 'ilike', '%' . $params->query . '%')
- ->orWhere(OrderDomainObjectAbstract::PUBLIC_ID, 'ilike', '%' . $params->query . '%')
- ->orWhere(OrderDomainObjectAbstract::EMAIL, 'ilike', '%' . $params->query . '%');
+ ), 'ilike', '%'.$params->query.'%')
+ ->orWhere(OrderDomainObjectAbstract::LAST_NAME, 'ilike', '%'.$params->query.'%')
+ ->orWhere(OrderDomainObjectAbstract::PUBLIC_ID, 'ilike', '%'.$params->query.'%')
+ ->orWhere(OrderDomainObjectAbstract::EMAIL, 'ilike', '%'.$params->query.'%');
};
}
- if (!empty($params->filter_fields)) {
+ if (! empty($params->filter_fields)) {
$this->applyFilterFields($params, OrderDomainObject::getAllowedFilterFields());
}
@@ -104,7 +111,7 @@ public function findByOrganizerId(int $organizerId, int $accountId, QueryParamsD
$sortBy = $this->validateSortColumn($params->sort_by, OrderDomainObject::class);
$this->model = $this->model->orderBy(
- column: 'orders.' . $sortBy,
+ column: 'orders.'.$sortBy,
direction: $this->validateSortDirection($params->sort_direction, OrderDomainObject::class),
);
@@ -138,10 +145,6 @@ public function addOrderItem(array $data): OrderItemDomainObject
return $this->handleSingleResult($orderItem, OrderItemDomainObject::class);
}
- /**
- * @param string $orderShortId
- * @return OrderDomainObject|null
- */
public function findByShortId(string $orderShortId): ?OrderDomainObject
{
return $this->findFirstByField('short_id', $orderShortId);
@@ -157,24 +160,43 @@ protected function getModel(): string
return Order::class;
}
- public function findOrdersAssociatedWithProducts(int $eventId, array $productIds, array $orderStatuses): Collection
- {
- return $this->handleResults(
- $this->model
- ->whereHas('order_items', static function (Builder $query) use ($productIds) {
- $query->whereIn('product_id', $productIds);
- })
- ->whereIn('status', $orderStatuses)
- ->where('event_id', $eventId)
- ->get()
- );
+ public function findOrdersAssociatedWithProducts(
+ int $eventId,
+ array $productIds,
+ array $orderStatuses,
+ ?int $eventOccurrenceId = null,
+ ?array $eventOccurrenceIds = null,
+ ): Collection {
+ $query = $this->model
+ ->whereHas('order_items', static function (Builder $query) use ($productIds, $eventOccurrenceId, $eventOccurrenceIds) {
+ $query->whereIn('product_id', $productIds);
+ if (! empty($eventOccurrenceIds)) {
+ $query->whereIn('order_items.event_occurrence_id', $eventOccurrenceIds);
+ } elseif ($eventOccurrenceId !== null) {
+ $query->where('order_items.event_occurrence_id', $eventOccurrenceId);
+ }
+ })
+ ->whereIn('status', $orderStatuses)
+ ->where('event_id', $eventId);
+
+ return $this->handleResults($query->get());
}
- public function countOrdersAssociatedWithProducts(int $eventId, array $productIds, array $orderStatuses): int
- {
+ public function countOrdersAssociatedWithProducts(
+ int $eventId,
+ array $productIds,
+ array $orderStatuses,
+ ?int $eventOccurrenceId = null,
+ ?array $eventOccurrenceIds = null,
+ ): int {
$count = $this->model
- ->whereHas('order_items', static function (Builder $query) use ($productIds) {
+ ->whereHas('order_items', static function (Builder $query) use ($productIds, $eventOccurrenceId, $eventOccurrenceIds) {
$query->whereIn('product_id', $productIds);
+ if (! empty($eventOccurrenceIds)) {
+ $query->whereIn('order_items.event_occurrence_id', $eventOccurrenceIds);
+ } elseif ($eventOccurrenceId !== null) {
+ $query->where('order_items.event_occurrence_id', $eventOccurrenceId);
+ }
})
->whereIn('status', $orderStatuses)
->where('event_id', $eventId)
@@ -198,11 +220,11 @@ public function getAllOrdersForAdmin(
if ($search) {
$this->model = $this->model->where(function ($q) use ($search) {
- $q->where(OrderDomainObjectAbstract::EMAIL, 'ilike', '%' . $search . '%')
- ->orWhere(OrderDomainObjectAbstract::FIRST_NAME, 'ilike', '%' . $search . '%')
- ->orWhere(OrderDomainObjectAbstract::LAST_NAME, 'ilike', '%' . $search . '%')
- ->orWhere(OrderDomainObjectAbstract::PUBLIC_ID, 'ilike', '%' . $search . '%')
- ->orWhere(OrderDomainObjectAbstract::SHORT_ID, 'ilike', '%' . $search . '%');
+ $q->where(OrderDomainObjectAbstract::EMAIL, 'ilike', '%'.$search.'%')
+ ->orWhere(OrderDomainObjectAbstract::FIRST_NAME, 'ilike', '%'.$search.'%')
+ ->orWhere(OrderDomainObjectAbstract::LAST_NAME, 'ilike', '%'.$search.'%')
+ ->orWhere(OrderDomainObjectAbstract::PUBLIC_ID, 'ilike', '%'.$search.'%')
+ ->orWhere(OrderDomainObjectAbstract::SHORT_ID, 'ilike', '%'.$search.'%');
});
}
@@ -213,10 +235,10 @@ public function getAllOrdersForAdmin(
$sortColumn = in_array($sortBy, $allowedSortColumns, true) ? $sortBy : 'created_at';
$sortDir = in_array(strtolower($sortDirection), ['asc', 'desc']) ? $sortDirection : 'desc';
- $this->model = $this->model->orderBy('orders.' . $sortColumn, $sortDir);
+ $this->model = $this->model->orderBy('orders.'.$sortColumn, $sortDir);
$this->loadRelation(new Relationship(EventDomainObject::class, nested: [
- new Relationship(AccountDomainObject::class, name: 'account')
+ new Relationship(AccountDomainObject::class, name: 'account'),
], name: 'event'));
return $this->paginate($perPage);
diff --git a/backend/app/Repository/Eloquent/OrganizerConfigurationRepository.php b/backend/app/Repository/Eloquent/OrganizerConfigurationRepository.php
new file mode 100644
index 0000000000..4256634bb0
--- /dev/null
+++ b/backend/app/Repository/Eloquent/OrganizerConfigurationRepository.php
@@ -0,0 +1,23 @@
+
+ */
+class OrganizerConfigurationRepository extends BaseRepository implements OrganizerConfigurationRepositoryInterface
+{
+ protected function getModel(): string
+ {
+ return OrganizerConfiguration::class;
+ }
+
+ public function getDomainObject(): string
+ {
+ return OrganizerConfigurationDomainObject::class;
+ }
+}
diff --git a/backend/app/Repository/Eloquent/OrganizerStripePlatformRepository.php b/backend/app/Repository/Eloquent/OrganizerStripePlatformRepository.php
new file mode 100644
index 0000000000..640263e42b
--- /dev/null
+++ b/backend/app/Repository/Eloquent/OrganizerStripePlatformRepository.php
@@ -0,0 +1,47 @@
+
+ */
+class OrganizerStripePlatformRepository extends BaseRepository implements OrganizerStripePlatformRepositoryInterface
+{
+ protected function getModel(): string
+ {
+ return OrganizerStripePlatform::class;
+ }
+
+ public function getDomainObject(): string
+ {
+ return OrganizerStripePlatformDomainObject::class;
+ }
+
+ public function findReusableForAccount(int $accountId, int $excludeOrganizerId, ?string $excludeStripeAccountId): Collection
+ {
+ return $this->db->table('organizer_stripe_platforms')
+ ->join('organizers', 'organizer_stripe_platforms.organizer_id', '=', 'organizers.id')
+ ->whereNotNull('organizer_stripe_platforms.stripe_setup_completed_at')
+ ->whereNull('organizer_stripe_platforms.deleted_at')
+ ->whereNull('organizers.deleted_at')
+ ->where('organizers.account_id', $accountId)
+ ->where('organizer_stripe_platforms.organizer_id', '!=', $excludeOrganizerId)
+ ->when($excludeStripeAccountId !== null, fn($q) => $q->where('organizer_stripe_platforms.stripe_account_id', '!=', $excludeStripeAccountId))
+ ->whereNotNull('organizer_stripe_platforms.stripe_account_id')
+ ->select([
+ 'organizer_stripe_platforms.organizer_id as organizer_id',
+ 'organizer_stripe_platforms.stripe_account_id as stripe_account_id',
+ 'organizer_stripe_platforms.stripe_connect_platform as stripe_connect_platform',
+ 'organizer_stripe_platforms.stripe_account_details as stripe_account_details',
+ 'organizers.name as organizer_name',
+ ])
+ ->get();
+ }
+}
diff --git a/backend/app/Repository/Eloquent/OrganizerVatSettingRepository.php b/backend/app/Repository/Eloquent/OrganizerVatSettingRepository.php
new file mode 100644
index 0000000000..ff212dfaef
--- /dev/null
+++ b/backend/app/Repository/Eloquent/OrganizerVatSettingRepository.php
@@ -0,0 +1,28 @@
+
+ */
+class OrganizerVatSettingRepository extends BaseRepository implements OrganizerVatSettingRepositoryInterface
+{
+ protected function getModel(): string
+ {
+ return OrganizerVatSetting::class;
+ }
+
+ public function getDomainObject(): string
+ {
+ return OrganizerVatSettingDomainObject::class;
+ }
+
+ public function findByOrganizerId(int $organizerId): ?OrganizerVatSettingDomainObject
+ {
+ return $this->findFirstWhere(['organizer_id' => $organizerId]);
+ }
+}
diff --git a/backend/app/Repository/Eloquent/ProductOccurrenceVisibilityRepository.php b/backend/app/Repository/Eloquent/ProductOccurrenceVisibilityRepository.php
new file mode 100644
index 0000000000..4afe65603a
--- /dev/null
+++ b/backend/app/Repository/Eloquent/ProductOccurrenceVisibilityRepository.php
@@ -0,0 +1,20 @@
+selectRaw("
+ $query = DB::table('waitlist_entries')
+ ->selectRaw('
COUNT(*) as total,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as waiting,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as offered,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as purchased,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as cancelled,
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as expired
- ", [
+ ', [
WaitlistEntryStatus::WAITING->name,
WaitlistEntryStatus::OFFERED->name,
WaitlistEntryStatus::PURCHASED->name,
@@ -50,8 +51,11 @@ public function getStatsByEventId(int $eventId): WaitlistStatsDTO
WaitlistEntryStatus::OFFER_EXPIRED->name,
])
->where('event_id', $eventId)
- ->whereNull('deleted_at')
- ->first();
+ ->whereNull('deleted_at');
+
+ $this->applyOccurrenceScope($query, $eventOccurrenceId);
+
+ $stats = $query->first();
return new WaitlistStatsDTO(
total: (int) ($stats->total ?? 0),
@@ -63,9 +67,9 @@ public function getStatsByEventId(int $eventId): WaitlistStatsDTO
);
}
- public function getProductStatsByEventId(int $eventId): \Illuminate\Support\Collection
+ public function getProductStatsByEventId(int $eventId, ?int $eventOccurrenceId = null): \Illuminate\Support\Collection
{
- return DB::table('waitlist_entries')
+ $query = DB::table('waitlist_entries')
->join('product_prices', 'waitlist_entries.product_price_id', '=', 'product_prices.id')
->join('products', 'product_prices.product_id', '=', 'products.id')
->selectRaw("
@@ -85,41 +89,56 @@ public function getProductStatsByEventId(int $eventId): \Illuminate\Support\Coll
->whereNull('waitlist_entries.deleted_at')
->whereNull('product_prices.deleted_at')
->whereNull('products.deleted_at')
- ->groupBy('waitlist_entries.product_price_id', 'products.title', 'product_prices.label')
- ->get();
+ ->groupBy('waitlist_entries.product_price_id', 'products.title', 'product_prices.label');
+
+ $this->applyOccurrenceScope($query, $eventOccurrenceId);
+
+ return $query->get();
}
- public function getMaxPosition(int $productPriceId): int
+ public function getMaxPosition(int $productPriceId, ?int $eventOccurrenceId = null): int
{
- return (int) DB::table('waitlist_entries')
+ $query = DB::table('waitlist_entries')
->where('product_price_id', $productPriceId)
- ->whereNull('deleted_at')
- ->max('position') ?? 0;
+ ->whereNull('deleted_at');
+
+ $this->applyOccurrenceScope($query, $eventOccurrenceId);
+
+ return (int) $query->max('position') ?? 0;
}
/**
* @return \Illuminate\Support\Collection
*/
- public function getNextWaitingEntries(int $productPriceId, int $limit): \Illuminate\Support\Collection
+ public function getNextWaitingEntries(int $productPriceId, ?int $limit = null, ?int $eventOccurrenceId = null): \Illuminate\Support\Collection
{
- $models = WaitlistEntry::query()
+ $query = WaitlistEntry::query()
->where('product_price_id', $productPriceId)
->where('status', WaitlistEntryStatus::WAITING->name)
->orderBy('position')
- ->limit($limit)
- ->get();
+ ->orderBy('created_at')
+ ->orderBy('id');
+
+ $this->applyOccurrenceScope($query, $eventOccurrenceId);
+
+ if ($limit !== null) {
+ $query->limit($limit);
+ }
+
+ $models = $query->get();
return $this->handleResults($models);
}
- public function lockForProductPrice(int $productPriceId): void
+ public function lockForProductPrice(int $productPriceId, ?int $eventOccurrenceId = null): void
{
- DB::table('waitlist_entries')
+ $query = DB::table('waitlist_entries')
->where('product_price_id', $productPriceId)
- ->whereIn('status', [WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFERED->name])
- ->lockForUpdate()
- ->select('id')
- ->get();
+ ->whereIn('status', [WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFERED->name]);
+
+ $this->applyOccurrenceScope($query, $eventOccurrenceId);
+
+ $query->lockForUpdate()->select('id')->get();
}
public function findByIdLocked(int $id): ?WaitlistEntryDomainObject
@@ -145,13 +164,13 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware
if ($params->query) {
$where[] = static function (Builder $builder) use ($params) {
$builder
- ->where(WaitlistEntryDomainObjectAbstract::FIRST_NAME, 'ilike', '%' . $params->query . '%')
- ->orWhere(WaitlistEntryDomainObjectAbstract::LAST_NAME, 'ilike', '%' . $params->query . '%')
- ->orWhere(WaitlistEntryDomainObjectAbstract::EMAIL, 'ilike', '%' . $params->query . '%');
+ ->where(WaitlistEntryDomainObjectAbstract::FIRST_NAME, 'ilike', '%'.$params->query.'%')
+ ->orWhere(WaitlistEntryDomainObjectAbstract::LAST_NAME, 'ilike', '%'.$params->query.'%')
+ ->orWhere(WaitlistEntryDomainObjectAbstract::EMAIL, 'ilike', '%'.$params->query.'%');
};
}
- if (!empty($params->filter_fields)) {
+ if (! empty($params->filter_fields)) {
$this->applyFilterFields($params, WaitlistEntryDomainObject::getAllowedFilterFields());
}
@@ -161,9 +180,9 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware
);
return $this->loadRelation(new Relationship(
- domainObject: OrderDomainObject::class,
- name: OrderDomainObjectAbstract::SINGULAR_NAME,
- ))
+ domainObject: OrderDomainObject::class,
+ name: OrderDomainObjectAbstract::SINGULAR_NAME,
+ ))
->loadRelation(new Relationship(
domainObject: ProductPriceDomainObject::class,
nested: [
@@ -174,10 +193,23 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware
],
name: ProductPriceDomainObjectAbstract::SINGULAR_NAME
))
+ ->loadRelation(new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ name: 'event_occurrence',
+ ))
->paginateWhere(
where: $where,
limit: $params->per_page,
page: $params->page,
);
}
+
+ private function applyOccurrenceScope($query, ?int $eventOccurrenceId): void
+ {
+ if ($eventOccurrenceId === null) {
+ return;
+ }
+
+ $query->where('event_occurrence_id', $eventOccurrenceId);
+ }
}
diff --git a/backend/app/Repository/Eloquent/WebhookRepository.php b/backend/app/Repository/Eloquent/WebhookRepository.php
index dffca0b3b5..d7a6129c72 100644
--- a/backend/app/Repository/Eloquent/WebhookRepository.php
+++ b/backend/app/Repository/Eloquent/WebhookRepository.php
@@ -25,21 +25,21 @@ public function getDomainObject(): string
public function findEnabledByEventId(int $eventId): Collection
{
- $results = $this->model::query()
- ->where('status', WebhookStatus::ENABLED->name)
- ->where(function ($query) use ($eventId) {
- $query->where('event_id', $eventId)
- ->orWhere('organizer_id', function ($subquery) use ($eventId) {
- $subquery->select('organizer_id')
- ->from('events')
- ->where('id', $eventId)
- ->limit(1);
- });
- })
- ->get();
+ return $this->runQuery(function () use ($eventId) {
+ $results = $this->model::query()
+ ->where('status', WebhookStatus::ENABLED->name)
+ ->where(function ($query) use ($eventId) {
+ $query->where('event_id', $eventId)
+ ->orWhere('organizer_id', function ($subquery) use ($eventId) {
+ $subquery->select('organizer_id')
+ ->from('events')
+ ->where('id', $eventId)
+ ->limit(1);
+ });
+ })
+ ->get();
- $this->resetModel();
-
- return $this->handleResults($results);
+ return $this->handleResults($results);
+ });
}
}
diff --git a/backend/app/Repository/Interfaces/AccountStripePlatformRepositoryInterface.php b/backend/app/Repository/Interfaces/AccountStripePlatformRepositoryInterface.php
deleted file mode 100644
index 8276d490f0..0000000000
--- a/backend/app/Repository/Interfaces/AccountStripePlatformRepositoryInterface.php
+++ /dev/null
@@ -1,14 +0,0 @@
-
- */
-interface AccountStripePlatformRepositoryInterface extends RepositoryInterface
-{
-}
\ No newline at end of file
diff --git a/backend/app/Repository/Interfaces/AccountVatSettingRepositoryInterface.php b/backend/app/Repository/Interfaces/AccountVatSettingRepositoryInterface.php
deleted file mode 100644
index ea15229e00..0000000000
--- a/backend/app/Repository/Interfaces/AccountVatSettingRepositoryInterface.php
+++ /dev/null
@@ -1,13 +0,0 @@
-
- */
-interface AccountVatSettingRepositoryInterface extends RepositoryInterface
-{
- public function findByAccountId(int $accountId): ?AccountVatSettingDomainObject;
-}
diff --git a/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php b/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php
index e176a4ce54..1ce6e9c538 100644
--- a/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php
@@ -15,7 +15,7 @@ interface AttendeeRepositoryInterface extends RepositoryInterface
{
public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator;
- public function findByEventIdForExport(int $eventId): Collection;
+ public function findByEventIdForExport(int $eventId, ?int $eventOccurrenceId = null): Collection;
public function getAttendeesByCheckInShortId(string $shortId, QueryParamsDTO $params): Paginator;
}
diff --git a/backend/app/Repository/Interfaces/CheckInListRepositoryInterface.php b/backend/app/Repository/Interfaces/CheckInListRepositoryInterface.php
index 787e734a4c..b2c8df69d8 100644
--- a/backend/app/Repository/Interfaces/CheckInListRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/CheckInListRepositoryInterface.php
@@ -5,6 +5,8 @@
use HiEvents\DomainObjects\CheckInListDomainObject;
use HiEvents\Http\DTO\QueryParamsDTO;
use HiEvents\Repository\DTO\CheckedInAttendeesCountDTO;
+use HiEvents\Repository\DTO\CheckInListProductStatDTO;
+use HiEvents\Repository\DTO\CheckInListRecentCheckInDTO;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
@@ -15,12 +17,42 @@ interface CheckInListRepositoryInterface extends RepositoryInterface
{
public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator;
- public function getCheckedInAttendeeCountById(int $checkInListId): CheckedInAttendeesCountDTO;
+ /**
+ * Counts attendees + check-ins for a check-in list. Auto-scopes to the
+ * list's own event_occurrence_id when set; callers can override via
+ * $eventOccurrenceIdOverride (e.g. when an "All occurrences" list is
+ * further narrowed by the client's filter pill).
+ */
+ public function getCheckedInAttendeeCountById(
+ int $checkInListId,
+ ?int $eventOccurrenceIdOverride = null,
+ ): CheckedInAttendeesCountDTO;
/**
+ * Bulk counterpart used by the admin lists view. Auto-scopes each list
+ * to its own event_occurrence_id — no override because each row has
+ * independent scope.
+ *
* @param array $checkInListIds
*
* @return Collection
*/
public function getCheckedInAttendeeCountByIds(array $checkInListIds): Collection;
+
+ /**
+ * @return Collection
+ */
+ public function getPerProductCheckInStatsById(
+ int $checkInListId,
+ ?int $eventOccurrenceIdOverride = null,
+ ): Collection;
+
+ /**
+ * @return Collection
+ */
+ public function getRecentCheckInsById(
+ int $checkInListId,
+ int $limit,
+ ?int $eventOccurrenceIdOverride = null,
+ ): Collection;
}
diff --git a/backend/app/Repository/Interfaces/EventOccurrenceDailyStatisticRepositoryInterface.php b/backend/app/Repository/Interfaces/EventOccurrenceDailyStatisticRepositoryInterface.php
new file mode 100644
index 0000000000..98cd9f8338
--- /dev/null
+++ b/backend/app/Repository/Interfaces/EventOccurrenceDailyStatisticRepositoryInterface.php
@@ -0,0 +1,13 @@
+
+ */
+interface EventOccurrenceDailyStatisticRepositoryInterface extends RepositoryInterface
+{
+
+}
diff --git a/backend/app/Repository/Interfaces/EventOccurrenceRepositoryInterface.php b/backend/app/Repository/Interfaces/EventOccurrenceRepositoryInterface.php
new file mode 100644
index 0000000000..175fce0b4e
--- /dev/null
+++ b/backend/app/Repository/Interfaces/EventOccurrenceRepositoryInterface.php
@@ -0,0 +1,22 @@
+
+ */
+interface EventOccurrenceRepositoryInterface extends RepositoryInterface
+{
+ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator;
+
+ /**
+ * Acquires a row-level lock on the occurrence (SELECT ... FOR UPDATE) so callers can
+ * safely read-then-update without losing concurrent state changes. Must be called
+ * inside a database transaction.
+ */
+ public function findByIdLocked(int $id): ?EventOccurrenceDomainObject;
+}
diff --git a/backend/app/Repository/Interfaces/EventOccurrenceStatisticRepositoryInterface.php b/backend/app/Repository/Interfaces/EventOccurrenceStatisticRepositoryInterface.php
new file mode 100644
index 0000000000..1d6e2464df
--- /dev/null
+++ b/backend/app/Repository/Interfaces/EventOccurrenceStatisticRepositoryInterface.php
@@ -0,0 +1,13 @@
+
+ */
+interface EventOccurrenceStatisticRepositoryInterface extends RepositoryInterface
+{
+
+}
diff --git a/backend/app/Repository/Interfaces/EventRepositoryInterface.php b/backend/app/Repository/Interfaces/EventRepositoryInterface.php
index 7f04c4277c..0486a79424 100644
--- a/backend/app/Repository/Interfaces/EventRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/EventRepositoryInterface.php
@@ -29,4 +29,6 @@ public function getAllEventsForAdmin(
public function getSitemapEvents(int $page, int $perPage): LengthAwarePaginator;
public function getSitemapEventCount(): int;
+
+ public function findByIdLocked(int $id): EventDomainObject;
}
diff --git a/backend/app/Repository/Interfaces/OrderItemRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderItemRepositoryInterface.php
index 6360d893e9..1d47427ca5 100644
--- a/backend/app/Repository/Interfaces/OrderItemRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/OrderItemRepositoryInterface.php
@@ -9,4 +9,10 @@
*/
interface OrderItemRepositoryInterface extends RepositoryInterface
{
+ /**
+ * Returns the total quantity reserved (orders in RESERVED status, still within their
+ * reservation window) against a specific event occurrence. Used by checkout capacity
+ * validation to subtract pending reservations from the available pool.
+ */
+ public function getReservedQuantityForOccurrence(int $occurrenceId): int;
}
diff --git a/backend/app/Repository/Interfaces/OrderRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderRepositoryInterface.php
index 58cf2c7a8e..dbcf3f61ae 100644
--- a/backend/app/Repository/Interfaces/OrderRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/OrderRepositoryInterface.php
@@ -27,9 +27,21 @@ public function addOrderItem(array $data): OrderItemDomainObject;
public function findByShortId(string $orderShortId): ?OrderDomainObject;
- public function findOrdersAssociatedWithProducts(int $eventId, array $productIds, array $orderStatuses): Collection;
-
- public function countOrdersAssociatedWithProducts(int $eventId, array $productIds, array $orderStatuses): int;
+ public function findOrdersAssociatedWithProducts(
+ int $eventId,
+ array $productIds,
+ array $orderStatuses,
+ ?int $eventOccurrenceId = null,
+ ?array $eventOccurrenceIds = null,
+ ): Collection;
+
+ public function countOrdersAssociatedWithProducts(
+ int $eventId,
+ array $productIds,
+ array $orderStatuses,
+ ?int $eventOccurrenceId = null,
+ ?array $eventOccurrenceIds = null,
+ ): int;
public function getAllOrdersForAdmin(
?string $search = null,
diff --git a/backend/app/Repository/Interfaces/OrganizerConfigurationRepositoryInterface.php b/backend/app/Repository/Interfaces/OrganizerConfigurationRepositoryInterface.php
new file mode 100644
index 0000000000..e4c89acfaa
--- /dev/null
+++ b/backend/app/Repository/Interfaces/OrganizerConfigurationRepositoryInterface.php
@@ -0,0 +1,12 @@
+
+ */
+interface OrganizerConfigurationRepositoryInterface extends RepositoryInterface
+{
+}
diff --git a/backend/app/Repository/Interfaces/OrganizerStripePlatformRepositoryInterface.php b/backend/app/Repository/Interfaces/OrganizerStripePlatformRepositoryInterface.php
new file mode 100644
index 0000000000..f6277e15e0
--- /dev/null
+++ b/backend/app/Repository/Interfaces/OrganizerStripePlatformRepositoryInterface.php
@@ -0,0 +1,22 @@
+
+ */
+interface OrganizerStripePlatformRepositoryInterface extends RepositoryInterface
+{
+ /**
+ * List completed Stripe Connect rows in $accountId, excluding $excludeOrganizerId
+ * and the currently-active Stripe account (so the FE can offer "reuse this connection").
+ *
+ * @return Collection
+ */
+ public function findReusableForAccount(int $accountId, int $excludeOrganizerId, ?string $excludeStripeAccountId): Collection;
+}
diff --git a/backend/app/Repository/Interfaces/OrganizerVatSettingRepositoryInterface.php b/backend/app/Repository/Interfaces/OrganizerVatSettingRepositoryInterface.php
new file mode 100644
index 0000000000..dcc001532c
--- /dev/null
+++ b/backend/app/Repository/Interfaces/OrganizerVatSettingRepositoryInterface.php
@@ -0,0 +1,13 @@
+
+ */
+interface OrganizerVatSettingRepositoryInterface extends RepositoryInterface
+{
+ public function findByOrganizerId(int $organizerId): ?OrganizerVatSettingDomainObject;
+}
diff --git a/backend/app/Repository/Interfaces/ProductOccurrenceVisibilityRepositoryInterface.php b/backend/app/Repository/Interfaces/ProductOccurrenceVisibilityRepositoryInterface.php
new file mode 100644
index 0000000000..1004c36330
--- /dev/null
+++ b/backend/app/Repository/Interfaces/ProductOccurrenceVisibilityRepositoryInterface.php
@@ -0,0 +1,12 @@
+
+ */
+interface ProductOccurrenceVisibilityRepositoryInterface extends RepositoryInterface
+{
+}
diff --git a/backend/app/Repository/Interfaces/ProductPriceOccurrenceOverrideRepositoryInterface.php b/backend/app/Repository/Interfaces/ProductPriceOccurrenceOverrideRepositoryInterface.php
new file mode 100644
index 0000000000..dfecd69121
--- /dev/null
+++ b/backend/app/Repository/Interfaces/ProductPriceOccurrenceOverrideRepositoryInterface.php
@@ -0,0 +1,12 @@
+
+ */
+interface ProductPriceOccurrenceOverrideRepositoryInterface extends RepositoryInterface
+{
+}
diff --git a/backend/app/Repository/Interfaces/RepositoryInterface.php b/backend/app/Repository/Interfaces/RepositoryInterface.php
index e6157bf732..f4727e5d31 100644
--- a/backend/app/Repository/Interfaces/RepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/RepositoryInterface.php
@@ -36,75 +36,57 @@ interface RepositoryInterface
public function getDomainObject(): string;
/**
- * @param array $columns
* @return Collection
*/
public function all(array $columns = self::DEFAULT_COLUMNS): Collection;
/**
- * @param int $limit
- * @param array $columns
* @return LengthAwarePaginator
*/
public function paginate(
- int $limit = self::DEFAULT_PAGINATE_LIMIT,
+ int $limit = self::DEFAULT_PAGINATE_LIMIT,
array $columns = self::DEFAULT_COLUMNS
): LengthAwarePaginator;
/**
- * @param array $where
- * @param int $limit
- * @param array $columns
* @return LengthAwarePaginator
*/
public function paginateWhere(
array $where,
- int $limit = self::DEFAULT_PAGINATE_LIMIT,
+ int $limit = self::DEFAULT_PAGINATE_LIMIT,
array $columns = self::DEFAULT_COLUMNS
): LengthAwarePaginator;
/**
- * @param array $where
- * @param int|null $limit
- * @param array $columns
* @return LengthAwarePaginator
*/
public function simplePaginateWhere(
array $where,
- ?int $limit = null,
+ ?int $limit = null,
array $columns = self::DEFAULT_COLUMNS,
): Paginator;
/**
- * @param Relation $relation
- * @param int $limit
- * @param array $columns
* @return LengthAwarePaginator
*/
public function paginateEloquentRelation(
Relation $relation,
- int $limit = self::DEFAULT_PAGINATE_LIMIT,
- array $columns = self::DEFAULT_COLUMNS
+ int $limit = self::DEFAULT_PAGINATE_LIMIT,
+ array $columns = self::DEFAULT_COLUMNS
): LengthAwarePaginator;
/**
- * @param int $id
- * @param array $columns
* @return T
*/
public function findById(int $id, array $columns = self::DEFAULT_COLUMNS): DomainObjectInterface;
/**
- * @param int $id
- * @param array $columns
* @return T|null
*/
public function findFirst(int $id, array $columns = self::DEFAULT_COLUMNS): ?DomainObjectInterface;
/**
- * @param array $where
- * @param array $columns
- * @param OrderAndDirection[] $orderAndDirections
+ * @param OrderAndDirection[] $orderAndDirections
* @return Collection
*/
public function findWhere(
@@ -112,74 +94,53 @@ public function findWhere(
array $columns = self::DEFAULT_COLUMNS,
/** @var OrderAndDirection[] */
array $orderAndDirections = [],
+ ?int $limit = null,
): Collection;
/**
- * @param array $where
- * @param array $columns
* @return T|null
*/
public function findFirstWhere(array $where, array $columns = self::DEFAULT_COLUMNS): ?DomainObjectInterface;
/**
- * @param string $field
- * @param string|null $value
- * @param array $columns
* @return T|null
*/
public function findFirstByField(
- string $field,
+ string $field,
?string $value = null,
- array $columns = ['*']
+ array $columns = ['*']
): ?DomainObjectInterface;
/**
- * @param string $field
- * @param array $values
- * @param array $additionalWhere
- * @param array $columns
* @return Collection
+ *
* @throws Exception
*/
public function findWhereIn(string $field, array $values, array $additionalWhere = [], array $columns = self::DEFAULT_COLUMNS): Collection;
/**
- * @param array $attributes
* @return T
*/
public function create(array $attributes): DomainObjectInterface;
- /**
- * @param array $inserts
- * @return bool
- */
public function insert(array $inserts): bool;
/**
- * @param int $id
- * @param DomainObjectInterface $domainObject
* @return T
*/
public function updateFromDomainObject(int $id, DomainObjectInterface $domainObject): DomainObjectInterface;
/**
- * @param int $id
- * @param array $attributes
* @return T
*/
public function updateFromArray(int $id, array $attributes): DomainObjectInterface;
/**
- * @param array $attributes
- * @param array $where
* @return int Number of affected rows
*/
public function updateWhere(array $attributes, array $where): int;
/**
- * @param int $id
- * @param array $attributes
- * @param array $where
* @return T
*/
public function updateByIdWhere(int $id, array $attributes, array $where): DomainObjectInterface;
diff --git a/backend/app/Repository/Interfaces/WaitlistEntryRepositoryInterface.php b/backend/app/Repository/Interfaces/WaitlistEntryRepositoryInterface.php
index 99d6901c78..4f73b5f185 100644
--- a/backend/app/Repository/Interfaces/WaitlistEntryRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/WaitlistEntryRepositoryInterface.php
@@ -15,18 +15,18 @@ interface WaitlistEntryRepositoryInterface extends RepositoryInterface
{
public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator;
- public function getStatsByEventId(int $eventId): WaitlistStatsDTO;
+ public function getStatsByEventId(int $eventId, ?int $eventOccurrenceId = null): WaitlistStatsDTO;
- public function getProductStatsByEventId(int $eventId): Collection;
+ public function getProductStatsByEventId(int $eventId, ?int $eventOccurrenceId = null): Collection;
- public function getMaxPosition(int $productPriceId): int;
+ public function getMaxPosition(int $productPriceId, ?int $eventOccurrenceId = null): int;
/**
* @return Collection
*/
- public function getNextWaitingEntries(int $productPriceId, int $limit): Collection;
+ public function getNextWaitingEntries(int $productPriceId, ?int $limit = null, ?int $eventOccurrenceId = null): Collection;
- public function lockForProductPrice(int $productPriceId): void;
+ public function lockForProductPrice(int $productPriceId, ?int $eventOccurrenceId = null): void;
public function findByIdLocked(int $id): ?WaitlistEntryDomainObject;
}
diff --git a/backend/app/Resources/Account/AccountResource.php b/backend/app/Resources/Account/AccountResource.php
index dcc55f687f..10ea4cd3a5 100644
--- a/backend/app/Resources/Account/AccountResource.php
+++ b/backend/app/Resources/Account/AccountResource.php
@@ -13,9 +13,6 @@ class AccountResource extends JsonResource
{
public function toArray(Request $request): array
{
- $activeStripePlatform = $this->getPrimaryStripePlatform();
- $isHiEvents = config('app.is_hi_events', false);
-
return [
'id' => $this->getId(),
'name' => $this->getName(),
@@ -25,20 +22,6 @@ public function toArray(Request $request): array
'is_account_email_confirmed' => $this->getAccountVerifiedAt() !== null,
'is_saas_mode_enabled' => config('app.saas_mode_enabled'),
-
- $this->mergeWhen(config('app.saas_mode_enabled') && $activeStripePlatform, fn() => [
- 'stripe_account_id' => $activeStripePlatform->getStripeAccountId(),
- 'stripe_connect_setup_complete' => $activeStripePlatform->getStripeSetupCompletedAt() !== null,
- 'stripe_account_details' => $activeStripePlatform->getStripeAccountDetails(),
- 'stripe_platform' => $this->getActiveStripePlatform()?->value,
- ]),
- $this->mergeWhen($isHiEvents, fn() => [
- 'stripe_hi_events_primary_platform' => config('services.stripe.primary_platform')
- ]),
-
- $this->mergeWhen($this->getConfiguration() !== null, fn() => [
- 'configuration' => new AccountConfigurationResource($this->getConfiguration()),
- ]),
'requires_manual_verification' => config('app.saas_mode_enabled') && !$this->getIsManuallyVerified(),
];
}
diff --git a/backend/app/Resources/Account/AdminAccountDetailResource.php b/backend/app/Resources/Account/AdminAccountDetailResource.php
index 6e0b8cb9f4..c9f13da29d 100644
--- a/backend/app/Resources/Account/AdminAccountDetailResource.php
+++ b/backend/app/Resources/Account/AdminAccountDetailResource.php
@@ -15,9 +15,6 @@ class AdminAccountDetailResource extends BaseResource
{
public function toArray(Request $request): array
{
- $configuration = $this->resource->configuration;
- $vatSetting = $this->resource->account_vat_setting;
-
return [
'id' => $this->resource->id,
'name' => $this->resource->name,
@@ -28,25 +25,38 @@ public function toArray(Request $request): array
'updated_at' => $this->resource->updated_at,
'events_count' => $this->resource->events_count ?? 0,
'users_count' => $this->resource->users_count ?? 0,
- 'configuration' => $configuration ? [
- 'id' => $configuration->id,
- 'name' => $configuration->name,
- 'is_system_default' => $configuration->is_system_default,
- 'application_fees' => $configuration->application_fees ?? [
- 'percentage' => 0,
- 'fixed' => 0,
- ],
- ] : null,
- 'vat_setting' => $vatSetting ? [
- 'id' => $vatSetting->id,
- 'vat_registered' => $vatSetting->vat_registered,
- 'vat_number' => $vatSetting->vat_number,
- 'vat_validated' => $vatSetting->vat_validated,
- 'vat_validation_date' => $vatSetting->vat_validation_date,
- 'business_name' => $vatSetting->business_name,
- 'business_address' => $vatSetting->business_address,
- 'vat_country_code' => $vatSetting->vat_country_code,
- ] : null,
+ 'organizers' => $this->resource->organizers
+ ? $this->resource->organizers->map(function ($organizer) {
+ $configuration = $organizer->organizer_configuration;
+ $vatSetting = $organizer->organizer_vat_setting;
+
+ return [
+ 'id' => $organizer->id,
+ 'name' => $organizer->name,
+ 'configuration' => $configuration ? [
+ 'id' => $configuration->id,
+ 'name' => $configuration->name,
+ 'is_system_default' => $configuration->is_system_default,
+ 'application_fees' => $configuration->application_fees ?? [
+ 'percentage' => 0,
+ 'fixed' => 0,
+ ],
+ 'bypass_application_fees' => (bool)($configuration->bypass_application_fees ?? false),
+ ] : null,
+ 'vat_setting' => $vatSetting ? [
+ 'id' => $vatSetting->id,
+ 'vat_registered' => (bool)$vatSetting->vat_registered,
+ 'vat_number' => $vatSetting->vat_number,
+ 'vat_validated' => (bool)$vatSetting->vat_validated,
+ 'vat_validation_status' => $vatSetting->vat_validation_status,
+ 'vat_validation_date' => $vatSetting->vat_validation_date,
+ 'business_name' => $vatSetting->business_name,
+ 'business_address' => $vatSetting->business_address,
+ 'vat_country_code' => $vatSetting->vat_country_code,
+ ] : null,
+ ];
+ })->values()
+ : [],
'users' => $this->resource->users->map(function ($user) {
return [
'id' => $user->id,
diff --git a/backend/app/Resources/Account/Stripe/StripeConnectAccountsResponseResource.php b/backend/app/Resources/Account/Stripe/StripeConnectAccountsResponseResource.php
deleted file mode 100644
index e816112267..0000000000
--- a/backend/app/Resources/Account/Stripe/StripeConnectAccountsResponseResource.php
+++ /dev/null
@@ -1,37 +0,0 @@
- [
- 'id' => $this->account->getId(),
- 'stripe_platform' => $this->account->getActiveStripePlatform()?->value,
- ],
- 'stripe_connect_accounts' => $this->stripeConnectAccounts->map(function (StripeConnectAccountDTO $account) {
- return [
- 'stripe_account_id' => $account->stripeAccountId,
- 'connect_url' => $account->connectUrl,
- 'is_setup_complete' => $account->isSetupComplete,
- 'platform' => $account->platform?->value,
- 'account_type' => $account->accountType,
- 'is_primary' => $account->isPrimary,
- 'country' => $account->country,
- ];
- })->toArray(),
- 'primary_stripe_account_id' => $this->primaryStripeAccountId,
- 'has_completed_setup' => $this->hasCompletedSetup,
- ];
- }
-}
diff --git a/backend/app/Resources/Attendee/AttendeeDetailPublicResource.php b/backend/app/Resources/Attendee/AttendeeDetailPublicResource.php
new file mode 100644
index 0000000000..67bb26ffab
--- /dev/null
+++ b/backend/app/Resources/Attendee/AttendeeDetailPublicResource.php
@@ -0,0 +1,82 @@
+attendee;
+ $order = $attendee->getOrder();
+ $product = $attendee->getProduct();
+
+ $occurrence = $attendee->getEventOccurrence();
+
+ $data = [
+ 'id' => $attendee->getId(),
+ 'public_id' => $attendee->getPublicId(),
+ 'first_name' => $attendee->getFirstName(),
+ 'last_name' => $attendee->getLastName(),
+ 'email' => $attendee->getEmail(),
+ 'status' => $attendee->getStatus(),
+ 'product_id' => $attendee->getProductId(),
+ 'product_title' => $product?->getTitle(),
+ 'event_occurrence' => $occurrence
+ ? (new EventOccurrenceResourcePublic($occurrence))->toArray(request())
+ : null,
+ 'check_ins' => $this->currentListCheckIns
+ ->map(static fn(AttendeeCheckInDomainObject $checkIn) => (new AttendeeCheckInPublicResource($checkIn))->toArray(request()))
+ ->values()
+ ->all(),
+ 'visibility' => [
+ 'notes' => $this->showNotes,
+ 'question_answers' => $this->showQuestionAnswers,
+ 'order_details' => $this->showOrderDetails,
+ ],
+ ];
+
+ if ($this->showNotes) {
+ $data['notes'] = $attendee->getNotes();
+ }
+
+ if ($this->showQuestionAnswers) {
+ $data['question_answers'] = array_map(
+ static fn(QuestionAndAnswerViewDomainObject $qa) => [
+ 'question_id' => $qa->getQuestionId(),
+ 'title' => $qa->getTitle(),
+ 'answer' => $qa->getAnswer(),
+ 'belongs_to' => $qa->getBelongsTo(),
+ ],
+ $attendee->getQuestionAndAnswerViews()?->all() ?? [],
+ );
+ }
+
+ if ($this->showOrderDetails && $order) {
+ $data['order'] = [
+ 'id' => $order->getId(),
+ 'public_id' => $order->getPublicId(),
+ 'short_id' => $order->getShortId(),
+ 'status' => $order->getStatus(),
+ 'total_gross' => $order->getTotalGross(),
+ 'currency' => $order->getCurrency(),
+ 'first_name' => $order->getFirstName(),
+ 'last_name' => $order->getLastName(),
+ 'email' => $order->getEmail(),
+ 'created_at' => $order->getCreatedAt(),
+ ];
+ }
+
+ return $data;
+ }
+}
diff --git a/backend/app/Resources/Attendee/AttendeeResource.php b/backend/app/Resources/Attendee/AttendeeResource.php
index 81d2eadbe7..e527765855 100644
--- a/backend/app/Resources/Attendee/AttendeeResource.php
+++ b/backend/app/Resources/Attendee/AttendeeResource.php
@@ -5,6 +5,7 @@
use HiEvents\DomainObjects\AttendeeDomainObject;
use HiEvents\DomainObjects\Enums\QuestionBelongsTo;
use HiEvents\Resources\CheckInList\AttendeeCheckInResource;
+use HiEvents\Resources\EventOccurrence\EventOccurrenceResource;
use HiEvents\Resources\Order\OrderResource;
use HiEvents\Resources\Question\QuestionAnswerViewResource;
use HiEvents\Resources\Product\ProductResource;
@@ -30,8 +31,13 @@ public function toArray(Request $request): array
'last_name' => $this->getLastName(),
'public_id' => $this->getPublicId(),
'short_id' => $this->getShortId(),
+ 'event_occurrence_id' => $this->getEventOccurrenceId(),
'locale' => $this->getLocale(),
'notes' => $this->getNotes(),
+ 'event_occurrence' => $this->when(
+ !is_null($this->getEventOccurrence()),
+ fn() => new EventOccurrenceResource($this->getEventOccurrence()),
+ ),
'product' => $this->when(
!is_null($this->getProduct()),
fn() => new ProductResource($this->getProduct()),
diff --git a/backend/app/Resources/Attendee/AttendeeResourcePublic.php b/backend/app/Resources/Attendee/AttendeeResourcePublic.php
index 1f4ffc1a36..93ef4168cb 100644
--- a/backend/app/Resources/Attendee/AttendeeResourcePublic.php
+++ b/backend/app/Resources/Attendee/AttendeeResourcePublic.php
@@ -3,6 +3,7 @@
namespace HiEvents\Resources\Attendee;
use HiEvents\DomainObjects\AttendeeDomainObject;
+use HiEvents\Resources\EventOccurrence\EventOccurrenceResourcePublic;
use HiEvents\Resources\Product\ProductMinimalResourcePublic;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -25,6 +26,11 @@ public function toArray(Request $request): array
'product_id' => $this->getProductId(),
'product_price_id' => $this->getProductPriceId(),
'product' => $this->when((bool)$this->getProduct(), fn() => new ProductMinimalResourcePublic($this->getProduct())),
+ 'event_occurrence_id' => $this->getEventOccurrenceId(),
+ 'event_occurrence' => $this->when(
+ (bool)$this->getEventOccurrence(),
+ fn() => new EventOccurrenceResourcePublic($this->getEventOccurrence()),
+ ),
'locale' => $this->getLocale(),
];
}
diff --git a/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php b/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php
index 198bf3f1fe..5be3567bdd 100644
--- a/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php
+++ b/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php
@@ -4,6 +4,7 @@
use HiEvents\DomainObjects\AttendeeDomainObject;
use HiEvents\Resources\CheckInList\AttendeeCheckInPublicResource;
+use HiEvents\Resources\EventOccurrence\EventOccurrenceResourcePublic;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -25,6 +26,10 @@ public function toArray(Request $request): array
'status' => $this->getStatus(),
'locale' => $this->getLocale(),
'order_id' => $this->getOrderId(),
+ 'event_occurrence_id' => $this->getEventOccurrenceId(),
+ 'event_occurrence' => $this->getEventOccurrence()
+ ? (new EventOccurrenceResourcePublic($this->getEventOccurrence()))->toArray($request)
+ : null,
$this->mergeWhen($this->getCheckIn() !== null, [
'check_in' => new AttendeeCheckInPublicResource($this->getCheckIn()),
]),
diff --git a/backend/app/Resources/CheckInList/AttendeeCheckInPublicResource.php b/backend/app/Resources/CheckInList/AttendeeCheckInPublicResource.php
index 8ae9dba8b8..c6c6b1c80e 100644
--- a/backend/app/Resources/CheckInList/AttendeeCheckInPublicResource.php
+++ b/backend/app/Resources/CheckInList/AttendeeCheckInPublicResource.php
@@ -19,6 +19,7 @@ public function toArray($request): array
'attendee_id' => $this->getAttendeeId(),
'checked_in_at' => $this->getCreatedAt(),
'order_id' => $this->getOrderId(),
+ 'event_occurrence_id' => $this->getEventOccurrenceId(),
];
}
}
diff --git a/backend/app/Resources/CheckInList/AttendeeCheckInResource.php b/backend/app/Resources/CheckInList/AttendeeCheckInResource.php
index e9630ba17b..3c5732fdf8 100644
--- a/backend/app/Resources/CheckInList/AttendeeCheckInResource.php
+++ b/backend/app/Resources/CheckInList/AttendeeCheckInResource.php
@@ -19,6 +19,7 @@ public function toArray($request): array
'check_in_list_id' => $this->getCheckInListId(),
'product_id' => $this->getProductId(),
'event_id' => $this->getEventId(),
+ 'event_occurrence_id' => $this->getEventOccurrenceId(),
'short_id' => $this->getShortId(),
'created_at' => $this->getCreatedAt(),
'check_in_list' => $this->when(
diff --git a/backend/app/Resources/CheckInList/CheckInListResource.php b/backend/app/Resources/CheckInList/CheckInListResource.php
index 744f947c70..ec928dae21 100644
--- a/backend/app/Resources/CheckInList/CheckInListResource.php
+++ b/backend/app/Resources/CheckInList/CheckInListResource.php
@@ -3,6 +3,7 @@
namespace HiEvents\Resources\CheckInList;
use HiEvents\DomainObjects\CheckInListDomainObject;
+use HiEvents\Resources\EventOccurrence\EventOccurrenceResource;
use HiEvents\Resources\Product\ProductResource;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -20,12 +21,21 @@ public function toArray($request): array
'expires_at' => $this->getExpiresAt(),
'activates_at' => $this->getActivatesAt(),
'short_id' => $this->getShortId(),
+ 'is_system_default' => $this->getIsSystemDefault(),
+ 'event_occurrence_id' => $this->getEventOccurrenceId(),
'total_attendees' => $this->getTotalAttendeesCount(),
'checked_in_attendees' => $this->getCheckedInCount(),
+ 'public_show_attendee_notes' => $this->getPublicShowAttendeeNotes(),
+ 'public_show_question_answers' => $this->getPublicShowQuestionAnswers(),
+ 'public_show_order_details' => $this->getPublicShowOrderDetails(),
$this->mergeWhen($this->getEvent() !== null, fn() => [
'is_expired' => $this->isExpired($this->getEvent()->getTimezone()),
'is_active' => $this->isActivated($this->getEvent()->getTimezone()),
]),
+ 'event_occurrence' => $this->when(
+ !is_null($this->getEventOccurrence()),
+ fn() => new EventOccurrenceResource($this->getEventOccurrence()),
+ ),
$this->mergeWhen($this->getProducts() !== null, fn() => [
'products' => ProductResource::collection($this->getProducts()),
]),
diff --git a/backend/app/Resources/CheckInList/CheckInListResourcePublic.php b/backend/app/Resources/CheckInList/CheckInListResourcePublic.php
index 7135da87e4..e4676f1389 100644
--- a/backend/app/Resources/CheckInList/CheckInListResourcePublic.php
+++ b/backend/app/Resources/CheckInList/CheckInListResourcePublic.php
@@ -3,7 +3,9 @@
namespace HiEvents\Resources\CheckInList;
use HiEvents\DomainObjects\CheckInListDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\Resources\Event\EventResourcePublic;
+use HiEvents\Resources\EventOccurrence\EventOccurrenceResourcePublic;
use HiEvents\Resources\Product\ProductMinimalResourcePublic;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -18,15 +20,34 @@ public function toArray($request): array
'id' => $this->getId(),
'short_id' => $this->getShortId(),
'name' => $this->getName(),
+ 'is_system_default' => $this->getIsSystemDefault(),
+ 'event_occurrence_id' => $this->getEventOccurrenceId(),
+ 'event_occurrence' => $this->getEventOccurrence()
+ ? (new EventOccurrenceResourcePublic($this->getEventOccurrence()))->toArray($request)
+ : null,
'description' => $this->getDescription(),
'expires_at' => $this->getExpiresAt(),
'activates_at' => $this->getActivatesAt(),
'total_attendees' => $this->getTotalAttendeesCount(),
'checked_in_attendees' => $this->getCheckedInCount(),
+ 'public_show_attendee_notes' => $this->getPublicShowAttendeeNotes(),
+ 'public_show_question_answers' => $this->getPublicShowQuestionAnswers(),
+ 'public_show_order_details' => $this->getPublicShowOrderDetails(),
$this->mergeWhen($this->getEvent() !== null, fn() => [
'is_expired' => $this->isExpired($this->getEvent()->getTimezone()),
'is_active' => $this->isActivated($this->getEvent()->getTimezone()),
'event' => EventResourcePublic::make($this->getEvent()),
+ // Unfiltered list (still excludes cancelled) so the staff filter
+ // pill can show past sessions for reconciliation. EventResourcePublic
+ // filters to future/active which is wrong for the check-in UI.
+ 'event_occurrences' => $this->getEvent()->getEventOccurrences()
+ ? EventOccurrenceResourcePublic::collection(
+ $this->getEvent()->getEventOccurrences()
+ ->filter(fn(EventOccurrenceDomainObject $occ) => !$occ->isCancelled())
+ ->sortBy(fn(EventOccurrenceDomainObject $occ) => $occ->getStartDate())
+ ->values()
+ )
+ : [],
]),
$this->mergeWhen($this->getProducts() !== null, fn() => [
'products' => ProductMinimalResourcePublic::collection($this->getProducts()),
diff --git a/backend/app/Resources/CheckInList/CheckInListStatsPublicResource.php b/backend/app/Resources/CheckInList/CheckInListStatsPublicResource.php
new file mode 100644
index 0000000000..ab760f9e57
--- /dev/null
+++ b/backend/app/Resources/CheckInList/CheckInListStatsPublicResource.php
@@ -0,0 +1,41 @@
+ $this->totalAttendees,
+ 'checked_in_attendees' => $this->checkedInAttendees,
+ 'per_product' => array_map(
+ static fn(CheckInListProductStatDTO $stat) => [
+ 'product_id' => $stat->productId,
+ 'product_title' => $stat->productTitle,
+ 'total_attendees' => $stat->totalAttendees,
+ 'checked_in_attendees' => $stat->checkedInAttendees,
+ ],
+ $this->perProduct,
+ ),
+ 'recent_check_ins' => array_map(
+ static fn(CheckInListRecentCheckInDTO $checkIn) => [
+ 'attendee_public_id' => $checkIn->attendeePublicId,
+ 'first_name' => $checkIn->firstName,
+ 'last_name' => $checkIn->lastName,
+ 'product_title' => $checkIn->productTitle,
+ 'checked_in_at' => $checkIn->checkedInAt,
+ ],
+ $this->recentCheckIns,
+ ),
+ ];
+ }
+}
diff --git a/backend/app/Resources/Event/EventResource.php b/backend/app/Resources/Event/EventResource.php
index 5b68ba7bfd..0273185ddd 100644
--- a/backend/app/Resources/Event/EventResource.php
+++ b/backend/app/Resources/Event/EventResource.php
@@ -4,6 +4,7 @@
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\Resources\BaseResource;
+use HiEvents\Resources\EventOccurrence\EventOccurrenceResource;
use HiEvents\Resources\Image\ImageResource;
use HiEvents\Resources\Organizer\OrganizerResource;
use HiEvents\Resources\Product\ProductResource;
@@ -24,7 +25,10 @@ public function toArray(Request $request): array
'description' => $this->getDescription(),
'start_date' => $this->getStartDate(),
'end_date' => $this->getEndDate(),
+ 'next_occurrence_start_date' => $this->getNextOccurrenceStartDate(),
'status' => $this->getStatus(),
+ 'type' => $this->getType(),
+ 'recurrence_rule' => $this->getRecurrenceRule(),
'lifecycle_status' => $this->getLifeCycleStatus(),
'currency' => $this->getCurrency(),
'timezone' => $this->getTimezone(),
@@ -52,6 +56,10 @@ public function toArray(Request $request): array
condition: !is_null($this->getEventStatistics()),
value: fn() => new EventStatisticsResource($this->getEventStatistics())
),
+ 'occurrences' => $this->when(
+ condition: !is_null($this->getEventOccurrences()) && $this->getEventOccurrences()->isNotEmpty(),
+ value: fn() => EventOccurrenceResource::collection($this->getEventOccurrences()),
+ ),
];
}
}
diff --git a/backend/app/Resources/Event/EventResourcePublic.php b/backend/app/Resources/Event/EventResourcePublic.php
index da969e58f5..8c078c30c0 100644
--- a/backend/app/Resources/Event/EventResourcePublic.php
+++ b/backend/app/Resources/Event/EventResourcePublic.php
@@ -2,8 +2,11 @@
namespace HiEvents\Resources\Event;
+use HiEvents\DomainObjects\Enums\EventType;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\Resources\BaseResource;
+use HiEvents\Resources\EventOccurrence\EventOccurrenceResourcePublic;
use HiEvents\Resources\Image\ImageResource;
use HiEvents\Resources\Organizer\OrganizerResourcePublic;
use HiEvents\Resources\ProductCategory\ProductCategoryResourcePublic;
@@ -20,8 +23,7 @@ class EventResourcePublic extends BaseResource
public function __construct(
mixed $resource,
mixed $includePostCheckoutData = false,
- )
- {
+ ) {
// This is a hacky workaround to handle when this resource is instantiated
// internally within Laravel the second param is the collection key (numeric)
// When called normally, second param is includePostCheckoutData (boolean)
@@ -34,6 +36,8 @@ public function __construct(
public function toArray(Request $request): array
{
+ $isRecurring = $this->getType() === EventType::RECURRING->name;
+
return [
'id' => $this->getId(),
'title' => $this->getTitle(),
@@ -42,38 +46,55 @@ public function toArray(Request $request): array
'description_preview' => $this->getDescriptionPreview(),
'start_date' => $this->getStartDate(),
'end_date' => $this->getEndDate(),
+ 'next_occurrence_start_date' => $this->getNextOccurrenceStartDate(),
+ 'type' => $this->getType(),
'currency' => $this->getCurrency(),
'slug' => $this->getSlug(),
'status' => $this->getStatus(),
'lifecycle_status' => $this->getLifecycleStatus(),
'timezone' => $this->getTimezone(),
- 'location_details' => $this->when((bool)$this->getLocationDetails(), fn() => $this->getLocationDetails()),
+ 'location_details' => $this->when((bool) $this->getLocationDetails(), fn () => $this->getLocationDetails()),
'product_categories' => $this->when(
- condition: !is_null($this->getProductCategories()) && $this->getProductCategories()->isNotEmpty(),
- value: fn() => ProductCategoryResourcePublic::collection($this->getProductCategories()),
+ condition: ! is_null($this->getProductCategories()) && $this->getProductCategories()->isNotEmpty(),
+ value: fn () => ProductCategoryResourcePublic::collection($this->getProductCategories()),
),
'settings' => $this->when(
- condition: !is_null($this->getEventSettings()),
- value: fn() => new EventSettingsResourcePublic(
+ condition: ! is_null($this->getEventSettings()),
+ value: fn () => new EventSettingsResourcePublic(
$this->getEventSettings(),
$this->includePostCheckoutData
),
),
// @TODO - public question resource
'questions' => $this->when(
- condition: !is_null($this->getQuestions()),
- value: fn() => QuestionResource::collection($this->getQuestions())
+ condition: ! is_null($this->getQuestions()),
+ value: fn () => QuestionResource::collection($this->getQuestions())
),
'attributes' => $this->when(
- condition: !is_null($this->getAttributes()),
- value: fn() => collect($this->getAttributes())->reject(fn($attribute) => !$attribute['is_public'])),
+ condition: ! is_null($this->getAttributes()),
+ value: fn () => collect($this->getAttributes())->reject(fn ($attribute) => ! $attribute['is_public'])),
'images' => $this->when(
- condition: !is_null($this->getImages()),
- value: fn() => ImageResource::collection($this->getImages())
+ condition: ! is_null($this->getImages()),
+ value: fn () => ImageResource::collection($this->getImages())
),
'organizer' => $this->when(
- condition: !is_null($this->getOrganizer()),
- value: fn() => new OrganizerResourcePublic($this->getOrganizer()),
+ condition: ! is_null($this->getOrganizer()),
+ value: fn () => new OrganizerResourcePublic($this->getOrganizer()),
+ ),
+ 'occurrences' => $this->when(
+ condition: ! is_null($this->getEventOccurrences()) && $this->getEventOccurrences()->isNotEmpty(),
+ // Cap is enforced by GetPublicEventHandler before assignment so
+ // the requested occurrence (which the handler explicitly pushes
+ // even when its position is past the cap) survives to the
+ // payload. Re-capping here re-sorted by start_date and dropped
+ // it again, breaking shared/checkout links to occurrences past
+ // the first 200 upcoming.
+ value: fn () => EventOccurrenceResourcePublic::collection(
+ $this->getEventOccurrences()
+ ->filter(fn (EventOccurrenceDomainObject $occ) => ! $occ->isCancelled() && (! $isRecurring || ! $occ->isPast()))
+ ->sortBy(fn (EventOccurrenceDomainObject $occ) => $occ->getStartDate())
+ ->values()
+ ),
),
];
}
diff --git a/backend/app/Resources/EventOccurrence/EventOccurrenceResource.php b/backend/app/Resources/EventOccurrence/EventOccurrenceResource.php
new file mode 100644
index 0000000000..abb0bcbcd3
--- /dev/null
+++ b/backend/app/Resources/EventOccurrence/EventOccurrenceResource.php
@@ -0,0 +1,46 @@
+getEventOccurrenceStatistics();
+
+ return [
+ 'id' => $this->getId(),
+ 'event_id' => $this->getEventId(),
+ 'short_id' => $this->getShortId(),
+ 'start_date' => $this->getStartDate(),
+ 'end_date' => $this->getEndDate(),
+ 'status' => $this->getStatus(),
+ 'capacity' => $this->getCapacity(),
+ 'used_capacity' => $this->getUsedCapacity(),
+ 'available_capacity' => $this->getAvailableCapacity(),
+ 'label' => $this->getLabel(),
+ 'is_overridden' => $this->getIsOverridden(),
+ 'is_past' => $this->isPast(),
+ 'is_future' => $this->isFuture(),
+ 'is_active' => $this->isActive(),
+ 'statistics' => $this->when($stats !== null, fn() => [
+ 'total_gross_sales' => $stats->getSalesTotalGross() ?? 0,
+ 'total_tax' => $stats->getTotalTax() ?? 0,
+ 'total_fee' => $stats->getTotalFee() ?? 0,
+ 'orders_created' => $stats->getOrdersCreated() ?? 0,
+ 'total_refunded' => $stats->getTotalRefunded() ?? 0,
+ 'attendees_registered' => $stats->getAttendeesRegistered() ?? 0,
+ 'products_sold' => $stats->getProductsSold() ?? 0,
+ ]),
+ 'created_at' => $this->getCreatedAt(),
+ 'updated_at' => $this->getUpdatedAt(),
+ ];
+ }
+}
diff --git a/backend/app/Resources/EventOccurrence/EventOccurrenceResourcePublic.php b/backend/app/Resources/EventOccurrence/EventOccurrenceResourcePublic.php
new file mode 100644
index 0000000000..de0671cab4
--- /dev/null
+++ b/backend/app/Resources/EventOccurrence/EventOccurrenceResourcePublic.php
@@ -0,0 +1,31 @@
+ $this->getId(),
+ 'event_id' => $this->getEventId(),
+ 'short_id' => $this->getShortId(),
+ 'start_date' => $this->getStartDate(),
+ 'end_date' => $this->getEndDate(),
+ 'status' => $this->getStatus(),
+ 'capacity' => $this->getCapacity(),
+ 'available_capacity' => $this->getAvailableCapacity(),
+ 'label' => $this->getLabel(),
+ 'is_past' => $this->isPast(),
+ 'is_future' => $this->isFuture(),
+ 'is_active' => $this->isActive(),
+ ];
+ }
+}
diff --git a/backend/app/Resources/EventOccurrence/ProductOccurrenceVisibilityResource.php b/backend/app/Resources/EventOccurrence/ProductOccurrenceVisibilityResource.php
new file mode 100644
index 0000000000..e31b27b448
--- /dev/null
+++ b/backend/app/Resources/EventOccurrence/ProductOccurrenceVisibilityResource.php
@@ -0,0 +1,23 @@
+ $this->getId(),
+ 'event_occurrence_id' => $this->getEventOccurrenceId(),
+ 'product_id' => $this->getProductId(),
+ 'created_at' => $this->getCreatedAt(),
+ ];
+ }
+}
diff --git a/backend/app/Resources/EventOccurrence/ProductPriceOccurrenceOverrideResource.php b/backend/app/Resources/EventOccurrence/ProductPriceOccurrenceOverrideResource.php
new file mode 100644
index 0000000000..d096d6334b
--- /dev/null
+++ b/backend/app/Resources/EventOccurrence/ProductPriceOccurrenceOverrideResource.php
@@ -0,0 +1,25 @@
+ $this->getId(),
+ 'event_occurrence_id' => $this->getEventOccurrenceId(),
+ 'product_price_id' => $this->getProductPriceId(),
+ 'price' => $this->getPrice(),
+ 'created_at' => $this->getCreatedAt(),
+ 'updated_at' => $this->getUpdatedAt(),
+ ];
+ }
+}
diff --git a/backend/app/Resources/Order/OrderItemResource.php b/backend/app/Resources/Order/OrderItemResource.php
index 3199ca498b..e1b3ce4ac4 100644
--- a/backend/app/Resources/Order/OrderItemResource.php
+++ b/backend/app/Resources/Order/OrderItemResource.php
@@ -4,6 +4,7 @@
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\Resources\BaseResource;
+use HiEvents\Resources\EventOccurrence\EventOccurrenceResource;
use Illuminate\Http\Request;
/**
@@ -20,9 +21,14 @@ public function toArray(Request $request): array
'price' => $this->getPrice(),
'quantity' => $this->getQuantity(),
'product_id' => $this->getProductId(),
+ 'event_occurrence_id' => $this->getEventOccurrenceId(),
'item_name' => $this->getItemName(),
'price_before_discount' => $this->getPriceBeforeDiscount(),
'taxes_and_fees_rollup' => $this->getTaxesAndFeesRollup(),
+ 'event_occurrence' => $this->when(
+ !is_null($this->getEventOccurrence()),
+ fn() => new EventOccurrenceResource($this->getEventOccurrence()),
+ ),
];
}
}
diff --git a/backend/app/Resources/Order/OrderItemResourcePublic.php b/backend/app/Resources/Order/OrderItemResourcePublic.php
index 18ff026120..f7c90c4551 100644
--- a/backend/app/Resources/Order/OrderItemResourcePublic.php
+++ b/backend/app/Resources/Order/OrderItemResourcePublic.php
@@ -4,6 +4,7 @@
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\Resources\BaseResource;
+use HiEvents\Resources\EventOccurrence\EventOccurrenceResourcePublic;
use HiEvents\Resources\Product\ProductResourcePublic;
use Illuminate\Http\Request;
@@ -29,6 +30,11 @@ public function toArray(Request $request): array
'total_tax' => $this->getTotalTax(),
'total_gross' => $this->getTotalGross(),
'taxes_and_fees_rollup' => $this->getTaxesAndFeesRollup(),
+ 'event_occurrence_id' => $this->getEventOccurrenceId(),
+ 'event_occurrence' => $this->when(
+ !is_null($this->getEventOccurrence()),
+ fn() => new EventOccurrenceResourcePublic($this->getEventOccurrence()),
+ ),
'product' => $this->when((bool)$this->getProduct(), fn() => new ProductResourcePublic($this->getProduct())),
];
}
diff --git a/backend/app/Resources/Account/AccountConfigurationResource.php b/backend/app/Resources/Organizer/OrganizerConfigurationResource.php
similarity index 75%
rename from backend/app/Resources/Account/AccountConfigurationResource.php
rename to backend/app/Resources/Organizer/OrganizerConfigurationResource.php
index 548477f479..4521c10c89 100644
--- a/backend/app/Resources/Account/AccountConfigurationResource.php
+++ b/backend/app/Resources/Organizer/OrganizerConfigurationResource.php
@@ -1,14 +1,14 @@
getOrganizerSettings()),
value: fn() => new OrganizerSettingsResource($this->getOrganizerSettings())
),
+ $this->mergeWhen(
+ config('app.saas_mode_enabled') && $this->getOrganizerStripePlatforms() !== null,
+ fn() => [
+ 'stripe_connect_setup_complete' => $this->isStripeSetupComplete(),
+ 'stripe_account_id' => $this->getActiveStripeAccountId(),
+ ],
+ ),
+ $this->mergeWhen(
+ $this->getOrganizerConfiguration() !== null,
+ fn() => [
+ 'configuration' => new OrganizerConfigurationResource($this->getOrganizerConfiguration()),
+ ],
+ ),
];
}
}
diff --git a/backend/app/Resources/Account/Stripe/StripeConnectAccountResponseResource.php b/backend/app/Resources/Organizer/Stripe/OrganizerStripeConnectAccountResponseResource.php
similarity index 56%
rename from backend/app/Resources/Account/Stripe/StripeConnectAccountResponseResource.php
rename to backend/app/Resources/Organizer/Stripe/OrganizerStripeConnectAccountResponseResource.php
index 9eb5411da4..a9cc9dfb62 100644
--- a/backend/app/Resources/Account/Stripe/StripeConnectAccountResponseResource.php
+++ b/backend/app/Resources/Organizer/Stripe/OrganizerStripeConnectAccountResponseResource.php
@@ -1,15 +1,15 @@
$this->stripeAccountId,
'is_connect_setup_complete' => $this->isConnectSetupComplete,
'connect_url' => $this->connectUrl,
- 'account' => new AccountResource($this->account),
+ 'organizer' => new OrganizerResource($this->organizer),
];
}
}
diff --git a/backend/app/Resources/Organizer/Stripe/OrganizerStripeConnectAccountsResponseResource.php b/backend/app/Resources/Organizer/Stripe/OrganizerStripeConnectAccountsResponseResource.php
new file mode 100644
index 0000000000..a27c14a530
--- /dev/null
+++ b/backend/app/Resources/Organizer/Stripe/OrganizerStripeConnectAccountsResponseResource.php
@@ -0,0 +1,54 @@
+ [
+ 'id' => $this->organizer->getId(),
+ 'name' => $this->organizer->getName(),
+ 'stripe_platform' => $this->organizer->getActiveStripePlatform()?->value,
+ ],
+ 'stripe_connect_accounts' => $this->stripeConnectAccounts->map(function (StripeConnectAccountDTO $account) {
+ return [
+ 'stripe_account_id' => $account->stripeAccountId,
+ 'connect_url' => $account->connectUrl,
+ 'is_setup_complete' => $account->isSetupComplete,
+ 'platform' => $account->platform?->value,
+ 'account_type' => $account->accountType,
+ 'is_primary' => $account->isPrimary,
+ 'country' => $account->country,
+ 'business_type' => $account->businessType,
+ 'charges_enabled' => $account->chargesEnabled,
+ 'payouts_enabled' => $account->payoutsEnabled,
+ 'capabilities' => $account->capabilities,
+ 'requirements' => $account->requirements,
+ ];
+ })->values()->toArray(),
+ 'reusable_connections' => $this->reusableConnections->map(function (ReusableStripeConnectionDTO $connection) {
+ return [
+ 'organizer_id' => $connection->organizerId,
+ 'organizer_name' => $connection->organizerName,
+ 'stripe_account_id' => $connection->stripeAccountId,
+ 'platform' => $connection->platform,
+ 'country' => $connection->country,
+ 'business_type' => $connection->businessType,
+ ];
+ })->values()->toArray(),
+ 'primary_stripe_account_id' => $this->primaryStripeAccountId,
+ 'has_completed_setup' => $this->hasCompletedSetup,
+ ];
+ }
+}
diff --git a/backend/app/Resources/Account/AccountVatSettingResource.php b/backend/app/Resources/Organizer/Vat/OrganizerVatSettingResource.php
similarity index 78%
rename from backend/app/Resources/Account/AccountVatSettingResource.php
rename to backend/app/Resources/Organizer/Vat/OrganizerVatSettingResource.php
index c6901bbf9e..aea3d9a8a7 100644
--- a/backend/app/Resources/Account/AccountVatSettingResource.php
+++ b/backend/app/Resources/Organizer/Vat/OrganizerVatSettingResource.php
@@ -2,21 +2,21 @@
declare(strict_types=1);
-namespace HiEvents\Resources\Account;
+namespace HiEvents\Resources\Organizer\Vat;
-use HiEvents\DomainObjects\AccountVatSettingDomainObject;
+use HiEvents\DomainObjects\OrganizerVatSettingDomainObject;
use HiEvents\Resources\BaseResource;
/**
- * @mixin AccountVatSettingDomainObject
+ * @mixin OrganizerVatSettingDomainObject
*/
-class AccountVatSettingResource extends BaseResource
+class OrganizerVatSettingResource extends BaseResource
{
public function toArray($request): array
{
return [
'id' => $this->getId(),
- 'account_id' => $this->getAccountId(),
+ 'organizer_id' => $this->getOrganizerId(),
'vat_registered' => $this->getVatRegistered(),
'vat_number' => $this->getVatNumber(),
'vat_validated' => $this->getVatValidated(),
diff --git a/backend/app/Resources/Waitlist/WaitlistEntryResource.php b/backend/app/Resources/Waitlist/WaitlistEntryResource.php
index a2ea76a20b..071a50a08e 100644
--- a/backend/app/Resources/Waitlist/WaitlistEntryResource.php
+++ b/backend/app/Resources/Waitlist/WaitlistEntryResource.php
@@ -4,6 +4,7 @@
use HiEvents\DomainObjects\WaitlistEntryDomainObject;
use HiEvents\Resources\BaseResource;
+use HiEvents\Resources\EventOccurrence\EventOccurrenceResource;
use HiEvents\Resources\Product\ProductPriceResource;
use HiEvents\Resources\Product\ProductResource;
use Illuminate\Http\Request;
@@ -19,6 +20,7 @@ public function toArray(Request $request): array
'id' => $this->getId(),
'event_id' => $this->getEventId(),
'product_price_id' => $this->getProductPriceId(),
+ 'event_occurrence_id' => $this->getEventOccurrenceId(),
'email' => $this->getEmail(),
'first_name' => $this->getFirstName(),
'last_name' => $this->getLastName(),
@@ -36,6 +38,9 @@ public function toArray(Request $request): array
'product_price' => $this->getProductPrice()
? new ProductPriceResource($this->getProductPrice())
: null,
+ 'event_occurrence' => $this->getEventOccurrence()
+ ? new EventOccurrenceResource($this->getEventOccurrence())
+ : null,
'created_at' => $this->getCreatedAt(),
'updated_at' => $this->getUpdatedAt(),
];
diff --git a/backend/app/Services/Application/Handlers/Account/Payment/Stripe/DTO/CreateStripeConnectAccountResponse.php b/backend/app/Services/Application/Handlers/Account/Payment/Stripe/DTO/CreateStripeConnectAccountResponse.php
deleted file mode 100644
index 9e0b800c72..0000000000
--- a/backend/app/Services/Application/Handlers/Account/Payment/Stripe/DTO/CreateStripeConnectAccountResponse.php
+++ /dev/null
@@ -1,19 +0,0 @@
-accountRepository
- ->loadRelation(AccountStripePlatformDomainObject::class)
- ->findById($accountId);
-
- $stripeConnectAccounts = $this->getStripeConnectAccounts($account);
- $primaryStripeAccountId = $account->getActiveStripeAccountId();
- $hasCompletedSetup = $account->isStripeSetupComplete();
-
- return new GetStripeConnectAccountsResponseDTO(
- account: $account,
- stripeConnectAccounts: $stripeConnectAccounts,
- primaryStripeAccountId: $primaryStripeAccountId,
- hasCompletedSetup: $hasCompletedSetup,
- );
- }
-
- private function getStripeConnectAccounts(AccountDomainObject $account): Collection
- {
- $stripeAccounts = collect();
- $stripePlatforms = $account->getAccountStripePlatforms();
-
- if (!$stripePlatforms || $stripePlatforms->isEmpty()) {
- return $stripeAccounts;
- }
-
- foreach ($stripePlatforms as $stripePlatform) {
- $stripeAccount = $this->getStripeAccount($stripePlatform);
- if ($stripeAccount) {
- $stripeAccounts->push($stripeAccount);
- }
- }
-
- return $stripeAccounts;
- }
-
- private function getStripeAccount(AccountStripePlatformDomainObject $stripePlatform): ?StripeConnectAccountDTO
- {
- if (!$stripePlatform->getStripeAccountId()) {
- return null;
- }
-
- try {
- $platform = $stripePlatform->getStripeConnectPlatform()
- ? StripePlatform::fromString($stripePlatform->getStripeConnectPlatform())
- : null;
-
- $stripeClient = $this->stripeClientFactory->createForPlatform($platform);
- $stripeAccount = $stripeClient->accounts->retrieve($stripePlatform->getStripeAccountId());
-
- $isSetupComplete = $this->stripeAccountSyncService->isStripeAccountComplete($stripeAccount);
- $connectUrl = null;
-
- // Check if Stripe says setup is complete but our DB doesn't reflect it
- if ($isSetupComplete && $stripePlatform->getStripeSetupCompletedAt() === null) {
- $this->stripeAccountSyncService->markAccountAsComplete($stripePlatform, $stripeAccount);
- }
-
- // Generate connect URL if setup is not complete
- if (!$isSetupComplete) {
- $connectUrl = $this->stripeAccountSyncService->createStripeAccountSetupUrl($stripeAccount, $stripeClient);
- }
-
- return new StripeConnectAccountDTO(
- stripeAccountId: $stripeAccount->id,
- connectUrl: $connectUrl,
- isSetupComplete: $isSetupComplete,
- platform: $platform,
- accountType: $stripeAccount->type,
- isPrimary: $stripePlatform->getStripeSetupCompletedAt() !== null,
- country: is_array($stripePlatform->getStripeAccountDetails()) ? ($stripePlatform->getStripeAccountDetails()['country'] ?? null) : null,
- );
- } catch (StripeClientConfigurationException $e) {
- $this->logger->warning('Failed to retrieve Stripe account due to configuration issue', [
- 'stripe_account_id' => $stripePlatform->getStripeAccountId(),
- 'platform' => $stripePlatform->getStripeConnectPlatform(),
- 'error' => $e->getMessage(),
- ]);
- return null;
- } catch (Throwable $e) {
- $this->logger->error('Failed to retrieve Stripe account', [
- 'stripe_account_id' => $stripePlatform->getStripeAccountId(),
- 'platform' => $stripePlatform->getStripeConnectPlatform(),
- 'error' => $e->getMessage(),
- ]);
- return null;
- }
- }
-}
diff --git a/backend/app/Services/Application/Handlers/Account/Vat/DTO/UpsertAccountVatSettingDTO.php b/backend/app/Services/Application/Handlers/Account/Vat/DTO/UpsertAccountVatSettingDTO.php
deleted file mode 100644
index 3c6168f66f..0000000000
--- a/backend/app/Services/Application/Handlers/Account/Vat/DTO/UpsertAccountVatSettingDTO.php
+++ /dev/null
@@ -1,15 +0,0 @@
-vatSettingRepository->findByAccountId($accountId);
- }
-}
diff --git a/backend/app/Services/Application/Handlers/Admin/AssignConfigurationHandler.php b/backend/app/Services/Application/Handlers/Admin/AssignConfigurationHandler.php
deleted file mode 100644
index 3706b92d12..0000000000
--- a/backend/app/Services/Application/Handlers/Admin/AssignConfigurationHandler.php
+++ /dev/null
@@ -1,31 +0,0 @@
-configurationRepository->findById($configurationId);
-
- $this->accountRepository->updateFromArray(
- id: $accountId,
- attributes: ['account_configuration_id' => $configurationId]
- );
- }
-}
diff --git a/backend/app/Services/Application/Handlers/Admin/DeleteConfigurationHandler.php b/backend/app/Services/Application/Handlers/Admin/DeleteConfigurationHandler.php
index 77822d298f..88ab63bf75 100644
--- a/backend/app/Services/Application/Handlers/Admin/DeleteConfigurationHandler.php
+++ b/backend/app/Services/Application/Handlers/Admin/DeleteConfigurationHandler.php
@@ -5,12 +5,12 @@
namespace HiEvents\Services\Application\Handlers\Admin;
use HiEvents\Exceptions\CannotDeleteEntityException;
-use HiEvents\Repository\Interfaces\AccountConfigurationRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrganizerConfigurationRepositoryInterface;
class DeleteConfigurationHandler
{
public function __construct(
- private readonly AccountConfigurationRepositoryInterface $repository,
+ private readonly OrganizerConfigurationRepositoryInterface $repository,
) {
}
diff --git a/backend/app/Services/Application/Handlers/Admin/GetAdminDashboardDataHandler.php b/backend/app/Services/Application/Handlers/Admin/GetAdminDashboardDataHandler.php
index 591838b14a..be8ac5936a 100644
--- a/backend/app/Services/Application/Handlers/Admin/GetAdminDashboardDataHandler.php
+++ b/backend/app/Services/Application/Handlers/Admin/GetAdminDashboardDataHandler.php
@@ -125,13 +125,23 @@ private function getTopOrganizers(Carbon $since, int $limit): array
private function getRecentAccounts(int $limit): array
{
+ // stripe_connect_setup_complete is computed from organizer_stripe_platforms —
+ // any organizer in the account with completed setup counts as connected.
$query = <<configurationRepository->findById($configurationId);
+
+ $this->organizerRepository->updateFromArray(
+ id: $organizerId,
+ attributes: ['organizer_configuration_id' => $configurationId],
+ );
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/Admin/UpdateAdminAccountVatSettingHandler.php b/backend/app/Services/Application/Handlers/Admin/Organizer/UpdateAdminOrganizerVatSettingHandler.php
similarity index 51%
rename from backend/app/Services/Application/Handlers/Admin/UpdateAdminAccountVatSettingHandler.php
rename to backend/app/Services/Application/Handlers/Admin/Organizer/UpdateAdminOrganizerVatSettingHandler.php
index 4971856bf8..aff5ad7806 100644
--- a/backend/app/Services/Application/Handlers/Admin/UpdateAdminAccountVatSettingHandler.php
+++ b/backend/app/Services/Application/Handlers/Admin/Organizer/UpdateAdminOrganizerVatSettingHandler.php
@@ -1,25 +1,25 @@
vatSettingRepository->findByAccountId($dto->accountId);
+ $existing = $this->vatSettingRepository->findByOrganizerId($dto->organizerId);
$data = [
- 'account_id' => $dto->accountId,
+ 'organizer_id' => $dto->organizerId,
'vat_registered' => $dto->vatRegistered,
'vat_number' => $dto->vatNumber,
'vat_validated' => $dto->vatValidated ?? false,
diff --git a/backend/app/Services/Application/Handlers/Admin/Organizer/UpdateOrganizerConfigurationHandler.php b/backend/app/Services/Application/Handlers/Admin/Organizer/UpdateOrganizerConfigurationHandler.php
new file mode 100644
index 0000000000..38c84f6d18
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Admin/Organizer/UpdateOrganizerConfigurationHandler.php
@@ -0,0 +1,76 @@
+organizerRepository
+ ->loadRelation(new Relationship(
+ domainObject: OrganizerConfigurationDomainObject::class,
+ name: 'organizer_configuration',
+ ))
+ ->findById($dto->organizerId);
+
+ $currentConfiguration = $organizer->getOrganizerConfiguration();
+
+ // Update in place only when the configuration is dedicated to this organizer.
+ // The system default and any backfilled/shared row may be referenced by sibling
+ // organizers, so mutating it would silently change their fees too.
+ if ($currentConfiguration !== null
+ && !$currentConfiguration->getIsSystemDefault()
+ && $this->isConfigurationDedicatedTo($currentConfiguration->getId(), $organizer->getId())
+ ) {
+ return $this->configurationRepository->updateFromArray(
+ id: $currentConfiguration->getId(),
+ attributes: ['application_fees' => $dto->applicationFees],
+ );
+ }
+
+ $configuration = $this->configurationRepository->create([
+ 'name' => sprintf('%s (#%d) - Custom Fees', $organizer->getName(), $organizer->getId()),
+ 'is_system_default' => false,
+ 'application_fees' => $dto->applicationFees,
+ ]);
+
+ $this->organizerRepository->updateFromArray(
+ id: $organizer->getId(),
+ attributes: ['organizer_configuration_id' => $configuration->getId()],
+ );
+
+ return $configuration;
+ }
+
+ private function isConfigurationDedicatedTo(int $configurationId, int $organizerId): bool
+ {
+ $totalReferences = $this->organizerRepository->countWhere([
+ 'organizer_configuration_id' => $configurationId,
+ ]);
+
+ if ($totalReferences !== 1) {
+ return false;
+ }
+
+ // The single reference must be the organizer we're editing.
+ $ownReference = $this->organizerRepository->countWhere([
+ 'organizer_configuration_id' => $configurationId,
+ 'id' => $organizerId,
+ ]);
+
+ return $ownReference === 1;
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/Admin/UpdateAccountConfigurationHandler.php b/backend/app/Services/Application/Handlers/Admin/UpdateAccountConfigurationHandler.php
deleted file mode 100644
index d398dd5cb2..0000000000
--- a/backend/app/Services/Application/Handlers/Admin/UpdateAccountConfigurationHandler.php
+++ /dev/null
@@ -1,49 +0,0 @@
-accountRepository
- ->loadRelation('configuration')
- ->findById($dto->accountId);
-
- $data = [
- 'application_fees' => $dto->applicationFees,
- ];
-
- if ($account->getConfiguration()) {
- return $this->configurationRepository->updateFromArray(
- id: $account->getConfiguration()->getId(),
- attributes: $data
- );
- }
-
- $configuration = $this->configurationRepository->create([
- 'name' => 'Account Configuration',
- 'is_system_default' => false,
- 'application_fees' => $dto->applicationFees,
- ]);
-
- $this->accountRepository->updateFromArray(
- id: $account->getId(),
- attributes: ['account_configuration_id' => $configuration->getId()]
- );
-
- return $configuration;
- }
-}
diff --git a/backend/app/Services/Application/Handlers/Attendee/CreateAttendeeHandler.php b/backend/app/Services/Application/Handlers/Attendee/CreateAttendeeHandler.php
index 4ca0d841a0..d36b3297d5 100644
--- a/backend/app/Services/Application/Handlers/Attendee/CreateAttendeeHandler.php
+++ b/backend/app/Services/Application/Handlers/Attendee/CreateAttendeeHandler.php
@@ -4,6 +4,7 @@
use Brick\Money\Money;
use HiEvents\DomainObjects\AttendeeDomainObject;
+use HiEvents\DomainObjects\Enums\EventType;
use HiEvents\DomainObjects\Enums\ProductType;
use HiEvents\DomainObjects\Generated\AttendeeDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract;
@@ -21,39 +22,43 @@
use HiEvents\Exceptions\NoTicketsAvailableException;
use HiEvents\Helper\IdHelper;
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
use HiEvents\Repository\Interfaces\TaxAndFeeRepositoryInterface;
use HiEvents\Services\Application\Handlers\Attendee\DTO\CreateAttendeeDTO;
use HiEvents\Services\Application\Handlers\Attendee\DTO\CreateAttendeeTaxAndFeeDTO;
+use HiEvents\Services\Domain\EventOccurrence\OccurrencePurchaseEligibilityService;
use HiEvents\Services\Domain\Order\OrderManagementService;
use HiEvents\Services\Domain\Product\ProductQuantityUpdateService;
+use HiEvents\Services\Domain\SelfService\OrderAuditLogService;
use HiEvents\Services\Domain\Tax\TaxAndFeeRollupService;
use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService;
use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType;
use HiEvents\Services\Infrastructure\DomainEvents\Events\OrderEvent;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Collection;
-use RuntimeException;
+use Illuminate\Validation\ValidationException;
use Throwable;
class CreateAttendeeHandler
{
public function __construct(
- private readonly AttendeeRepositoryInterface $attendeeRepository,
- private readonly OrderRepositoryInterface $orderRepository,
- private readonly ProductRepositoryInterface $productRepository,
- private readonly EventRepositoryInterface $eventRepository,
+ private readonly AttendeeRepositoryInterface $attendeeRepository,
+ private readonly OrderRepositoryInterface $orderRepository,
+ private readonly ProductRepositoryInterface $productRepository,
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly EventOccurrenceRepositoryInterface $eventOccurrenceRepository,
private readonly ProductQuantityUpdateService $productQuantityAdjustmentService,
- private readonly DatabaseManager $databaseManager,
+ private readonly DatabaseManager $databaseManager,
private readonly TaxAndFeeRepositoryInterface $taxAndFeeRepository,
- private readonly TaxAndFeeRollupService $taxAndFeeRollupService,
- private readonly OrderManagementService $orderManagementService,
+ private readonly TaxAndFeeRollupService $taxAndFeeRollupService,
+ private readonly OrderManagementService $orderManagementService,
private readonly DomainEventDispatcherService $domainEventDispatcherService,
- )
- {
- }
+ private readonly OccurrencePurchaseEligibilityService $occurrenceEligibilityService,
+ private readonly OrderAuditLogService $orderAuditLogService,
+ ) {}
/**
* @throws NoTicketsAvailableException
@@ -61,6 +66,24 @@ public function __construct(
*/
public function handle(CreateAttendeeDTO $attendeeDTO): AttendeeDomainObject
{
+ $attendeeDTO = $this->resolveOccurrenceId($attendeeDTO);
+
+ // Same eligibility checks the public checkout runs — manual creation
+ // previously bypassed every one of these, letting organisers issue
+ // tickets against cancelled or sold-out occurrences and ignore product
+ // visibility rules. The override_capacity flag opts out of the capacity
+ // check only and is audited below.
+ $this->occurrenceEligibilityService->assertOccurrencePurchasable(
+ eventId: $attendeeDTO->event_id,
+ occurrenceId: $attendeeDTO->event_occurrence_id,
+ additionalQuantity: 1,
+ overrideCapacity: $attendeeDTO->override_capacity,
+ );
+ $this->occurrenceEligibilityService->assertProductsVisibleOnOccurrence(
+ $attendeeDTO->event_occurrence_id,
+ [$attendeeDTO->product_id],
+ );
+
return $this->databaseManager->transaction(function () use ($attendeeDTO) {
$this->calculateTaxesAndFees($attendeeDTO);
@@ -75,7 +98,7 @@ public function handle(CreateAttendeeDTO $attendeeDTO): AttendeeDomainObject
ProductDomainObjectAbstract::PRODUCT_TYPE => ProductType::TICKET->name,
]);
- if (!$product) {
+ if (! $product) {
throw new NoTicketsAvailableException(__('This ticket is invalid'));
}
@@ -87,25 +110,38 @@ public function handle(CreateAttendeeDTO $attendeeDTO): AttendeeDomainObject
);
if ($availableQuantity <= 0) {
- throw new NoTicketsAvailableException(__('There are no tickets available. ' .
- 'If you would like to assign a product to this attendee,' .
+ throw new NoTicketsAvailableException(__('There are no tickets available. '.
+ 'If you would like to assign a product to this attendee,'.
' please adjust the product\'s available quantity.'));
}
- $productPriceId = $this->getProductPriceId($attendeeDTO, $product);
-
$this->processTaxesAndFees($attendeeDTO);
$orderItem = $this->createOrderItem($attendeeDTO, $order, $product, $productPriceId);
- $attendee = $this->createAttendee($order, $attendeeDTO);
+ // Use the resolved $productPriceId (not $attendeeDTO->product_price_id)
+ // so the attendee row and inventory adjustment match the order item.
+ // The DTO field is nullable — direct API callers can omit it and
+ // getProductPriceId() falls back to the product's first price.
+ $attendee = $this->createAttendee($order, $attendeeDTO, $productPriceId);
$this->orderManagementService->updateOrderTotals($order, collect([$orderItem]));
- $this->fireEventsAndUpdateQuantities($attendeeDTO, $order);
+ $this->fireEventsAndUpdateQuantities($attendeeDTO, $order, $productPriceId);
$this->queueWebhooks($order);
+ if ($attendeeDTO->override_capacity) {
+ $this->orderAuditLogService->logManualAttendeeCapacityOverride(
+ eventId: $attendeeDTO->event_id,
+ orderId: $order->getId(),
+ attendeeId: $attendee->getId(),
+ occurrenceId: $attendeeDTO->event_occurrence_id,
+ ipAddress: $attendeeDTO->client_ip ?? '',
+ userAgent: $attendeeDTO->client_user_agent,
+ );
+ }
+
return $attendee;
});
}
@@ -140,12 +176,13 @@ private function createOrder(int $eventId, CreateAttendeeDTO $attendeeDTO): Orde
*/
private function getProductPriceId(CreateAttendeeDTO $attendeeDTO, ProductDomainObject $product): int
{
- $priceIds = $product->getProductPrices()->map(fn(ProductPriceDomainObject $productPrice) => $productPrice->getId());
+ $priceIds = $product->getProductPrices()->map(fn (ProductPriceDomainObject $productPrice) => $productPrice->getId());
if ($attendeeDTO->product_price_id) {
- if (!$priceIds->contains($attendeeDTO->product_price_id)) {
+ if (! $priceIds->contains($attendeeDTO->product_price_id)) {
throw new InvalidProductPriceId(__('The product price ID is invalid.'));
}
+
return $attendeeDTO->product_price_id;
}
@@ -161,7 +198,7 @@ private function getProductPriceId(CreateAttendeeDTO $attendeeDTO, ProductDomain
private function calculateTaxesAndFees(CreateAttendeeDTO $attendeeDTO): ?Collection
{
- if (!$attendeeDTO->taxes_and_fees) {
+ if (! $attendeeDTO->taxes_and_fees) {
return null;
}
@@ -169,16 +206,18 @@ private function calculateTaxesAndFees(CreateAttendeeDTO $attendeeDTO): ?Collect
'id',
$attendeeDTO
->taxes_and_fees
- ->map(fn(CreateAttendeeTaxAndFeeDTO $taxAndFee) => $taxAndFee->tax_or_fee_id)
+ ->map(fn (CreateAttendeeTaxAndFeeDTO $taxAndFee) => $taxAndFee->tax_or_fee_id)
->toArray()
);
$validatedTaxesAndFees = collect();
$attendeeDTO->taxes_and_fees->each(function (CreateAttendeeTaxAndFeeDTO $taxAndFee) use ($validatedTaxesAndFees, $taxesAndFees) {
- $taxOrFee = $taxesAndFees->first(fn($taxOrFee) => $taxOrFee->getId() === $taxAndFee->tax_or_fee_id);
+ $taxOrFee = $taxesAndFees->first(fn ($taxOrFee) => $taxOrFee->getId() === $taxAndFee->tax_or_fee_id);
- if (!$taxOrFee) {
- throw new RuntimeException('Tax or fee not found.');
+ if (! $taxOrFee) {
+ throw ValidationException::withMessages([
+ 'taxes_and_fees' => __('One or more selected taxes or fees could not be found.'),
+ ]);
}
$validatedTaxesAndFees->push($taxOrFee);
@@ -190,12 +229,12 @@ private function calculateTaxesAndFees(CreateAttendeeDTO $attendeeDTO): ?Collect
private function processTaxesAndFees(CreateAttendeeDTO $attendeeDTO): void
{
$this->calculateTaxesAndFees($attendeeDTO)
- ?->each(fn($taxOrFee) => $this->taxAndFeeRollupService
+ ?->each(fn ($taxOrFee) => $this->taxAndFeeRollupService
->addToRollUp(
$taxOrFee,
$attendeeDTO
->taxes_and_fees
- ->first(fn($taxOrFeeDTO) => $taxOrFeeDTO->tax_or_fee_id === $taxOrFee->getId())
+ ->first(fn ($taxOrFeeDTO) => $taxOrFeeDTO->tax_or_fee_id === $taxOrFee->getId())
->amount)
);
}
@@ -215,16 +254,17 @@ private function createOrderItem(CreateAttendeeDTO $attendeeDTO, OrderDomainObje
OrderItemDomainObjectAbstract::ITEM_NAME => $product->getTitle(),
OrderItemDomainObjectAbstract::PRODUCT_PRICE_ID => $productPriceId,
OrderItemDomainObjectAbstract::TAXES_AND_FEES_ROLLUP => $this->taxAndFeeRollupService->getRollUp(),
+ OrderItemDomainObjectAbstract::EVENT_OCCURRENCE_ID => $attendeeDTO->event_occurrence_id,
]
);
}
- private function createAttendee(OrderDomainObject $order, CreateAttendeeDTO $attendeeDTO): AttendeeDomainObject
+ private function createAttendee(OrderDomainObject $order, CreateAttendeeDTO $attendeeDTO, int $productPriceId): AttendeeDomainObject
{
return $this->attendeeRepository->create([
AttendeeDomainObjectAbstract::EVENT_ID => $order->getEventId(),
AttendeeDomainObjectAbstract::PRODUCT_ID => $attendeeDTO->product_id,
- AttendeeDomainObjectAbstract::PRODUCT_PRICE_ID => $attendeeDTO->product_price_id,
+ AttendeeDomainObjectAbstract::PRODUCT_PRICE_ID => $productPriceId,
AttendeeDomainObjectAbstract::STATUS => AttendeeStatus::ACTIVE->name,
AttendeeDomainObjectAbstract::EMAIL => $attendeeDTO->email,
AttendeeDomainObjectAbstract::FIRST_NAME => $attendeeDTO->first_name,
@@ -232,14 +272,16 @@ private function createAttendee(OrderDomainObject $order, CreateAttendeeDTO $att
AttendeeDomainObjectAbstract::ORDER_ID => $order->getId(),
AttendeeDomainObjectAbstract::PUBLIC_ID => IdHelper::publicId(IdHelper::ATTENDEE_PREFIX),
AttendeeDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::ATTENDEE_PREFIX),
+ AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID => $attendeeDTO->event_occurrence_id,
AttendeeDomainObjectAbstract::LOCALE => $attendeeDTO->locale,
]);
}
- private function fireEventsAndUpdateQuantities(CreateAttendeeDTO $attendeeDTO, OrderDomainObject $order): void
+ private function fireEventsAndUpdateQuantities(CreateAttendeeDTO $attendeeDTO, OrderDomainObject $order, int $productPriceId): void
{
$this->productQuantityAdjustmentService->increaseQuantitySold(
- priceId: $attendeeDTO->product_price_id,
+ priceId: $productPriceId,
+ eventOccurrenceId: $attendeeDTO->event_occurrence_id,
);
event(new OrderStatusChangedEvent(
@@ -254,4 +296,34 @@ private function queueWebhooks(OrderDomainObject $order): void
new OrderEvent(DomainEventType::ORDER_CREATED, $order->getId())
);
}
+
+ private function resolveOccurrenceId(CreateAttendeeDTO $attendeeDTO): CreateAttendeeDTO
+ {
+ if ($attendeeDTO->event_occurrence_id !== null) {
+ return $attendeeDTO;
+ }
+
+ $event = $this->eventRepository->findById($attendeeDTO->event_id);
+
+ if ($event->getType() !== EventType::SINGLE->name) {
+ throw ValidationException::withMessages([
+ 'event_occurrence_id' => __('An occurrence must be selected for recurring events.'),
+ ]);
+ }
+
+ $occurrence = $this->eventOccurrenceRepository->findFirstWhere([
+ 'event_id' => $attendeeDTO->event_id,
+ ]);
+
+ if (!$occurrence) {
+ throw ValidationException::withMessages([
+ 'event_occurrence_id' => __('No occurrence found for this event.'),
+ ]);
+ }
+
+ return CreateAttendeeDTO::fromArray(array_merge(
+ $attendeeDTO->toArray(),
+ ['event_occurrence_id' => $occurrence->getId()]
+ ));
+ }
}
diff --git a/backend/app/Services/Application/Handlers/Attendee/DTO/CreateAttendeeDTO.php b/backend/app/Services/Application/Handlers/Attendee/DTO/CreateAttendeeDTO.php
index 8cba021ad7..54e37bed4e 100644
--- a/backend/app/Services/Application/Handlers/Attendee/DTO/CreateAttendeeDTO.php
+++ b/backend/app/Services/Application/Handlers/Attendee/DTO/CreateAttendeeDTO.php
@@ -9,19 +9,21 @@
class CreateAttendeeDTO extends BaseDTO
{
public function __construct(
- public readonly string $first_name,
- public readonly string $last_name,
- public readonly string $email,
- public readonly int $product_id,
- public readonly int $event_id,
- public readonly bool $send_confirmation_email,
- public readonly float $amount_paid,
- public readonly string $locale,
- public readonly ?bool $amount_includes_tax = false,
- public readonly ?int $product_price_id = null,
+ public readonly string $first_name,
+ public readonly string $last_name,
+ public readonly string $email,
+ public readonly int $product_id,
+ public readonly int $event_id,
+ public readonly bool $send_confirmation_email,
+ public readonly float $amount_paid,
+ public readonly string $locale,
+ public readonly ?bool $amount_includes_tax = false,
+ public readonly ?int $product_price_id = null,
+ public readonly ?int $event_occurrence_id = null,
+ public readonly bool $override_capacity = false,
+ public readonly ?string $client_ip = null,
+ public readonly ?string $client_user_agent = null,
#[CollectionOf(CreateAttendeeTaxAndFeeDTO::class)]
public readonly ?Collection $taxes_and_fees = null,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php b/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php
index f7dd85f02a..79a8fac590 100644
--- a/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php
+++ b/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php
@@ -63,14 +63,15 @@ public function handle(EditAttendeeDTO $editAttendeeDTO): AttendeeDomainObject
private function adjustProductQuantities(AttendeeDomainObject $attendee, EditAttendeeDTO $editAttendeeDTO): void
{
if ($attendee->getProductPriceId() !== $editAttendeeDTO->product_price_id) {
- $this->productQuantityService->decreaseQuantitySold($attendee->getProductPriceId());
- $this->productQuantityService->increaseQuantitySold($editAttendeeDTO->product_price_id);
+ $this->productQuantityService->decreaseQuantitySold($attendee->getProductPriceId(), 1, $attendee->getEventOccurrenceId());
+ $this->productQuantityService->increaseQuantitySold($editAttendeeDTO->product_price_id, 1, $attendee->getEventOccurrenceId());
event(new CapacityChangedEvent(
eventId: $editAttendeeDTO->event_id,
direction: CapacityChangeDirection::INCREASED,
productId: $attendee->getProductId(),
productPriceId: $attendee->getProductPriceId(),
+ eventOccurrenceId: $attendee->getEventOccurrenceId(),
));
}
}
diff --git a/backend/app/Services/Application/Handlers/Attendee/GetAttendeesHandler.php b/backend/app/Services/Application/Handlers/Attendee/GetAttendeesHandler.php
index d8e5881ebf..3cc09219da 100644
--- a/backend/app/Services/Application/Handlers/Attendee/GetAttendeesHandler.php
+++ b/backend/app/Services/Application/Handlers/Attendee/GetAttendeesHandler.php
@@ -3,6 +3,7 @@
namespace HiEvents\Services\Application\Handlers\Attendee;
use HiEvents\DomainObjects\AttendeeCheckInDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\Http\DTO\QueryParamsDTO;
use HiEvents\Repository\Eloquent\Value\Relationship;
@@ -28,6 +29,10 @@ public function handle(int $eventId, QueryParamsDTO $queryParams): LengthAwarePa
domainObject: AttendeeCheckInDomainObject::class,
name: 'check_ins'
))
+ ->loadRelation(new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ name: 'event_occurrence'
+ ))
->findByEventId($eventId, $queryParams);
}
}
diff --git a/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php b/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php
index 36c5bc22a7..f0621859e1 100644
--- a/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php
+++ b/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php
@@ -93,22 +93,24 @@ private function updateAttendee(PartialEditAttendeeDTO $data): AttendeeDomainObj
private function adjustProductQuantity(PartialEditAttendeeDTO $data, AttendeeDomainObject $attendee): void
{
if ($data->status === AttendeeStatus::ACTIVE->name) {
- $this->productQuantityService->increaseQuantitySold($attendee->getProductPriceId());
+ $this->productQuantityService->increaseQuantitySold($attendee->getProductPriceId(), 1, $attendee->getEventOccurrenceId());
event(new CapacityChangedEvent(
eventId: $attendee->getEventId(),
direction: CapacityChangeDirection::DECREASED,
productId: $attendee->getProductId(),
productPriceId: $attendee->getProductPriceId(),
+ eventOccurrenceId: $attendee->getEventOccurrenceId(),
));
} elseif ($data->status === AttendeeStatus::CANCELLED->name) {
- $this->productQuantityService->decreaseQuantitySold($attendee->getProductPriceId());
+ $this->productQuantityService->decreaseQuantitySold($attendee->getProductPriceId(), 1, $attendee->getEventOccurrenceId());
event(new CapacityChangedEvent(
eventId: $attendee->getEventId(),
direction: CapacityChangeDirection::INCREASED,
productId: $attendee->getProductId(),
productPriceId: $attendee->getProductPriceId(),
+ eventOccurrenceId: $attendee->getEventOccurrenceId(),
));
}
}
@@ -137,12 +139,14 @@ private function adjustEventStatistics(PartialEditAttendeeDTO $data, AttendeeDom
if ($data->status === AttendeeStatus::CANCELLED->name) {
$this->eventStatisticsCancellationService->decrementForCancelledAttendee(
eventId: $attendee->getEventId(),
- orderDate: $order->getCreatedAt()
+ orderDate: $order->getCreatedAt(),
+ occurrenceId: $attendee->getEventOccurrenceId(),
);
} elseif ($data->status === AttendeeStatus::ACTIVE->name) {
$this->eventStatisticsReactivationService->incrementForReactivatedAttendee(
eventId: $attendee->getEventId(),
- orderDate: $order->getCreatedAt()
+ orderDate: $order->getCreatedAt(),
+ occurrenceId: $attendee->getEventOccurrenceId(),
);
}
}
diff --git a/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php b/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php
index e8380b1766..5d5a7584e1 100644
--- a/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php
+++ b/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php
@@ -3,6 +3,7 @@
namespace HiEvents\Services\Application\Handlers\Attendee;
use HiEvents\DomainObjects\EventSettingDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
@@ -36,6 +37,10 @@ public function handle(ResendAttendeeTicketDTO $resendAttendeeProductDTO): void
->loadRelation(new Relationship(OrderDomainObject::class, nested: [
new Relationship(OrderItemDomainObject::class),
], name: 'order'))
+ ->loadRelation(new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ name: 'event_occurrence',
+ ))
->findFirstWhere([
'id' => $resendAttendeeProductDTO->attendeeId,
'event_id' => $resendAttendeeProductDTO->eventId,
diff --git a/backend/app/Services/Application/Handlers/CheckInList/CreateCheckInListHandler.php b/backend/app/Services/Application/Handlers/CheckInList/CreateCheckInListHandler.php
index 6682b2a346..d929ab44d9 100644
--- a/backend/app/Services/Application/Handlers/CheckInList/CreateCheckInListHandler.php
+++ b/backend/app/Services/Application/Handlers/CheckInList/CreateCheckInListHandler.php
@@ -25,7 +25,11 @@ public function handle(UpsertCheckInListDTO $listData): CheckInListDomainObject
->setDescription($listData->description)
->setEventId($listData->eventId)
->setExpiresAt($listData->expiresAt)
- ->setActivatesAt($listData->activatesAt);
+ ->setActivatesAt($listData->activatesAt)
+ ->setEventOccurrenceId($listData->eventOccurrenceId)
+ ->setPublicShowAttendeeNotes($listData->publicShowAttendeeNotes)
+ ->setPublicShowQuestionAnswers($listData->publicShowQuestionAnswers)
+ ->setPublicShowOrderDetails($listData->publicShowOrderDetails);
return $this->createCheckInListService->createCheckInList(
checkInList: $checkInList,
diff --git a/backend/app/Services/Application/Handlers/CheckInList/DTO/UpsertCheckInListDTO.php b/backend/app/Services/Application/Handlers/CheckInList/DTO/UpsertCheckInListDTO.php
index 229a93c42f..5c58935dee 100644
--- a/backend/app/Services/Application/Handlers/CheckInList/DTO/UpsertCheckInListDTO.php
+++ b/backend/app/Services/Application/Handlers/CheckInList/DTO/UpsertCheckInListDTO.php
@@ -14,6 +14,10 @@ public function __construct(
public ?string $expiresAt = null,
public ?string $activatesAt = null,
public ?int $id = null,
+ public ?int $eventOccurrenceId = null,
+ public bool $publicShowAttendeeNotes = true,
+ public bool $publicShowQuestionAnswers = true,
+ public bool $publicShowOrderDetails = true,
)
{
}
diff --git a/backend/app/Services/Application/Handlers/CheckInList/DeleteCheckInListHandler.php b/backend/app/Services/Application/Handlers/CheckInList/DeleteCheckInListHandler.php
index 1ef9674a57..0161c01ddd 100644
--- a/backend/app/Services/Application/Handlers/CheckInList/DeleteCheckInListHandler.php
+++ b/backend/app/Services/Application/Handlers/CheckInList/DeleteCheckInListHandler.php
@@ -2,6 +2,7 @@
namespace HiEvents\Services\Application\Handlers\CheckInList;
+use HiEvents\Exceptions\ResourceConflictException;
use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
@@ -25,6 +26,12 @@ public function handle(int $eventId, int $checkInListId): void
throw new ResourceNotFoundException(__('Check-in list not found'));
}
+ if ($checkInList->getIsSystemDefault()) {
+ throw new ResourceConflictException(
+ __('The default check-in list can\'t be deleted.')
+ );
+ }
+
$this->checkInListRepository->deleteWhere([
'id' => $checkInListId,
'event_id' => $eventId,
diff --git a/backend/app/Services/Application/Handlers/CheckInList/GetCheckInListsHandler.php b/backend/app/Services/Application/Handlers/CheckInList/GetCheckInListsHandler.php
index 3725d05ff7..0789d4614e 100644
--- a/backend/app/Services/Application/Handlers/CheckInList/GetCheckInListsHandler.php
+++ b/backend/app/Services/Application/Handlers/CheckInList/GetCheckInListsHandler.php
@@ -4,6 +4,7 @@
use HiEvents\DomainObjects\CheckInListDomainObject;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface;
@@ -23,6 +24,7 @@ public function handle(GetCheckInListsDTO $dto): LengthAwarePaginator
$checkInLists = $this->checkInListRepository
->loadRelation(ProductDomainObject::class)
->loadRelation(new Relationship(domainObject: EventDomainObject::class, name: 'event'))
+ ->loadRelation(new Relationship(domainObject: EventOccurrenceDomainObject::class, name: 'event_occurrence'))
->findByEventId(
eventId: $dto->eventId,
params: $dto->queryParams,
diff --git a/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/PublicAttendeeDetailDTO.php b/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/PublicAttendeeDetailDTO.php
new file mode 100644
index 0000000000..7ad997b4da
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/PublicAttendeeDetailDTO.php
@@ -0,0 +1,24 @@
+ $currentListCheckIns
+ */
+ public function __construct(
+ public AttendeeDomainObject $attendee,
+ public Collection $currentListCheckIns,
+ public bool $showNotes,
+ public bool $showQuestionAnswers,
+ public bool $showOrderDetails,
+ )
+ {
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeeDetailPublicHandler.php b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeeDetailPublicHandler.php
new file mode 100644
index 0000000000..038c9e61f0
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeeDetailPublicHandler.php
@@ -0,0 +1,134 @@
+checkInListRepository
+ ->loadRelation(ProductDomainObject::class)
+ ->loadRelation(new Relationship(EventDomainObject::class, name: 'event'))
+ ->findFirstWhere([
+ CheckInListDomainObjectAbstract::SHORT_ID => $shortId,
+ ]);
+
+ if (!$checkInList) {
+ throw new ResourceNotFoundException(__('Check-in list not found'));
+ }
+
+ $this->validateCheckInListIsActive($checkInList);
+
+ $attendee = $this->attendeeRepository
+ ->loadRelation(new Relationship(OrderDomainObject::class, name: 'order'))
+ ->loadRelation(QuestionAndAnswerViewDomainObject::class)
+ ->loadRelation(new Relationship(ProductDomainObject::class, name: 'product'))
+ ->loadRelation(new Relationship(AttendeeCheckInDomainObject::class, name: 'check_ins'))
+ ->loadRelation(new Relationship(EventOccurrenceDomainObject::class, name: 'event_occurrence'))
+ ->findFirstWhere([
+ 'public_id' => $attendeePublicId,
+ 'event_id' => $checkInList->getEventId(),
+ ]);
+
+ if (!$attendee) {
+ throw new ResourceNotFoundException(__('Attendee not found'));
+ }
+
+ $this->verifyAttendeeBelongsToCheckInList($checkInList, $attendee);
+
+ $currentListCheckIns = $this->filterCheckInsForList($attendee->getCheckIns(), $checkInList->getId());
+ $isStaff = $this->hasStaffAccess($checkInList, $staffAccountId);
+
+ return new PublicAttendeeDetailDTO(
+ attendee: $attendee,
+ currentListCheckIns: $currentListCheckIns,
+ showNotes: $isStaff || $checkInList->getPublicShowAttendeeNotes(),
+ showQuestionAnswers: $isStaff || $checkInList->getPublicShowQuestionAnswers(),
+ showOrderDetails: $isStaff || $checkInList->getPublicShowOrderDetails(),
+ );
+ }
+
+ /**
+ * @return Collection
+ */
+ private function filterCheckInsForList(?Collection $checkIns, int $checkInListId): Collection
+ {
+ if ($checkIns === null) {
+ return new Collection();
+ }
+
+ return $checkIns->filter(
+ static fn(AttendeeCheckInDomainObject $checkIn) => $checkIn->getCheckInListId() === $checkInListId
+ )->values();
+ }
+
+ private function hasStaffAccess(CheckInListDomainObject $checkInList, ?int $staffAccountId): bool
+ {
+ if ($staffAccountId === null) {
+ return false;
+ }
+
+ $event = $checkInList->getEvent();
+ if ($event === null) {
+ return false;
+ }
+
+ return $event->getAccountId() === $staffAccountId;
+ }
+
+ /**
+ * @throws CannotCheckInException
+ */
+ private function validateCheckInListIsActive(CheckInListDomainObject $checkInList): void
+ {
+ if ($checkInList->getExpiresAt() && DateHelper::utcDateIsPast($checkInList->getExpiresAt())) {
+ throw new CannotCheckInException(__('Check-in list has expired'));
+ }
+
+ if ($checkInList->getActivatesAt() && DateHelper::utcDateIsFuture($checkInList->getActivatesAt())) {
+ throw new CannotCheckInException(__('Check-in list is not active yet'));
+ }
+ }
+
+ private function verifyAttendeeBelongsToCheckInList(
+ CheckInListDomainObject $checkInList,
+ AttendeeDomainObject $attendee,
+ ): void {
+ $allowedProductIds = $checkInList->getProducts()?->map(fn($product) => $product->getId())->toArray() ?? [];
+
+ if (! empty($allowedProductIds) && ! in_array($attendee->getProductId(), $allowedProductIds, true)) {
+ throw new ResourceNotFoundException(__('Attendee not found'));
+ }
+
+ if ($checkInList->getEventOccurrenceId() !== null
+ && $attendee->getEventOccurrenceId() !== $checkInList->getEventOccurrenceId()
+ ) {
+ throw new ResourceNotFoundException(__('Attendee not found'));
+ }
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeePublicHandler.php b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeePublicHandler.php
index 5879b08856..d599f099a2 100644
--- a/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeePublicHandler.php
+++ b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeePublicHandler.php
@@ -41,10 +41,18 @@ public function handle(string $shortId, string $attendeePublicId): AttendeeDomai
$this->validateCheckInListIsActive($checkInList);
- return $this->attendeeRepository->findFirstWhere([
+ $attendee = $this->attendeeRepository->findFirstWhere([
'public_id' => $attendeePublicId,
'event_id' => $checkInList->getEventId(),
]);
+
+ if (! $attendee) {
+ throw new ResourceNotFoundException(__('Attendee not found'));
+ }
+
+ $this->verifyAttendeeBelongsToCheckInList($checkInList, $attendee);
+
+ return $attendee;
}
/**
@@ -61,4 +69,21 @@ private function validateCheckInListIsActive(CheckInListDomainObject $checkInLis
throw new CannotCheckInException(__('Check-in list is not active yet'));
}
}
+
+ private function verifyAttendeeBelongsToCheckInList(
+ CheckInListDomainObject $checkInList,
+ AttendeeDomainObject $attendee,
+ ): void {
+ $allowedProductIds = $checkInList->getProducts()?->map(fn($product) => $product->getId())->toArray() ?? [];
+
+ if (! empty($allowedProductIds) && ! in_array($attendee->getProductId(), $allowedProductIds, true)) {
+ throw new ResourceNotFoundException(__('Attendee not found'));
+ }
+
+ if ($checkInList->getEventOccurrenceId() !== null
+ && $attendee->getEventOccurrenceId() !== $checkInList->getEventOccurrenceId()
+ ) {
+ throw new ResourceNotFoundException(__('Attendee not found'));
+ }
+ }
}
diff --git a/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandler.php b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandler.php
index 8d25b1f036..93c14d5e21 100644
--- a/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandler.php
+++ b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandler.php
@@ -9,6 +9,7 @@
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\Exceptions\CannotCheckInException;
use HiEvents\Helper\DateHelper;
+use HiEvents\Http\DTO\FilterFieldDTO;
use HiEvents\Http\DTO\QueryParamsDTO;
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
@@ -43,6 +44,8 @@ public function handle(string $shortId, QueryParamsDTO $queryParams): Paginator
$this->validateCheckInListIsActive($checkInList);
+ $queryParams = $this->applyCheckInListOccurrenceScope($checkInList, $queryParams);
+
$attendees = $this->attendeeRepository->getAttendeesByCheckInShortId($shortId, $queryParams);
// Set the check-in for each attendee
@@ -54,6 +57,40 @@ public function handle(string $shortId, QueryParamsDTO $queryParams): Paginator
return $attendees;
}
+ /**
+ * Force the attendee query to the list's own occurrence when set; otherwise
+ * honour the client's filter (that's how the optional filter pill works).
+ */
+ private function applyCheckInListOccurrenceScope(
+ CheckInListDomainObject $checkInList,
+ QueryParamsDTO $queryParams,
+ ): QueryParamsDTO {
+ $scopedOccurrenceId = $checkInList->getEventOccurrenceId();
+ if ($scopedOccurrenceId === null) {
+ return $queryParams;
+ }
+
+ $filterFields = ($queryParams->filter_fields ?? collect())
+ ->reject(fn(FilterFieldDTO $f) => $f->field === 'event_occurrence_id')
+ ->push(new FilterFieldDTO(
+ field: 'event_occurrence_id',
+ operator: 'eq',
+ value: (string) $scopedOccurrenceId,
+ ))
+ ->values();
+
+ return new QueryParamsDTO(
+ page: $queryParams->page,
+ per_page: $queryParams->per_page,
+ sort_by: $queryParams->sort_by,
+ sort_direction: $queryParams->sort_direction,
+ query: $queryParams->query,
+ filter_fields: $filterFields,
+ includes: $queryParams->includes,
+ query_params: $queryParams->query_params,
+ );
+ }
+
/**
* @throws CannotCheckInException
*/
diff --git a/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListPublicHandler.php b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListPublicHandler.php
index ffee8de664..b99154df17 100644
--- a/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListPublicHandler.php
+++ b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListPublicHandler.php
@@ -4,6 +4,7 @@
use HiEvents\DomainObjects\CheckInListDomainObject;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\Repository\Eloquent\Value\Relationship;
@@ -23,8 +24,12 @@ public function handle(string $shortId): CheckInListDomainObject
$checkInList = $this->checkInListRepository
->loadRelation((new Relationship(domainObject: EventDomainObject::class, nested: [
new Relationship(domainObject: EventSettingDomainObject::class, name: 'event_settings'),
+ new Relationship(domainObject: EventOccurrenceDomainObject::class, name: 'event_occurrences'),
], name: 'event')))
->loadRelation(ProductDomainObject::class)
+ // Load separately — it may be past and therefore absent from
+ // event.occurrences, which filters to future.
+ ->loadRelation(new Relationship(EventOccurrenceDomainObject::class, name: 'event_occurrence'))
->findFirstWhere([
'short_id' => $shortId,
]);
diff --git a/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListStatsPublicHandler.php b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListStatsPublicHandler.php
new file mode 100644
index 0000000000..c80372e355
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListStatsPublicHandler.php
@@ -0,0 +1,44 @@
+checkInListRepository->findFirstWhere(['short_id' => $shortId]);
+
+ if (!$checkInList) {
+ throw new ResourceNotFoundException(__('Check-in list not found'));
+ }
+
+ // Scoped lists ignore the client filter (the list already owns an
+ // occurrence). Unscoped lists honour the filter pill.
+ $effectiveOverride = $checkInList->getEventOccurrenceId() !== null
+ ? null
+ : $clientOccurrenceFilter;
+
+ $totals = $this->checkInListRepository->getCheckedInAttendeeCountById($checkInList->getId(), $effectiveOverride);
+ $perProduct = $this->checkInListRepository->getPerProductCheckInStatsById($checkInList->getId(), $effectiveOverride);
+ $recent = $this->checkInListRepository->getRecentCheckInsById($checkInList->getId(), self::RECENT_CHECK_INS_LIMIT, $effectiveOverride);
+
+ return new CheckInListStatsDTO(
+ totalAttendees: $totals->totalAttendeesCount,
+ checkedInAttendees: $totals->checkedInCount,
+ perProduct: $perProduct->values()->all(),
+ recentCheckIns: $recent->values()->all(),
+ );
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/CheckInList/UpdateCheckInlistHandler.php b/backend/app/Services/Application/Handlers/CheckInList/UpdateCheckInlistHandler.php
index d31d7873c3..439bc1c06a 100644
--- a/backend/app/Services/Application/Handlers/CheckInList/UpdateCheckInlistHandler.php
+++ b/backend/app/Services/Application/Handlers/CheckInList/UpdateCheckInlistHandler.php
@@ -26,7 +26,11 @@ public function handle(UpsertCheckInListDTO $data): CheckInListDomainObject
->setDescription($data->description)
->setEventId($data->eventId)
->setExpiresAt($data->expiresAt)
- ->setActivatesAt($data->activatesAt);
+ ->setActivatesAt($data->activatesAt)
+ ->setEventOccurrenceId($data->eventOccurrenceId)
+ ->setPublicShowAttendeeNotes($data->publicShowAttendeeNotes)
+ ->setPublicShowQuestionAnswers($data->publicShowQuestionAnswers)
+ ->setPublicShowOrderDetails($data->publicShowOrderDetails);
return $this->updateCheckInlistService->updateCheckInlist(
checkInList: $checkInList,
diff --git a/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php b/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php
index 7b86a00011..b1a8c7d257 100644
--- a/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php
+++ b/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php
@@ -52,18 +52,21 @@ private function createEvent(CreateEventDTO $eventData): EventDomainObject
->setAccountId($eventData->account_id)
->setUserId($eventData->user_id)
->setTitle($eventData->title)
- ->setStartDate($eventData->start_date)
- ->setEndDate($eventData->end_date)
->setDescription($eventData->description)
->setAttributes($eventData->attributes?->toArray())
->setTimezone($eventData->timezone ?? $organizer->getTimezone())
->setCurrency($eventData->currency ?? $organizer->getCurrency())
->setCategory($eventData->category?->value ?? EventCategory::OTHER->value)
->setStatus($eventData->status)
+ ->setType($eventData->type?->name)
->setEventSettings($eventData->event_settings)
->setLocationDetails($eventData->location_details?->toArray());
- $newEvent = $this->createEventService->createEvent($event);
+ $newEvent = $this->createEventService->createEvent(
+ eventData: $event,
+ startDate: $eventData->start_date,
+ endDate: $eventData->end_date,
+ );
$this->createProductCategoryService->createDefaultProductCategory($newEvent);
diff --git a/backend/app/Services/Application/Handlers/Event/DTO/CreateEventDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/CreateEventDTO.php
index e25ac39383..e0ea4b8aad 100644
--- a/backend/app/Services/Application/Handlers/Event/DTO/CreateEventDTO.php
+++ b/backend/app/Services/Application/Handlers/Event/DTO/CreateEventDTO.php
@@ -7,6 +7,7 @@
use HiEvents\DataTransferObjects\AttributesDTO;
use HiEvents\DataTransferObjects\BaseDTO;
use HiEvents\DomainObjects\Enums\EventCategory;
+use HiEvents\DomainObjects\Enums\EventType;
use HiEvents\DomainObjects\Status\EventStatus;
use HiEvents\Services\Application\Handlers\EventSettings\DTO\UpdateEventSettingsDTO;
use Illuminate\Support\Collection;
@@ -29,6 +30,7 @@ public function __construct(
public readonly ?EventCategory $category = null,
public readonly ?AddressDTO $location_details = null,
public readonly ?string $status = EventStatus::DRAFT->name,
+ public readonly ?EventType $type = EventType::SINGLE,
public ?UpdateEventSettingsDTO $event_settings = null
)
diff --git a/backend/app/Services/Application/Handlers/Event/DTO/EventStatsRequestDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/EventStatsRequestDTO.php
index 9b21de9fa4..a308331df0 100644
--- a/backend/app/Services/Application/Handlers/Event/DTO/EventStatsRequestDTO.php
+++ b/backend/app/Services/Application/Handlers/Event/DTO/EventStatsRequestDTO.php
@@ -11,6 +11,7 @@ public function __construct(
public ?string $start_date = null,
public ?string $end_date = null,
public string $date_range_preset = 'month',
+ public ?int $occurrence_id = null,
)
{
}
diff --git a/backend/app/Services/Application/Handlers/Event/DTO/GetPublicEventDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/GetPublicEventDTO.php
index c3d61ce009..cfbeb36f52 100644
--- a/backend/app/Services/Application/Handlers/Event/DTO/GetPublicEventDTO.php
+++ b/backend/app/Services/Application/Handlers/Event/DTO/GetPublicEventDTO.php
@@ -11,6 +11,7 @@ public function __construct(
public bool $isAuthenticated,
public ?string $ipAddress = null,
public ?string $promoCode = null,
+ public ?int $eventOccurrenceId = null,
)
{
}
diff --git a/backend/app/Services/Application/Handlers/Event/DuplicateEventHandler.php b/backend/app/Services/Application/Handlers/Event/DuplicateEventHandler.php
index 11dd929687..e490c14acb 100644
--- a/backend/app/Services/Application/Handlers/Event/DuplicateEventHandler.php
+++ b/backend/app/Services/Application/Handlers/Event/DuplicateEventHandler.php
@@ -35,6 +35,7 @@ public function handle(DuplicateEventDataDTO $data): EventDomainObject
duplicateTicketLogo: $data->duplicateTicketLogo,
duplicateWebhooks: $data->duplicateWebhooks,
duplicateAffiliates: $data->duplicateAffiliates,
+ duplicateOccurrences: $data->duplicateOccurrences,
description: $data->description,
endDate: $data->endDate,
);
diff --git a/backend/app/Services/Application/Handlers/Event/GetEventsHandler.php b/backend/app/Services/Application/Handlers/Event/GetEventsHandler.php
index d58beb6c43..10dba8d19d 100644
--- a/backend/app/Services/Application/Handlers/Event/GetEventsHandler.php
+++ b/backend/app/Services/Application/Handlers/Event/GetEventsHandler.php
@@ -2,6 +2,7 @@
namespace HiEvents\Services\Application\Handlers\Event;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\EventStatisticDomainObject;
use HiEvents\DomainObjects\ImageDomainObject;
@@ -24,6 +25,7 @@ public function __construct(
public function handle(GetEventsDTO $dto): LengthAwarePaginator
{
return $this->eventRepository
+ ->loadRelation(new Relationship(EventOccurrenceDomainObject::class))
->loadRelation(new Relationship(ImageDomainObject::class))
->loadRelation(new Relationship(EventSettingDomainObject::class))
->loadRelation(new Relationship(EventStatisticDomainObject::class))
diff --git a/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php b/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php
index 12867e1912..28d66a741d 100644
--- a/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php
+++ b/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php
@@ -2,8 +2,11 @@
namespace HiEvents\Services\Application\Handlers\Event;
+use HiEvents\DomainObjects\Enums\EventType;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
+use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract;
use HiEvents\DomainObjects\ImageDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
@@ -11,9 +14,11 @@
use HiEvents\DomainObjects\ProductCategoryDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
+use HiEvents\DomainObjects\Status\EventOccurrenceStatus;
use HiEvents\DomainObjects\TaxAndFeesDomainObject;
use HiEvents\Repository\Eloquent\Value\OrderAndDirection;
use HiEvents\Repository\Eloquent\Value\Relationship;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface;
use HiEvents\Services\Application\Handlers\Event\DTO\GetPublicEventDTO;
@@ -22,14 +27,15 @@
class GetPublicEventHandler
{
+ public const MAX_PUBLIC_OCCURRENCES = 200;
+
public function __construct(
- private readonly EventRepositoryInterface $eventRepository,
- private readonly PromoCodeRepositoryInterface $promoCodeRepository,
- private readonly ProductFilterService $productFilterService,
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly EventOccurrenceRepositoryInterface $occurrenceRepository,
+ private readonly PromoCodeRepositoryInterface $promoCodeRepository,
+ private readonly ProductFilterService $productFilterService,
private readonly EventPageViewIncrementService $eventPageViewIncrementService,
- )
- {
- }
+ ) {}
public function handle(GetPublicEventDTO $data): EventDomainObject
{
@@ -55,22 +61,88 @@ public function handle(GetPublicEventDTO $data): EventDomainObject
], name: 'organizer'))
->findById($data->eventId);
+ $occurrenceWhere = [
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $data->eventId,
+ [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name],
+ ];
+
+ if ($event->getType() === EventType::RECURRING->name) {
+ $occurrenceWhere[] = [EventOccurrenceDomainObjectAbstract::START_DATE, '>=', now()->toDateTimeString()];
+ }
+
+ // +1 lets us detect overflow without loading the entire occurrence table for
+ // long-running recurring events (e.g. daily over multiple years).
+ $occurrences = $this->occurrenceRepository->findWhere(
+ where: $occurrenceWhere,
+ orderAndDirections: [
+ new OrderAndDirection(EventOccurrenceDomainObjectAbstract::START_DATE, 'asc'),
+ ],
+ limit: self::MAX_PUBLIC_OCCURRENCES + 1,
+ );
+
+ // Resolve once: only honour the requested occurrence id if it actually
+ // belongs to this event. The caller can supply any id, and downstream
+ // ProductFilterService applies visibility/capacity rules for whichever
+ // id we pass — so a cross-event id would otherwise leak another event's
+ // visibility-altered product payload through this event's response.
+ $verifiedOccurrence = null;
+ if ($data->eventOccurrenceId !== null) {
+ $verifiedOccurrence = $occurrences->first(
+ fn (EventOccurrenceDomainObject $o) => $o->getId() === $data->eventOccurrenceId
+ );
+ if ($verifiedOccurrence === null) {
+ $verifiedOccurrence = $this->occurrenceRepository->findFirstWhere([
+ EventOccurrenceDomainObjectAbstract::ID => $data->eventOccurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $data->eventId,
+ [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name],
+ ]);
+ }
+ // The fallback above only filters out CANCELLED — drop past dates
+ // here too. Without this, a stale share/email link to a past date
+ // resolves successfully, drives `productFilterService->filter` for
+ // that occurrence, and then the storefront date picker (which hides
+ // past dates) leaves the user with occurrence-filtered products and
+ // no selectable date. Treat past-link as "no occurrence verified"
+ // and let the payload fall back to the event-wide product set.
+ if ($verifiedOccurrence !== null && $verifiedOccurrence->isPast()) {
+ $verifiedOccurrence = null;
+ }
+ }
+
+ $verifiedOccurrenceId = $verifiedOccurrence?->getId();
+
+ if ($occurrences->count() > self::MAX_PUBLIC_OCCURRENCES) {
+ $occurrences = $occurrences->take(self::MAX_PUBLIC_OCCURRENCES)->values();
+ }
+
+ // Append the verified occurrence when it isn't already in the public
+ // payload — covers two cases: (1) the linked occurrence was beyond the
+ // capped range for a long-running schedule, and (2) the requested id
+ // matched but only via the fallback ownership query (safety net).
+ if ($verifiedOccurrence !== null
+ && ! $occurrences->contains(fn (EventOccurrenceDomainObject $o) => $o->getId() === $verifiedOccurrenceId)) {
+ $occurrences->push($verifiedOccurrence);
+ }
+
+ $event->setEventOccurrences($occurrences);
+
$promoCodeDomainObject = $this->promoCodeRepository->findFirstWhere([
PromoCodeDomainObjectAbstract::EVENT_ID => $data->eventId,
PromoCodeDomainObjectAbstract::CODE => $data->promoCode,
]);
- if (!$promoCodeDomainObject?->isValid()) {
+ if (! $promoCodeDomainObject?->isValid()) {
$promoCodeDomainObject = null;
}
- if (!$data->isAuthenticated) {
+ if (! $data->isAuthenticated) {
$this->eventPageViewIncrementService->increment($data->eventId, $data->ipAddress);
}
return $event->setProductCategories($this->productFilterService->filter(
productsCategories: $event->getProductCategories(),
- promoCode: $promoCodeDomainObject
+ promoCode: $promoCodeDomainObject,
+ eventOccurrenceId: $verifiedOccurrenceId,
));
}
}
diff --git a/backend/app/Services/Application/Handlers/Event/GetPublicEventsHandler.php b/backend/app/Services/Application/Handlers/Event/GetPublicEventsHandler.php
index 933f1fc1c0..af779c4b9c 100644
--- a/backend/app/Services/Application/Handlers/Event/GetPublicEventsHandler.php
+++ b/backend/app/Services/Application/Handlers/Event/GetPublicEventsHandler.php
@@ -2,6 +2,7 @@
namespace HiEvents\Services\Application\Handlers\Event;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\ImageDomainObject;
use HiEvents\DomainObjects\ProductCategoryDomainObject;
@@ -30,6 +31,7 @@ public function handle(GetPublicOrganizerEventsDTO $dto): LengthAwarePaginator
$organizer = $this->organizerRepository->findById($dto->organizerId);
$query = $this->eventRepository
+ ->loadRelation(new Relationship(EventOccurrenceDomainObject::class))
->loadRelation(
new Relationship(ProductCategoryDomainObject::class, [
new Relationship(ProductDomainObject::class,
diff --git a/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php b/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php
index 8f284ff631..9000b73c13 100644
--- a/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php
+++ b/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php
@@ -2,12 +2,14 @@
namespace HiEvents\Services\Application\Handlers\Event;
+use HiEvents\DomainObjects\Enums\EventType;
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\Status\OrderStatus;
use HiEvents\Events\Dispatcher;
use HiEvents\Events\EventUpdateEvent;
use HiEvents\Exceptions\CannotChangeCurrencyException;
use HiEvents\Helper\DateHelper;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Services\Application\Handlers\Event\DTO\UpdateEventDTO;
@@ -21,11 +23,12 @@
readonly class UpdateEventHandler
{
public function __construct(
- private EventRepositoryInterface $eventRepository,
- private Dispatcher $dispatcher,
- private DatabaseManager $databaseManager,
- private OrderRepositoryInterface $orderRepository,
- private HtmlPurifierService $purifier,
+ private EventRepositoryInterface $eventRepository,
+ private Dispatcher $dispatcher,
+ private DatabaseManager $databaseManager,
+ private OrderRepositoryInterface $orderRepository,
+ private HtmlPurifierService $purifier,
+ private EventOccurrenceRepositoryInterface $occurrenceRepository,
)
{
}
@@ -73,10 +76,6 @@ private function updateEventAttributes(UpdateEventDTO $eventData): void
attributes: [
'title' => $eventData->title,
'category' => $eventData->category?->value ?? $existingEvent->getCategory(),
- 'start_date' => DateHelper::convertToUTC($eventData->start_date, $eventData->timezone),
- 'end_date' => $eventData->end_date
- ? DateHelper::convertToUTC($eventData->end_date, $eventData->timezone)
- : null,
'description' => $this->purifier->purify($eventData->description),
'timezone' => $eventData->timezone ?? $existingEvent->getTimezone(),
'currency' => $eventData->currency ?? $existingEvent->getCurrency(),
@@ -88,6 +87,41 @@ private function updateEventAttributes(UpdateEventDTO $eventData): void
'account_id' => $eventData->account_id,
],
);
+
+ $this->updateSingleOccurrenceDates($eventData, $existingEvent);
+ }
+
+ private function updateSingleOccurrenceDates(UpdateEventDTO $eventData, EventDomainObject $existingEvent): void
+ {
+ if ($existingEvent->getType() !== EventType::SINGLE->name) {
+ return;
+ }
+
+ if ($eventData->start_date === null) {
+ return;
+ }
+
+ $timezone = $eventData->timezone ?? $existingEvent->getTimezone();
+
+ $occurrence = $this->occurrenceRepository->findFirstWhere([
+ 'event_id' => $eventData->id,
+ ]);
+
+ if ($occurrence === null) {
+ return;
+ }
+
+ $this->occurrenceRepository->updateWhere(
+ attributes: [
+ 'start_date' => DateHelper::convertToUTC($eventData->start_date, $timezone),
+ 'end_date' => $eventData->end_date
+ ? DateHelper::convertToUTC($eventData->end_date, $timezone)
+ : null,
+ ],
+ where: [
+ 'id' => $occurrence->getId(),
+ ],
+ );
}
private function getUpdateEvent(UpdateEventDTO $eventData): EventDomainObject
diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandler.php
new file mode 100644
index 0000000000..1ece2cffa4
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandler.php
@@ -0,0 +1,404 @@
+databaseManager->transaction(function () use ($dto) {
+ $occurrences = $this->occurrenceRepository->findWhere(
+ where: [
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $dto->event_id,
+ ],
+ );
+
+ $eligible = $this->filterEligible($occurrences, $dto);
+
+ return match ($dto->action) {
+ BulkOccurrenceAction::CANCEL => $this->handleCancel($dto, $eligible),
+ BulkOccurrenceAction::DELETE => $this->handleDelete($dto, $eligible),
+ BulkOccurrenceAction::UPDATE => $this->handleUpdate($dto, $eligible),
+ };
+ });
+ }
+
+ private function filterEligible(Collection $occurrences, BulkUpdateOccurrencesDTO $dto): Collection
+ {
+ return $occurrences->filter(function (EventOccurrenceDomainObject $occurrence) use ($dto) {
+ if (! empty($dto->occurrence_ids) && ! in_array($occurrence->getId(), $dto->occurrence_ids, true)) {
+ return false;
+ }
+
+ if ($dto->action !== BulkOccurrenceAction::DELETE && $occurrence->getStatus() === EventOccurrenceStatus::CANCELLED->name) {
+ return false;
+ }
+
+ if ($dto->future_only && $occurrence->isPast()) {
+ return false;
+ }
+
+ if ($dto->skip_overridden && $occurrence->getIsOverridden()) {
+ return false;
+ }
+
+ return true;
+ });
+ }
+
+ private function handleCancel(BulkUpdateOccurrencesDTO $dto, Collection $eligible): BulkUpdateOccurrencesResultDTO
+ {
+ $ids = $this->collectIds($eligible);
+
+ if (! empty($ids)) {
+ BulkCancelOccurrencesJob::dispatch($dto->event_id, $ids, $dto->refund_orders);
+ }
+
+ return new BulkUpdateOccurrencesResultDTO(
+ updated_count: count($ids),
+ updated_ids: $ids,
+ );
+ }
+
+ private function handleDelete(BulkUpdateOccurrencesDTO $dto, Collection $eligible): BulkUpdateOccurrencesResultDTO
+ {
+ $eligibleIds = $this->collectIds($eligible);
+
+ if (empty($eligibleIds)) {
+ return new BulkUpdateOccurrencesResultDTO(updated_count: 0, updated_ids: []);
+ }
+
+ // Two batched lookups instead of 2N countWhere calls — bulk delete
+ // during regenerate can touch hundreds of occurrences. Mirrors the
+ // single-delete handler: attendees can exist without order items
+ // (imports, legacy data), so both checks must run.
+ $idsWithOrders = $this->orderItemRepository
+ ->findWhereIn(
+ field: OrderItemDomainObjectAbstract::EVENT_OCCURRENCE_ID,
+ values: $eligibleIds,
+ columns: [OrderItemDomainObjectAbstract::EVENT_OCCURRENCE_ID],
+ )
+ ->map(fn (OrderItemDomainObject $item) => $item->getEventOccurrenceId())
+ ->flip()
+ ->all();
+
+ $idsWithAttendees = $this->attendeeRepository
+ ->findWhereIn(
+ field: AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID,
+ values: $eligibleIds,
+ columns: [AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID],
+ )
+ ->map(fn (AttendeeDomainObject $attendee) => $attendee->getEventOccurrenceId())
+ ->flip()
+ ->all();
+
+ $deletableIds = [];
+ $deletableStartDates = [];
+
+ foreach ($eligible as $occurrence) {
+ $id = $occurrence->getId();
+
+ if (! isset($idsWithOrders[$id]) && ! isset($idsWithAttendees[$id])) {
+ $deletableIds[] = $id;
+ $deletableStartDates[] = $occurrence->getStartDate();
+ }
+ }
+
+ if (! empty($deletableIds)) {
+ // FK is nullOnDelete; without this, WAITING/OFFERED entries scoped to
+ // the deleted occurrences become orphans and crash ProcessWaitlistService
+ // on the next CapacityChangedEvent.
+ $this->waitlistEntryRepository->updateWhere(
+ attributes: [
+ 'status' => WaitlistEntryStatus::CANCELLED->name,
+ ],
+ where: [
+ 'event_id' => $dto->event_id,
+ ['event_occurrence_id', 'in', $deletableIds],
+ ['status', 'in', [
+ WaitlistEntryStatus::WAITING->name,
+ WaitlistEntryStatus::OFFERED->name,
+ ]],
+ ],
+ );
+
+ $this->occurrenceRepository->deleteWhere([
+ [EventOccurrenceDomainObjectAbstract::ID, 'in', $deletableIds],
+ ]);
+
+ $this->exclusionService->addExclusions($dto->event_id, $deletableStartDates);
+ }
+
+ return new BulkUpdateOccurrencesResultDTO(
+ updated_count: count($deletableIds),
+ updated_ids: $deletableIds,
+ );
+ }
+
+ private function handleUpdate(BulkUpdateOccurrencesDTO $dto, Collection $eligible): BulkUpdateOccurrencesResultDTO
+ {
+ $requiresPerRow = $dto->start_time_shift !== null
+ || $dto->end_time_shift !== null
+ || $dto->duration_minutes !== null;
+
+ if ($requiresPerRow) {
+ return $this->applyPerRowUpdate($dto, $eligible);
+ }
+
+ return $this->applyUniformUpdate($dto, $eligible);
+ }
+
+ private function applyUniformUpdate(BulkUpdateOccurrencesDTO $dto, Collection $eligible): BulkUpdateOccurrencesResultDTO
+ {
+ $attributes = $this->buildUniformAttributes($dto);
+
+ if (empty($attributes)) {
+ return new BulkUpdateOccurrencesResultDTO(updated_count: 0, updated_ids: []);
+ }
+
+ $capacityChanged = array_key_exists(EventOccurrenceDomainObjectAbstract::CAPACITY, $attributes);
+
+ // Capacity changes diverge the occurrence from the rule's defaults —
+ // flag overridden so the next regenerate doesn't reset them. Label-only
+ // edits don't pin against regenerate (parity with single-edit handler).
+ if ($capacityChanged) {
+ $attributes[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] = true;
+ }
+
+ $ids = $this->collectIds($eligible);
+
+ if (empty($ids)) {
+ return new BulkUpdateOccurrencesResultDTO(updated_count: 0, updated_ids: []);
+ }
+
+ $this->occurrenceRepository->updateWhere(
+ attributes: $attributes,
+ where: [
+ [EventOccurrenceDomainObjectAbstract::ID, 'in', $ids],
+ ],
+ );
+
+ // Mirror the single-edit handler's status reconciliation: capacity is
+ // uniform across the batch but each occurrence carries its own
+ // used_capacity, so SOLD_OUT/ACTIVE has to flip per row. Two scoped
+ // updateWheres avoid an N-row loop while still respecting the uniform
+ // path. CANCELLED is left untouched on either side — its lifecycle
+ // belongs to the dedicated cancel/reactivate handlers.
+ if ($capacityChanged) {
+ $this->reconcileStatusForUniformCapacity(
+ ids: $ids,
+ newCapacity: $attributes[EventOccurrenceDomainObjectAbstract::CAPACITY],
+ );
+ }
+
+ return new BulkUpdateOccurrencesResultDTO(
+ updated_count: count($ids),
+ updated_ids: $ids,
+ );
+ }
+
+ /**
+ * @param int[] $ids
+ */
+ private function reconcileStatusForUniformCapacity(array $ids, ?int $newCapacity): void
+ {
+ // Re-open any sold-out occurrence that now has headroom (or is now
+ // unlimited).
+ $reopenWhere = [
+ [EventOccurrenceDomainObjectAbstract::ID, 'in', $ids],
+ [EventOccurrenceDomainObjectAbstract::STATUS, '=', EventOccurrenceStatus::SOLD_OUT->name],
+ ];
+ if ($newCapacity !== null) {
+ $reopenWhere[] = [EventOccurrenceDomainObjectAbstract::USED_CAPACITY, '<', $newCapacity];
+ }
+
+ $this->occurrenceRepository->updateWhere(
+ attributes: [
+ EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::ACTIVE->name,
+ ],
+ where: $reopenWhere,
+ );
+
+ // Mark sold-out any active occurrence that the new ceiling has now
+ // exceeded. Only meaningful when the new capacity is finite.
+ if ($newCapacity !== null) {
+ $this->occurrenceRepository->updateWhere(
+ attributes: [
+ EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::SOLD_OUT->name,
+ ],
+ where: [
+ [EventOccurrenceDomainObjectAbstract::ID, 'in', $ids],
+ [EventOccurrenceDomainObjectAbstract::STATUS, '=', EventOccurrenceStatus::ACTIVE->name],
+ [EventOccurrenceDomainObjectAbstract::USED_CAPACITY, '>=', $newCapacity],
+ ],
+ );
+ }
+ }
+
+ private function applyPerRowUpdate(BulkUpdateOccurrencesDTO $dto, Collection $eligible): BulkUpdateOccurrencesResultDTO
+ {
+ $updatedIds = [];
+
+ foreach ($eligible as $occurrence) {
+ $attributes = $this->buildPerRowAttributes($dto, $occurrence);
+
+ if (! empty($attributes)) {
+ // Same SOLD_OUT/ACTIVE reconciliation as the single-edit and
+ // uniform-bulk paths — when the bulk operation also tweaks
+ // capacity (e.g. shift_times + clear_capacity in one save) the
+ // status has to follow the new ceiling per row.
+ if (array_key_exists(EventOccurrenceDomainObjectAbstract::CAPACITY, $attributes)) {
+ $reconciled = $this->reconcileCapacityStatus(
+ currentStatus: $occurrence->getStatus(),
+ newCapacity: $attributes[EventOccurrenceDomainObjectAbstract::CAPACITY],
+ usedCapacity: $occurrence->getUsedCapacity() ?? 0,
+ );
+ if ($reconciled !== null) {
+ $attributes[EventOccurrenceDomainObjectAbstract::STATUS] = $reconciled;
+ }
+ }
+
+ $this->occurrenceRepository->updateWhere(
+ attributes: $attributes,
+ where: [EventOccurrenceDomainObjectAbstract::ID => $occurrence->getId()],
+ );
+ $updatedIds[] = $occurrence->getId();
+ }
+ }
+
+ return new BulkUpdateOccurrencesResultDTO(
+ updated_count: count($updatedIds),
+ updated_ids: $updatedIds,
+ );
+ }
+
+ /**
+ * Returns the status the occurrence should sit at after a capacity edit,
+ * or null if the existing status is correct. Mirrors UpdateEventOccurrenceHandler
+ * — keep both in sync. Never touches CANCELLED.
+ */
+ private function reconcileCapacityStatus(
+ string $currentStatus,
+ ?int $newCapacity,
+ int $usedCapacity,
+ ): ?string {
+ if ($currentStatus === EventOccurrenceStatus::CANCELLED->name) {
+ return null;
+ }
+
+ if ($newCapacity === null) {
+ return $currentStatus === EventOccurrenceStatus::SOLD_OUT->name
+ ? EventOccurrenceStatus::ACTIVE->name
+ : null;
+ }
+
+ $shouldBeSoldOut = $usedCapacity >= $newCapacity;
+
+ if ($shouldBeSoldOut && $currentStatus !== EventOccurrenceStatus::SOLD_OUT->name) {
+ return EventOccurrenceStatus::SOLD_OUT->name;
+ }
+
+ if (! $shouldBeSoldOut && $currentStatus === EventOccurrenceStatus::SOLD_OUT->name) {
+ return EventOccurrenceStatus::ACTIVE->name;
+ }
+
+ return null;
+ }
+
+ private function buildUniformAttributes(BulkUpdateOccurrencesDTO $dto): array
+ {
+ $attributes = [];
+
+ if ($dto->clear_capacity) {
+ $attributes[EventOccurrenceDomainObjectAbstract::CAPACITY] = null;
+ } elseif ($dto->capacity !== null) {
+ $attributes[EventOccurrenceDomainObjectAbstract::CAPACITY] = $dto->capacity;
+ }
+
+ if ($dto->clear_label) {
+ $attributes[EventOccurrenceDomainObjectAbstract::LABEL] = null;
+ } elseif ($dto->label !== null) {
+ $attributes[EventOccurrenceDomainObjectAbstract::LABEL] = $dto->label;
+ }
+
+ return $attributes;
+ }
+
+ private function buildPerRowAttributes(BulkUpdateOccurrencesDTO $dto, EventOccurrenceDomainObject $occurrence): array
+ {
+ $attributes = $this->buildUniformAttributes($dto);
+ $startEndChanged = false;
+
+ if ($dto->start_time_shift !== null && $dto->start_time_shift !== 0) {
+ $start = Carbon::parse($occurrence->getStartDate(), 'UTC');
+ $start->addMinutes($dto->start_time_shift);
+ $attributes[EventOccurrenceDomainObjectAbstract::START_DATE] = $start->toDateTimeString();
+ $startEndChanged = true;
+ }
+
+ if ($dto->end_time_shift !== null && $dto->end_time_shift !== 0 && $occurrence->getEndDate() !== null) {
+ $end = Carbon::parse($occurrence->getEndDate(), 'UTC');
+ $end->addMinutes($dto->end_time_shift);
+ $attributes[EventOccurrenceDomainObjectAbstract::END_DATE] = $end->toDateTimeString();
+ $startEndChanged = true;
+ }
+
+ if ($dto->duration_minutes !== null) {
+ $startDate = $attributes[EventOccurrenceDomainObjectAbstract::START_DATE] ?? $occurrence->getStartDate();
+ $start = Carbon::parse($startDate, 'UTC');
+ $attributes[EventOccurrenceDomainObjectAbstract::END_DATE] = $start->copy()->addMinutes($dto->duration_minutes)->toDateTimeString();
+ $startEndChanged = true;
+ }
+
+ // Pin overridden so the next regenerate doesn't revert the shift.
+ // Capacity-only changes already flagged via buildUniformAttributes path.
+ if ($startEndChanged
+ || array_key_exists(EventOccurrenceDomainObjectAbstract::CAPACITY, $attributes)
+ ) {
+ $attributes[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] = true;
+ }
+
+ return $attributes;
+ }
+
+ private function collectIds(Collection $eligible): array
+ {
+ return $eligible->map(fn (EventOccurrenceDomainObject $o) => $o->getId())->values()->all();
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/CancelOccurrenceHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/CancelOccurrenceHandler.php
new file mode 100644
index 0000000000..d57214b5da
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/CancelOccurrenceHandler.php
@@ -0,0 +1,85 @@
+databaseManager->transaction(function () use ($eventId, $occurrenceId, &$wasCancelled) {
+ // Lock to prevent concurrent cancels from double-dispatching refund
+ // and notification side-effects below.
+ $occurrence = $this->occurrenceRepository->findByIdLocked($occurrenceId);
+
+ if (! $occurrence || $occurrence->getEventId() !== $eventId) {
+ throw new ResourceNotFoundException(
+ __('Occurrence :id not found for event :eventId', [
+ 'id' => $occurrenceId,
+ 'eventId' => $eventId,
+ ])
+ );
+ }
+
+ if ($occurrence->getStatus() === EventOccurrenceStatus::CANCELLED->name) {
+ return $occurrence;
+ }
+
+ $updated = $this->occurrenceRepository->updateFromArray(
+ id: $occurrenceId,
+ attributes: [
+ EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::CANCELLED->name,
+ ],
+ );
+
+ $this->cancelAttendeesService->cancelForOccurrence($eventId, $occurrenceId);
+
+ $this->exclusionService->addExclusions($eventId, [$occurrence->getStartDate()]);
+
+ $wasCancelled = true;
+
+ return $updated;
+ });
+
+ if ($wasCancelled) {
+ event(new OccurrenceCancelledEvent(
+ eventId: $eventId,
+ occurrenceId: $occurrenceId,
+ refundOrders: $refundOrders,
+ ));
+
+ event(new OccurrenceEvent(
+ type: DomainEventType::OCCURRENCE_CANCELLED,
+ occurrenceId: $occurrenceId,
+ ));
+ }
+
+ return $updated;
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandler.php
new file mode 100644
index 0000000000..186363c99a
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandler.php
@@ -0,0 +1,42 @@
+databaseManager->transaction(function () use ($dto) {
+ return $this->occurrenceRepository->create([
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $dto->event_id,
+ EventOccurrenceDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::OCCURRENCE_PREFIX),
+ EventOccurrenceDomainObjectAbstract::START_DATE => $dto->start_date,
+ EventOccurrenceDomainObjectAbstract::END_DATE => $dto->end_date,
+ EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::ACTIVE->name,
+ EventOccurrenceDomainObjectAbstract::CAPACITY => $dto->capacity,
+ EventOccurrenceDomainObjectAbstract::USED_CAPACITY => 0,
+ EventOccurrenceDomainObjectAbstract::LABEL => $dto->label,
+ EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => $dto->is_overridden,
+ ]);
+ });
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/DTO/BulkUpdateOccurrencesDTO.php b/backend/app/Services/Application/Handlers/EventOccurrence/DTO/BulkUpdateOccurrencesDTO.php
new file mode 100644
index 0000000000..c16ef596fb
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/DTO/BulkUpdateOccurrencesDTO.php
@@ -0,0 +1,27 @@
+databaseManager->transaction(function () use ($eventId, $occurrenceId) {
+ $occurrence = $this->occurrenceRepository->findFirstWhere([
+ EventOccurrenceDomainObjectAbstract::ID => $occurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
+ ]);
+
+ if (! $occurrence) {
+ throw new ResourceNotFoundException(
+ __('Occurrence :id not found for event :eventId', [
+ 'id' => $occurrenceId,
+ 'eventId' => $eventId,
+ ])
+ );
+ }
+
+ $orderCount = $this->orderItemRepository->countWhere([
+ 'event_occurrence_id' => $occurrenceId,
+ ]);
+
+ if ($orderCount > 0) {
+ throw ValidationException::withMessages([
+ 'occurrence' => __('Cannot delete an occurrence that has orders. Cancel it instead.'),
+ ]);
+ }
+
+ $attendeeCount = $this->attendeeRepository->countWhere([
+ 'event_occurrence_id' => $occurrenceId,
+ ]);
+
+ if ($attendeeCount > 0) {
+ throw ValidationException::withMessages([
+ 'occurrence' => __('Cannot delete an occurrence that has attendees. Cancel it instead.'),
+ ]);
+ }
+
+ $occurrenceStartDate = $occurrence->getStartDate();
+
+ // Cancel waitlist entries scoped to this occurrence BEFORE deleting
+ // it. The FK is `nullOnDelete`, so without this any WAITING/OFFERED
+ // entry would have its event_occurrence_id nulled — leaving an
+ // orphan that ProcessWaitlistService can't resolve, which throws
+ // ResourceConflictException and crashes the offer batch on the
+ // next CapacityChangedEvent.
+ $this->waitlistEntryRepository->updateWhere(
+ attributes: [
+ 'status' => WaitlistEntryStatus::CANCELLED->name,
+ ],
+ where: [
+ 'event_id' => $eventId,
+ 'event_occurrence_id' => $occurrenceId,
+ ['status', 'in', [
+ WaitlistEntryStatus::WAITING->name,
+ WaitlistEntryStatus::OFFERED->name,
+ ]],
+ ],
+ );
+
+ $this->occurrenceRepository->deleteWhere([
+ EventOccurrenceDomainObjectAbstract::ID => $occurrenceId,
+ ]);
+
+ // For recurring events, the rule itself produces the candidate
+ // dates on the next regenerate. Without recording the deletion in
+ // excluded_occurrences, the regenerate parses the rule, sees the same
+ // candidate, and recreates the occurrence — silently undoing the
+ // delete. Mirror the cancel handler's behaviour here.
+ $this->appendOccurrenceToRecurrenceExclusions($eventId, $occurrenceStartDate);
+ });
+ }
+
+ private function appendOccurrenceToRecurrenceExclusions(int $eventId, string $startDate): void
+ {
+ $event = $this->eventRepository->findByIdLocked($eventId);
+
+ if ($event === null || $event->getType() !== EventType::RECURRING->name) {
+ return;
+ }
+
+ $recurrenceRule = $event->getRecurrenceRule() ?? [];
+ if (is_string($recurrenceRule)) {
+ $recurrenceRule = json_decode($recurrenceRule, true, 512, JSON_THROW_ON_ERROR);
+ }
+
+ $excludedOccurrences = $recurrenceRule['excluded_occurrences'] ?? [];
+ $startDateTime = CarbonImmutable::parse($startDate, 'UTC')
+ ->setTimezone($event->getTimezone() ?? 'UTC')
+ ->format('Y-m-d H:i');
+
+ if (in_array($startDateTime, $excludedOccurrences, true)) {
+ return;
+ }
+
+ $excludedOccurrences[] = $startDateTime;
+ $recurrenceRule['excluded_occurrences'] = $excludedOccurrences;
+
+ $this->eventRepository->updateFromArray(
+ id: $eventId,
+ attributes: [
+ EventDomainObjectAbstract::RECURRENCE_RULE => $recurrenceRule,
+ ],
+ );
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandler.php
new file mode 100644
index 0000000000..c65f79c0bf
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandler.php
@@ -0,0 +1,61 @@
+eventRepository->findById($dto->event_id);
+ $timezone = $event->getTimezone() ?? 'UTC';
+
+ $previewCount = $this->ruleParserService->parse($dto->recurrence_rule, $timezone)->count();
+
+ if ($previewCount > RecurrenceRuleParserService::MAX_OCCURRENCES) {
+ throw ValidationException::withMessages([
+ 'recurrence_rule' => [
+ __('This rule would generate too many occurrences. Please reduce the date range or frequency, or contact support.'),
+ ],
+ ]);
+ }
+
+ return $this->databaseManager->transaction(function () use ($dto, $event) {
+ $this->eventRepository->updateFromArray(
+ id: $event->getId(),
+ attributes: [
+ EventDomainObjectAbstract::RECURRENCE_RULE => $dto->recurrence_rule,
+ EventDomainObjectAbstract::TYPE => EventType::RECURRING->name,
+ ],
+ );
+
+ $event->setRecurrenceRule($dto->recurrence_rule);
+
+ return $this->generatorService->generate($event, $dto->recurrence_rule);
+ });
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandler.php
new file mode 100644
index 0000000000..998c326bc4
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandler.php
@@ -0,0 +1,41 @@
+occurrenceRepository
+ ->loadRelation(EventOccurrenceStatisticDomainObject::class)
+ ->findFirstWhere([
+ EventOccurrenceDomainObjectAbstract::ID => $occurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
+ ]);
+
+ if (!$occurrence) {
+ throw new ResourceNotFoundException(
+ __('Occurrence :id not found for event :eventId', [
+ 'id' => $occurrenceId,
+ 'eventId' => $eventId,
+ ])
+ );
+ }
+
+ return $occurrence;
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandler.php
new file mode 100644
index 0000000000..66f945a8c7
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandler.php
@@ -0,0 +1,28 @@
+occurrenceRepository;
+
+ if ($includeStats) {
+ $repository = $repository->loadRelation(EventOccurrenceStatisticDomainObject::class);
+ }
+
+ return $repository->findByEventId($eventId, $queryParams);
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandler.php
new file mode 100644
index 0000000000..8a3020cc22
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandler.php
@@ -0,0 +1,40 @@
+occurrenceRepository->findFirstWhere([
+ EventOccurrenceDomainObjectAbstract::ID => $occurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
+ ]);
+
+ if (!$occurrence) {
+ throw new ResourceNotFoundException(
+ __('Occurrence :id not found for this event', ['id' => $occurrenceId])
+ );
+ }
+
+ return $this->visibilityRepository->findWhere([
+ ProductOccurrenceVisibilityDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrenceId,
+ ]);
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/DTO/UpsertPriceOverrideDTO.php b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/DTO/UpsertPriceOverrideDTO.php
new file mode 100644
index 0000000000..e2a18c80b5
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/DTO/UpsertPriceOverrideDTO.php
@@ -0,0 +1,17 @@
+databaseManager->transaction(function () use ($eventId, $occurrenceId, $overrideId) {
+ $occurrence = $this->occurrenceRepository->findFirstWhere([
+ EventOccurrenceDomainObjectAbstract::ID => $occurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
+ ]);
+
+ if (!$occurrence) {
+ throw new ResourceNotFoundException(
+ __('Occurrence :id not found for event :eventId', [
+ 'id' => $occurrenceId,
+ 'eventId' => $eventId,
+ ])
+ );
+ }
+
+ $override = $this->overrideRepository->findFirstWhere([
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::ID => $overrideId,
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrenceId,
+ ]);
+
+ if (!$override) {
+ throw new ResourceNotFoundException(
+ __('Price override :id not found for occurrence :occurrenceId', [
+ 'id' => $overrideId,
+ 'occurrenceId' => $occurrenceId,
+ ])
+ );
+ }
+
+ $this->overrideRepository->deleteWhere([
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::ID => $overrideId,
+ ]);
+ });
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandler.php
new file mode 100644
index 0000000000..3a63d9b187
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandler.php
@@ -0,0 +1,40 @@
+occurrenceRepository->findFirstWhere([
+ EventOccurrenceDomainObjectAbstract::ID => $occurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
+ ]);
+
+ if (!$occurrence) {
+ throw new ResourceNotFoundException(
+ __('Occurrence :id not found for this event', ['id' => $occurrenceId])
+ );
+ }
+
+ return $this->overrideRepository->findWhere([
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrenceId,
+ ]);
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandler.php
new file mode 100644
index 0000000000..7bdee33228
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandler.php
@@ -0,0 +1,87 @@
+occurrenceRepository->findFirstWhere([
+ EventOccurrenceDomainObjectAbstract::ID => $dto->event_occurrence_id,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $dto->event_id,
+ ]);
+
+ if (!$occurrence) {
+ throw new ResourceNotFoundException(
+ __('Occurrence :id not found for this event', ['id' => $dto->event_occurrence_id])
+ );
+ }
+
+ $productPrice = $this->productPriceRepository->findFirst($dto->product_price_id);
+ if (!$productPrice) {
+ throw new ResourceNotFoundException(
+ __('Product price :id not found', ['id' => $dto->product_price_id])
+ );
+ }
+
+ $product = $this->productRepository->findFirstWhere([
+ 'id' => $productPrice->getProductId(),
+ 'event_id' => $dto->event_id,
+ ]);
+
+ if (!$product) {
+ throw new ResourceNotFoundException(
+ __('Product price :id does not belong to this event', ['id' => $dto->product_price_id])
+ );
+ }
+
+ return $this->databaseManager->transaction(function () use ($dto) {
+ $existing = $this->overrideRepository->findFirstWhere([
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => $dto->event_occurrence_id,
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::PRODUCT_PRICE_ID => $dto->product_price_id,
+ ]);
+
+ if ($existing) {
+ return $this->overrideRepository->updateFromArray(
+ id: $existing->getId(),
+ attributes: [
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::PRICE => $dto->price,
+ ],
+ );
+ }
+
+ return $this->overrideRepository->create([
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => $dto->event_occurrence_id,
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::PRODUCT_PRICE_ID => $dto->product_price_id,
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::PRICE => $dto->price,
+ ]);
+ });
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/ReactivateOccurrenceHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/ReactivateOccurrenceHandler.php
new file mode 100644
index 0000000000..23f184797f
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/ReactivateOccurrenceHandler.php
@@ -0,0 +1,60 @@
+databaseManager->transaction(function () use ($eventId, $occurrenceId) {
+ $occurrence = $this->occurrenceRepository->findByIdLocked($occurrenceId);
+
+ if (! $occurrence || $occurrence->getEventId() !== $eventId) {
+ throw new ResourceNotFoundException(
+ __('Occurrence :id not found for event :eventId', [
+ 'id' => $occurrenceId,
+ 'eventId' => $eventId,
+ ])
+ );
+ }
+
+ if ($occurrence->getStatus() !== EventOccurrenceStatus::CANCELLED->name) {
+ throw ValidationException::withMessages([
+ 'status' => __('Only cancelled dates can be reactivated.'),
+ ]);
+ }
+
+ $updated = $this->occurrenceRepository->updateFromArray(
+ id: $occurrenceId,
+ attributes: [
+ EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::ACTIVE->name,
+ ],
+ );
+
+ $this->exclusionService->removeExclusion($eventId, $occurrence->getStartDate());
+
+ return $updated;
+ });
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandler.php
new file mode 100644
index 0000000000..13780dcc39
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandler.php
@@ -0,0 +1,134 @@
+databaseManager->transaction(function () use ($occurrenceId, $dto) {
+ $occurrence = $this->occurrenceRepository->findFirstWhere([
+ EventOccurrenceDomainObjectAbstract::ID => $occurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $dto->event_id,
+ ]);
+
+ if (! $occurrence) {
+ throw new ResourceNotFoundException(
+ __('Occurrence :id not found for event :eventId', [
+ 'id' => $occurrenceId,
+ 'eventId' => $dto->event_id,
+ ])
+ );
+ }
+
+ // Only flag as overridden when a rule-determining field actually changes
+ // away from what the rule would produce. Label edits and no-op saves
+ // shouldn't pin the row against rule regenerates. Dates are normalized
+ // to a canonical parsed form — `DateHelper::convertToUTC` returns a
+ // different string format than the DB-hydrated getStartDate(), so
+ // string comparison alone would always register as changed.
+ $isOverride = $occurrence->getIsOverridden()
+ || $this->datesDiffer($dto->start_date, $occurrence->getStartDate())
+ || $this->datesDiffer($dto->end_date, $occurrence->getEndDate())
+ || $dto->capacity !== $occurrence->getCapacity();
+
+ // CANCELLED is intentionally not writable here — lifecycle
+ // transitions (cancel / reactivate) live in their own handlers
+ // and fan out into refund / attendee / recurrence-exclusion side
+ // effects this handler does not perform. SOLD_OUT/ACTIVE on the
+ // other hand are capacity-derived: ProductQuantityUpdateService
+ // flips between them whenever used_capacity crosses capacity, so
+ // a capacity edit here has to do the same reconciliation or a
+ // sold-out date stays blocked after capacity is increased / cleared,
+ // and an over-capacity active date stays visually open.
+ $attributes = [
+ EventOccurrenceDomainObjectAbstract::START_DATE => $dto->start_date,
+ EventOccurrenceDomainObjectAbstract::END_DATE => $dto->end_date,
+ EventOccurrenceDomainObjectAbstract::CAPACITY => $dto->capacity,
+ EventOccurrenceDomainObjectAbstract::LABEL => $dto->label,
+ EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => $isOverride,
+ ];
+
+ $reconciledStatus = $this->reconcileCapacityStatus(
+ currentStatus: $occurrence->getStatus(),
+ newCapacity: $dto->capacity,
+ usedCapacity: $occurrence->getUsedCapacity() ?? 0,
+ );
+ if ($reconciledStatus !== null) {
+ $attributes[EventOccurrenceDomainObjectAbstract::STATUS] = $reconciledStatus;
+ }
+
+ return $this->occurrenceRepository->updateFromArray(
+ id: $occurrence->getId(),
+ attributes: $attributes,
+ );
+ });
+ }
+
+ /**
+ * Returns the status the occurrence should sit at after a capacity edit,
+ * or null if the existing status is correct. Only flips between ACTIVE
+ * and SOLD_OUT — never touches CANCELLED, which is owned by the dedicated
+ * cancel/reactivate handlers.
+ */
+ private function reconcileCapacityStatus(
+ string $currentStatus,
+ ?int $newCapacity,
+ int $usedCapacity,
+ ): ?string {
+ if ($currentStatus === EventOccurrenceStatus::CANCELLED->name) {
+ return null;
+ }
+
+ // Unlimited (null) capacity can never be sold out.
+ if ($newCapacity === null) {
+ return $currentStatus === EventOccurrenceStatus::SOLD_OUT->name
+ ? EventOccurrenceStatus::ACTIVE->name
+ : null;
+ }
+
+ $shouldBeSoldOut = $usedCapacity >= $newCapacity;
+
+ if ($shouldBeSoldOut && $currentStatus !== EventOccurrenceStatus::SOLD_OUT->name) {
+ return EventOccurrenceStatus::SOLD_OUT->name;
+ }
+
+ if (! $shouldBeSoldOut && $currentStatus === EventOccurrenceStatus::SOLD_OUT->name) {
+ return EventOccurrenceStatus::ACTIVE->name;
+ }
+
+ return null;
+ }
+
+ private function datesDiffer(?string $a, ?string $b): bool
+ {
+ if ($a === null && $b === null) {
+ return false;
+ }
+ if ($a === null || $b === null) {
+ return true;
+ }
+
+ return ! Carbon::parse($a, 'UTC')->equalTo(Carbon::parse($b, 'UTC'));
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandler.php
new file mode 100644
index 0000000000..84134e228a
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandler.php
@@ -0,0 +1,85 @@
+occurrenceRepository->findFirstWhere([
+ EventOccurrenceDomainObjectAbstract::ID => $dto->event_occurrence_id,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $dto->event_id,
+ ]);
+
+ if (!$occurrence) {
+ throw new ResourceNotFoundException(
+ __('Occurrence :id not found for this event', ['id' => $dto->event_occurrence_id])
+ );
+ }
+
+ return $this->databaseManager->transaction(function () use ($dto) {
+ $this->visibilityRepository->deleteWhere([
+ ProductOccurrenceVisibilityDomainObjectAbstract::EVENT_OCCURRENCE_ID => $dto->event_occurrence_id,
+ ]);
+
+ $allProducts = $this->productRepository->findWhere([
+ ProductDomainObjectAbstract::EVENT_ID => $dto->event_id,
+ ]);
+
+ $allProductIds = $allProducts->pluck('id')->sort()->values()->toArray();
+ $selectedProductIds = collect($dto->product_ids)->sort()->values()->toArray();
+
+ $invalidIds = array_diff($selectedProductIds, $allProductIds);
+ if (!empty($invalidIds)) {
+ throw new ResourceNotFoundException(
+ __('One or more product IDs do not belong to this event')
+ );
+ }
+
+ if ($allProductIds === $selectedProductIds) {
+ return collect();
+ }
+
+ // The request rule enforces `distinct`, but dedupe defensively in
+ // case a future caller bypasses request validation — the unique
+ // constraint on (event_occurrence_id, product_id) would otherwise
+ // surface as a 500 mid-batch.
+ foreach (array_unique($dto->product_ids) as $productId) {
+ $this->visibilityRepository->create([
+ ProductOccurrenceVisibilityDomainObjectAbstract::EVENT_OCCURRENCE_ID => $dto->event_occurrence_id,
+ ProductOccurrenceVisibilityDomainObjectAbstract::PRODUCT_ID => $productId,
+ ]);
+ }
+
+ return $this->visibilityRepository->findWhere([
+ ProductOccurrenceVisibilityDomainObjectAbstract::EVENT_OCCURRENCE_ID => $dto->event_occurrence_id,
+ ]);
+ });
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandler.php b/backend/app/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandler.php
index d741e3e764..2b9e301e9f 100644
--- a/backend/app/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandler.php
+++ b/backend/app/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandler.php
@@ -3,9 +3,10 @@
namespace HiEvents\Services\Application\Handlers\EventSettings;
use Brick\Money\Currency;
-use HiEvents\DomainObjects\AccountConfigurationDomainObject;
+use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\OrganizerConfigurationDomainObject;
+use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\Repository\Eloquent\Value\Relationship;
-use HiEvents\Repository\Interfaces\AccountRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Services\Application\Handlers\EventSettings\DTO\GetPlatformFeePreviewDTO;
use HiEvents\Services\Application\Handlers\EventSettings\DTO\PlatformFeePreviewResponseDTO;
@@ -14,7 +15,6 @@
class GetPlatformFeePreviewHandler
{
public function __construct(
- private readonly AccountRepositoryInterface $accountRepository,
private readonly EventRepositoryInterface $eventRepository,
private readonly CurrencyConversionClientInterface $currencyConversionClient,
)
@@ -23,17 +23,22 @@ public function __construct(
public function handle(GetPlatformFeePreviewDTO $dto): PlatformFeePreviewResponseDTO
{
- $event = $this->eventRepository->findById($dto->eventId);
- $eventCurrency = $event->getCurrency();
-
- $account = $this->accountRepository
+ /** @var EventDomainObject $event */
+ $event = $this->eventRepository
->loadRelation(new Relationship(
- domainObject: AccountConfigurationDomainObject::class,
- name: 'configuration',
+ domainObject: OrganizerDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: OrganizerConfigurationDomainObject::class,
+ name: 'organizer_configuration',
+ ),
+ ],
+ name: 'organizer',
))
- ->findByEventId($dto->eventId);
+ ->findById($dto->eventId);
- $configuration = $account->getConfiguration();
+ $eventCurrency = $event->getCurrency();
+ $configuration = $event->getOrganizer()?->getOrganizerConfiguration();
if ($configuration === null) {
return new PlatformFeePreviewResponseDTO(
diff --git a/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php b/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php
index ef6eb83ce4..37d10e8d11 100644
--- a/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php
+++ b/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php
@@ -8,21 +8,26 @@
class SendMessageDTO extends BaseDTO
{
public function __construct(
- public readonly int $account_id,
- public readonly int $event_id,
- public readonly string $subject,
- public readonly string $message,
+ public readonly int $account_id,
+ public readonly int $event_id,
+ public readonly string $subject,
+ public readonly string $message,
public readonly MessageTypeEnum $type,
- public readonly bool $is_test,
- public readonly bool $send_copy_to_current_user,
- public readonly int $sent_by_user_id,
- public readonly ?int $order_id = null,
- public readonly ?array $order_statuses = [],
- public readonly ?int $id = null,
- public readonly ?array $attendee_ids = [],
- public readonly ?array $product_ids = [],
- public readonly ?string $scheduled_at = null,
- )
- {
- }
+ public readonly bool $is_test,
+ public readonly bool $send_copy_to_current_user,
+ public readonly int $sent_by_user_id,
+ public readonly ?int $order_id = null,
+ public readonly ?array $order_statuses = [],
+ public readonly ?int $id = null,
+ public readonly ?array $attendee_ids = [],
+ public readonly ?array $product_ids = [],
+ public readonly ?string $scheduled_at = null,
+ public readonly ?int $event_occurrence_id = null,
+ /**
+ * When set, filters recipients to attendees/orders tied to any of these
+ * occurrences. Mutually exclusive with event_occurrence_id (handler
+ * prefers this when both are provided).
+ */
+ public readonly ?array $event_occurrence_ids = null,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php b/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php
index 9f1b7b9872..57b7b639b7 100644
--- a/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php
+++ b/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php
@@ -27,18 +27,16 @@
class SendMessageHandler
{
public function __construct(
- private readonly OrderRepositoryInterface $orderRepository,
- private readonly AttendeeRepositoryInterface $attendeeRepository,
- private readonly ProductRepositoryInterface $productRepository,
- private readonly MessageRepositoryInterface $messageRepository,
- private readonly AccountRepositoryInterface $accountRepository,
- private readonly EventRepositoryInterface $eventRepository,
- private readonly HtmlPurifierService $purifier,
- private readonly Repository $config,
- private readonly MessagingEligibilityService $eligibilityService,
- )
- {
- }
+ private readonly OrderRepositoryInterface $orderRepository,
+ private readonly AttendeeRepositoryInterface $attendeeRepository,
+ private readonly ProductRepositoryInterface $productRepository,
+ private readonly MessageRepositoryInterface $messageRepository,
+ private readonly AccountRepositoryInterface $accountRepository,
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly HtmlPurifierService $purifier,
+ private readonly Repository $config,
+ private readonly MessagingEligibilityService $eligibilityService,
+ ) {}
/**
* @throws AccountNotVerifiedException
@@ -52,12 +50,12 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject
throw new AccountNotVerifiedException(__('You cannot send messages until your account is verified.'));
}
- if ($this->config->get('app.saas_mode_enabled') && !$account->getIsManuallyVerified()) {
+ if ($this->config->get('app.saas_mode_enabled') && ! $account->getIsManuallyVerified()) {
throw new AccountNotVerifiedException(
- __('Due to issues with spam, you must contact us to enable your account for sending messages. ' .
+ __('Due to issues with spam, you must contact us to enable your account for sending messages. '.
'Please contact us at :email', [
- 'email' => $this->config->get('app.platform_support_email'),
- ])
+ 'email' => $this->config->get('app.platform_support_email'),
+ ])
);
}
@@ -77,7 +75,7 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject
$messageData->event_id
);
- $isScheduled = $messageData->scheduled_at !== null && !$messageData->is_test;
+ $isScheduled = $messageData->scheduled_at !== null && ! $messageData->is_test;
$event = $this->eventRepository->findById($messageData->event_id);
@@ -105,6 +103,7 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject
'message' => $this->purifier->purify($messageData->message),
'type' => $messageData->type->name,
'order_id' => $this->getOrderId($messageData),
+ 'event_occurrence_id' => $messageData->event_occurrence_id,
'attendee_ids' => $this->getAttendeeIds($messageData)->toArray(),
'product_ids' => $this->getProductIds($messageData)->toArray(),
'sent_at' => $isScheduled ? null : Carbon::now()->toDateTimeString(),
@@ -119,6 +118,10 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject
'account_id' => $messageData->account_id,
'attendee_ids' => $messageData->attendee_ids,
'product_ids' => $messageData->product_ids,
+ // event_occurrence_ids doesn't have a dedicated column — messages
+ // only have a single event_occurrence_id FK — so we persist the
+ // array here for audit + job replay.
+ 'event_occurrence_ids' => $messageData->event_occurrence_ids,
],
]);
@@ -139,6 +142,8 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject
'id' => $message->getId(),
'attendee_ids' => $message->getAttendeeIds(),
'product_ids' => $message->getProductIds(),
+ 'event_occurrence_id' => $messageData->event_occurrence_id,
+ 'event_occurrence_ids' => $messageData->event_occurrence_ids,
]);
SendMessagesJob::dispatch($updatedData);
@@ -149,24 +154,45 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject
private function estimateRecipientCount(SendMessageDTO $messageData): int
{
+ $occurrenceCondition = $this->occurrenceWhere($messageData);
+
return match ($messageData->type) {
MessageTypeEnum::INDIVIDUAL_ATTENDEES => count($messageData->attendee_ids ?? []),
MessageTypeEnum::ORDER_OWNER => 1,
- MessageTypeEnum::ALL_ATTENDEES => $this->attendeeRepository->countWhere([
+ MessageTypeEnum::ALL_ATTENDEES => $this->attendeeRepository->countWhere(array_merge([
'event_id' => $messageData->event_id,
- ]),
- MessageTypeEnum::TICKET_HOLDERS => $this->attendeeRepository->countWhere([
+ ], $occurrenceCondition)),
+ MessageTypeEnum::TICKET_HOLDERS => $this->attendeeRepository->countWhere(array_merge([
'event_id' => $messageData->event_id,
['product_id', 'in', $messageData->product_ids ?? []],
- ]),
+ ], $occurrenceCondition)),
MessageTypeEnum::ORDER_OWNERS_WITH_PRODUCT => $this->orderRepository->countOrdersAssociatedWithProducts(
eventId: $messageData->event_id,
productIds: $messageData->product_ids ?? [],
orderStatuses: $messageData->order_statuses ?? ['COMPLETED'],
+ eventOccurrenceId: $messageData->event_occurrence_id,
+ eventOccurrenceIds: $messageData->event_occurrence_ids,
),
};
}
+ /**
+ * Build the `where` fragment that scopes a recipient query to the target
+ * occurrences. Prefers event_occurrence_ids (multi) over event_occurrence_id
+ * (single); returns an empty array when neither is set (= whole event).
+ */
+ private function occurrenceWhere(SendMessageDTO $messageData): array
+ {
+ if (! empty($messageData->event_occurrence_ids)) {
+ return [['event_occurrence_id', 'in', $messageData->event_occurrence_ids]];
+ }
+ if ($messageData->event_occurrence_id) {
+ return ['event_occurrence_id' => $messageData->event_occurrence_id];
+ }
+
+ return [];
+ }
+
private function getAttendeeIds(SendMessageDTO $messageData): Collection
{
$attendees = $this->attendeeRepository->findWhereIn(
@@ -178,10 +204,9 @@ private function getAttendeeIds(SendMessageDTO $messageData): Collection
columns: ['id']
);
- return $attendees->map(fn($attendee) => $attendee->getId());
+ return $attendees->map(fn ($attendee) => $attendee->getId());
}
-
private function getProductIds(SendMessageDTO $messageData): Collection
{
$products = $this->productRepository->findWhereIn(
@@ -193,7 +218,7 @@ private function getProductIds(SendMessageDTO $messageData): Collection
columns: ['id']
);
- return $products->map(fn($product) => $product->getId());
+ return $products->map(fn ($product) => $product->getId());
}
private function getOrderId(SendMessageDTO $messageData): ?int
diff --git a/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php b/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php
index 49abae7e49..6bce172691 100644
--- a/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php
+++ b/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php
@@ -27,6 +27,7 @@
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\AffiliateRepositoryInterface;
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface;
@@ -62,6 +63,7 @@ public function __construct(
private readonly DomainEventDispatcherService $domainEventDispatcherService,
private readonly EventSettingsRepositoryInterface $eventSettingsRepository,
private readonly CheckoutSessionManagementService $sessionManagementService,
+ private readonly EventOccurrenceRepositoryInterface $occurrenceRepository,
)
{
}
@@ -81,6 +83,8 @@ public function handle(string $orderShortId, CompleteOrderDTO $orderData): Order
$order = $this->getOrder($orderShortId);
+ $this->validateOccurrenceStatus($order);
+
$updatedOrder = $this->updateOrder($order, $orderDTO);
$this->createAttendees($orderData->products, $order, $orderDTO, $eventSettings);
@@ -144,13 +148,15 @@ private function createAttendees(
$isPerOrderCollection = $eventSettings->getAttendeeDetailsCollectionMethod() === AttendeeDetailsCollectionMethod::PER_ORDER->name;
$this->validateTicketProductsCount($order, $orderProducts);
+ $orderItemRemainingQuantities = $order->getOrderItems()
+ ->mapWithKeys(fn(OrderItemDomainObject $item) => [$item->getId() => $item->getQuantity()]);
+
foreach ($orderProducts as $attendee) {
$productId = $productsPrices->first(
fn(ProductPriceDomainObject $productPrice) => $productPrice->getId() === $attendee->product_price_id)
->getProductId();
$productType = $this->getProductTypeFromPriceId($attendee->product_price_id, $order->getOrderItems());
- // If it's not a ticket, skip, as we only want to create attendees for tickets
if ($productType !== ProductType::TICKET->name) {
$createdProductData->push(new CreatedProductDataDTO(
productRequestData: $attendee,
@@ -160,12 +166,22 @@ private function createAttendees(
continue;
}
+ $orderItem = $order->getOrderItems()->first(
+ fn(OrderItemDomainObject $item) => $item->getProductPriceId() === $attendee->product_price_id
+ && ($orderItemRemainingQuantities[$item->getId()] ?? 0) > 0
+ );
+
+ if ($orderItem !== null) {
+ $orderItemRemainingQuantities[$orderItem->getId()] = $orderItemRemainingQuantities[$orderItem->getId()] - 1;
+ }
+
$shortId = IdHelper::shortId(IdHelper::ATTENDEE_PREFIX);
$inserts[] = [
AttendeeDomainObjectAbstract::EVENT_ID => $order->getEventId(),
AttendeeDomainObjectAbstract::PRODUCT_ID => $productId,
AttendeeDomainObjectAbstract::PRODUCT_PRICE_ID => $attendee->product_price_id,
+ AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID => $orderItem?->getEventOccurrenceId(),
AttendeeDomainObjectAbstract::STATUS => $order->isPaymentRequired()
? AttendeeStatus::AWAITING_PAYMENT->name
: AttendeeStatus::ACTIVE->name,
@@ -275,6 +291,37 @@ private function validateOrder(OrderDomainObject $order): void
}
}
+ /**
+ * @throws ResourceConflictException
+ */
+ private function validateOccurrenceStatus(OrderDomainObject $order): void
+ {
+ $occurrenceIds = $order->getOrderItems()
+ ?->map(fn(OrderItemDomainObject $item) => $item->getEventOccurrenceId())
+ ->filter()
+ ->unique()
+ ->values();
+
+ if ($occurrenceIds === null || $occurrenceIds->isEmpty()) {
+ return;
+ }
+
+ $occurrences = $this->occurrenceRepository->findWhereIn('id', $occurrenceIds->toArray());
+
+ foreach ($occurrences as $occurrence) {
+ if ($occurrence->isCancelled()) {
+ throw new ResourceConflictException(__('This event date has been cancelled'));
+ }
+
+ // Reservation could have been created before the occurrence ended
+ // — re-check on completion so a reserved order cannot age into a
+ // valid purchase for a session that has since passed.
+ if ($occurrence->isPast()) {
+ throw new ResourceConflictException(__('This event date has already ended'));
+ }
+ }
+ }
+
/**
* @throws ResourceConflictException
*/
diff --git a/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php b/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php
index 5278ef2243..ec4c5f65ae 100644
--- a/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php
+++ b/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php
@@ -132,24 +132,34 @@ public function validateEventStatus(EventDomainObject $event, CreateOrderPublicD
*/
private function validateProductAvailability(int $eventId, CreateOrderPublicDTO $createOrderPublicDTO): void
{
- $availability = $this->availableProductQuantitiesFetchService
- ->getAvailableProductQuantities($eventId, ignoreCache: true);
-
- foreach ($createOrderPublicDTO->products as $product) {
- foreach ($product->quantities as $priceQuantity) {
- if ($priceQuantity->quantity <= 0) {
- continue;
- }
-
- $available = $availability->productQuantities
- ->where('product_id', $product->product_id)
- ->where('price_id', $priceQuantity->price_id)
- ->first()?->quantity_available ?? 0;
-
- if ($priceQuantity->quantity > $available) {
- throw ValidationException::withMessages([
- 'products' => __('Not enough products available. Please try again.'),
- ]);
+ $productsByOccurrence = $createOrderPublicDTO->products->groupBy(
+ fn(DTO\ProductOrderDetailsDTO $p) => $p->event_occurrence_id
+ );
+
+ foreach ($productsByOccurrence as $occurrenceId => $products) {
+ $availability = $this->availableProductQuantitiesFetchService
+ ->getAvailableProductQuantities(
+ $eventId,
+ ignoreCache: true,
+ eventOccurrenceId: $occurrenceId ?: null,
+ );
+
+ foreach ($products as $product) {
+ foreach ($product->quantities as $priceQuantity) {
+ if ($priceQuantity->quantity <= 0) {
+ continue;
+ }
+
+ $available = $availability->productQuantities
+ ->where('product_id', $product->product_id)
+ ->where('price_id', $priceQuantity->price_id)
+ ->first()?->quantity_available ?? 0;
+
+ if ($priceQuantity->quantity > $available) {
+ throw ValidationException::withMessages([
+ 'products' => __('Not enough products available. Please try again.'),
+ ]);
+ }
}
}
}
diff --git a/backend/app/Services/Application/Handlers/Order/DTO/ProductOrderDetailsDTO.php b/backend/app/Services/Application/Handlers/Order/DTO/ProductOrderDetailsDTO.php
index 5fecfac13f..61e8094144 100644
--- a/backend/app/Services/Application/Handlers/Order/DTO/ProductOrderDetailsDTO.php
+++ b/backend/app/Services/Application/Handlers/Order/DTO/ProductOrderDetailsDTO.php
@@ -10,9 +10,10 @@
class ProductOrderDetailsDTO extends BaseDTO
{
public function __construct(
- public readonly int $product_id,
+ public readonly int $product_id,
#[CollectionOf(OrderProductPriceDTO::class)]
- public Collection $quantities,
+ public Collection $quantities,
+ public readonly ?int $event_occurrence_id = null,
)
{
}
diff --git a/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php b/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php
index 0672a0e995..0f97b0db56 100644
--- a/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php
+++ b/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php
@@ -4,6 +4,7 @@
use HiEvents\DomainObjects\AttendeeDomainObject;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\Generated\EventDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\OrganizerDomainObjectAbstract;
@@ -75,12 +76,22 @@ private function getOrderDomainObject(GetOrderPublicDTO $getOrderData): ?OrderDo
)
],
name: ProductDomainObjectAbstract::SINGULAR_NAME,
- )
+ ),
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ name: 'event_occurrence',
+ ),
],
))
->loadRelation(new Relationship(domainObject: InvoiceDomainObject::class))
->loadRelation(new Relationship(
domainObject: OrderItemDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ name: 'event_occurrence',
+ ),
+ ],
));
if ($getOrderData->includeEventInResponse) {
diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php
index 47ccbde563..dcfd7689b5 100644
--- a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php
+++ b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/CreatePaymentIntentHandler.php
@@ -6,12 +6,12 @@
use Brick\Math\Exception\NumberFormatException;
use Brick\Math\Exception\RoundingNecessaryException;
use Brick\Money\Exception\UnknownCurrencyException;
-use HiEvents\DomainObjects\AccountConfigurationDomainObject;
-use HiEvents\DomainObjects\AccountStripePlatformDomainObject;
-use HiEvents\DomainObjects\AccountVatSettingDomainObject;
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\Generated\StripePaymentDomainObjectAbstract;
use HiEvents\DomainObjects\OrderItemDomainObject;
+use HiEvents\DomainObjects\OrganizerConfigurationDomainObject;
+use HiEvents\DomainObjects\OrganizerStripePlatformDomainObject;
+use HiEvents\DomainObjects\OrganizerVatSettingDomainObject;
use HiEvents\DomainObjects\Status\OrderStatus;
use HiEvents\DomainObjects\StripePaymentDomainObject;
use HiEvents\Exceptions\ResourceConflictException;
@@ -20,6 +20,7 @@
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\AccountRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface;
use HiEvents\Repository\Interfaces\StripePaymentsRepositoryInterface;
use HiEvents\Services\Domain\Payment\Stripe\DTOs\CreatePaymentIntentRequestDTO;
use HiEvents\Services\Domain\Payment\Stripe\DTOs\CreatePaymentIntentResponseDTO;
@@ -40,6 +41,7 @@ public function __construct(
private CheckoutSessionManagementService $sessionIdentifierService,
private StripePaymentsRepositoryInterface $stripePaymentsRepository,
private AccountRepositoryInterface $accountRepository,
+ private OrganizerRepositoryInterface $organizerRepository,
private StripeClientFactory $stripeClientFactory,
private StripeConfigurationService $stripeConfigurationService,
)
@@ -47,8 +49,6 @@ public function __construct(
}
/**
- * @param string $orderShortId
- * @return CreatePaymentIntentResponseDTO
* @throws CreatePaymentIntentFailedException
* @throws MathException
* @throws NumberFormatException
@@ -73,32 +73,30 @@ public function handle(string $orderShortId): CreatePaymentIntentResponseDTO
throw new ResourceConflictException(__('Sorry, is expired or not in a valid state.'));
}
- $account = $this->accountRepository
+ $event = $order->getEvent();
+
+ $organizer = $this->organizerRepository
+ ->loadRelation(OrganizerStripePlatformDomainObject::class)
->loadRelation(new Relationship(
- domainObject: AccountConfigurationDomainObject::class,
- name: 'configuration',
+ domainObject: OrganizerConfigurationDomainObject::class,
+ name: 'organizer_configuration',
))
- ->loadRelation(AccountStripePlatformDomainObject::class)
->loadRelation(new Relationship(
- domainObject: AccountVatSettingDomainObject::class,
- name: 'account_vat_setting',
+ domainObject: OrganizerVatSettingDomainObject::class,
+ name: 'organizer_vat_setting',
))
- ->findByEventId($order->getEventId());
+ ->findById($event->getOrganizerId());
- $stripePlatform = $account->getActiveStripePlatform()
- ?? $this->stripeConfigurationService->getPrimaryPlatform();
+ $account = $this->accountRepository->findByEventId($order->getEventId());
- $stripeAccountId = $account->getActiveStripeAccountId();
+ $stripePlatform = $organizer?->getActiveStripePlatform()
+ ?? $this->stripeConfigurationService->getPrimaryPlatform();
- // If no platform is configured, we can still process payments with regular Stripe keys
- if (!$stripePlatform) {
- $stripePlatform = null; // This will use default keys in StripeClientFactory
- }
+ $stripeAccountId = $organizer?->getActiveStripeAccountId();
$stripeClient = $this->stripeClientFactory->createForPlatform($stripePlatform);
$publicKey = $this->stripeConfigurationService->getPublicKey($stripePlatform);
- // If we already have a Stripe session then re-fetch the client secret
if ($order->getStripePayment() !== null) {
return new CreatePaymentIntentResponseDTO(
paymentIntentId: $order->getStripePayment()->getPaymentIntentId(),
@@ -126,8 +124,9 @@ public function handle(string $orderShortId): CreatePaymentIntentResponseDTO
'currencyCode' => $order->getCurrency(),
'account' => $account,
'order' => $order,
+ 'configuration' => $organizer?->getOrganizerConfiguration(),
'stripeAccountId' => $stripeAccountId,
- 'vatSettings' => $account->getAccountVatSetting(),
+ 'vatSettings' => $organizer?->getOrganizerVatSetting(),
'description' => Str::limit($description, 997),
])
);
diff --git a/backend/app/Services/Application/Handlers/Organizer/CreateOrganizerHandler.php b/backend/app/Services/Application/Handlers/Organizer/CreateOrganizerHandler.php
index 4e8441641b..d6228b2636 100644
--- a/backend/app/Services/Application/Handlers/Organizer/CreateOrganizerHandler.php
+++ b/backend/app/Services/Application/Handlers/Organizer/CreateOrganizerHandler.php
@@ -4,18 +4,24 @@
use HiEvents\DomainObjects\ImageDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
+use HiEvents\Repository\Interfaces\AccountRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrganizerConfigurationRepositoryInterface;
use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface;
use HiEvents\Services\Application\Handlers\Organizer\DTO\CreateOrganizerDTO;
use HiEvents\Services\Domain\Organizer\CreateDefaultOrganizerSettingsService;
use Illuminate\Database\DatabaseManager;
+use Psr\Log\LoggerInterface;
use Throwable;
class CreateOrganizerHandler
{
public function __construct(
- private readonly OrganizerRepositoryInterface $organizerRepository,
- private readonly DatabaseManager $databaseManager,
- private readonly CreateDefaultOrganizerSettingsService $createDefaultOrganizerSettingsService,
+ private readonly OrganizerRepositoryInterface $organizerRepository,
+ private readonly OrganizerConfigurationRepositoryInterface $organizerConfigurationRepository,
+ private readonly AccountRepositoryInterface $accountRepository,
+ private readonly DatabaseManager $databaseManager,
+ private readonly CreateDefaultOrganizerSettingsService $createDefaultOrganizerSettingsService,
+ private readonly LoggerInterface $logger,
)
{
}
@@ -41,6 +47,7 @@ private function createOrganizer(CreateOrganizerDTO $organizerData): OrganizerDo
'account_id' => $organizerData->account_id,
'timezone' => $organizerData->timezone,
'currency' => $organizerData->currency,
+ 'organizer_configuration_id' => $this->resolveConfigurationId($organizerData->account_id),
]);
$this->createDefaultOrganizerSettingsService->createOrganizerSettings($organizer);
@@ -49,4 +56,39 @@ private function createOrganizer(CreateOrganizerDTO $organizerData): OrganizerDo
->loadRelation(ImageDomainObject::class)
->findById($organizer->getId());
}
+
+ /**
+ * Prefer the parent account's plan via the legacy id pointer kept on
+ * organizer_configurations during the deprecation window — handles SaaS
+ * invite tokens that pre-assign a custom account_configuration. Falls back
+ * to the organizer-level system default.
+ */
+ private function resolveConfigurationId(int $accountId): ?int
+ {
+ $account = $this->accountRepository->findFirst($accountId);
+ $legacyAccountConfigurationId = $account?->getAccountConfigurationId();
+
+ if ($legacyAccountConfigurationId !== null) {
+ $matched = $this->organizerConfigurationRepository->findFirstWhere([
+ 'legacy_account_configuration_id' => $legacyAccountConfigurationId,
+ ]);
+
+ if ($matched !== null) {
+ return $matched->getId();
+ }
+ }
+
+ $defaultConfiguration = $this->organizerConfigurationRepository->findFirstWhere([
+ 'is_system_default' => true,
+ ]);
+
+ if ($defaultConfiguration === null) {
+ $this->logger->error('No default organizer configuration found while creating organizer', [
+ 'account_id' => $accountId,
+ ]);
+ return null;
+ }
+
+ return $defaultConfiguration->getId();
+ }
}
diff --git a/backend/app/Services/Application/Handlers/Organizer/GetOrganizerEventsHandler.php b/backend/app/Services/Application/Handlers/Organizer/GetOrganizerEventsHandler.php
index 06a24e2a27..51525ee5fa 100644
--- a/backend/app/Services/Application/Handlers/Organizer/GetOrganizerEventsHandler.php
+++ b/backend/app/Services/Application/Handlers/Organizer/GetOrganizerEventsHandler.php
@@ -2,6 +2,7 @@
namespace HiEvents\Services\Application\Handlers\Organizer;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\ImageDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
@@ -21,6 +22,7 @@ public function __construct(
public function handle(GetOrganizerEventsDTO $dto): LengthAwarePaginator
{
return $this->eventRepository
+ ->loadRelation(new Relationship(EventOccurrenceDomainObject::class))
->loadRelation(new Relationship(ImageDomainObject::class))
->loadRelation(new Relationship(EventSettingDomainObject::class))
->loadRelation(new Relationship(
diff --git a/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandler.php b/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandler.php
new file mode 100644
index 0000000000..1b6bf9fed3
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandler.php
@@ -0,0 +1,130 @@
+config->get('app.saas_mode_enabled')) {
+ throw new SaasModeEnabledException(
+ __('Stripe Connect Account creation is only available in Saas Mode.'),
+ );
+ }
+
+ return $this->databaseManager->transaction(fn() => $this->copy($command));
+ }
+
+ /**
+ * @throws ResourceNotFoundException
+ * @throws ResourceConflictException
+ */
+ private function copy(CopyStripeConnectAccountDTO $command): CreateStripeConnectAccountResponse
+ {
+ $target = $this->organizerRepository
+ ->loadRelation(OrganizerStripePlatformDomainObject::class)
+ ->findFirstWhere([
+ 'id' => $command->targetOrganizerId,
+ 'account_id' => $command->accountId,
+ ]);
+
+ if ($target === null) {
+ throw new ResourceNotFoundException(__('Organizer not found.'));
+ }
+
+ $source = $this->organizerRepository
+ ->loadRelation(OrganizerStripePlatformDomainObject::class)
+ ->findFirstWhere([
+ 'id' => $command->sourceOrganizerId,
+ 'account_id' => $command->accountId,
+ ]);
+
+ if ($source === null) {
+ throw new ResourceNotFoundException(__('The source organizer was not found.'));
+ }
+
+ $sourcePlatform = $source->getPrimaryStripePlatform();
+ if ($sourcePlatform === null) {
+ throw new ResourceConflictException(
+ __('The selected organizer does not have a connected Stripe account.'),
+ );
+ }
+
+ $existing = $target->getOrganizerStripePlatforms()
+ ?->first(fn(OrganizerStripePlatformDomainObject $row) => $row->getStripeAccountId() === $sourcePlatform->getStripeAccountId());
+
+ if ($existing !== null) {
+ $this->organizerStripePlatformRepository->updateWhere(
+ attributes: [
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_CONNECT_ACCOUNT_TYPE => $sourcePlatform->getStripeConnectAccountType(),
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_CONNECT_PLATFORM => $sourcePlatform->getStripeConnectPlatform(),
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_SETUP_COMPLETED_AT => $sourcePlatform->getStripeSetupCompletedAt(),
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_DETAILS => $sourcePlatform->getStripeAccountDetails(),
+ ],
+ where: [
+ 'id' => $existing->getId(),
+ ],
+ );
+ } else {
+ $this->organizerStripePlatformRepository->create([
+ OrganizerStripePlatformDomainObjectAbstract::ORGANIZER_ID => $target->getId(),
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $sourcePlatform->getStripeAccountId(),
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_CONNECT_ACCOUNT_TYPE => $sourcePlatform->getStripeConnectAccountType(),
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_CONNECT_PLATFORM => $sourcePlatform->getStripeConnectPlatform(),
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_SETUP_COMPLETED_AT => $sourcePlatform->getStripeSetupCompletedAt(),
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_DETAILS => $sourcePlatform->getStripeAccountDetails(),
+ ]);
+ }
+
+ // Mirror the connect/webhook flow: when an organizer first acquires a Stripe
+ // connection (even via copy), seed an empty VAT row if the country is EU.
+ // We read country from the cached account details since we don't fetch
+ // the live Stripe account here.
+ $sourceDetails = $sourcePlatform->getStripeAccountDetails();
+ if (is_string($sourceDetails)) {
+ $sourceDetails = json_decode($sourceDetails, true) ?: [];
+ } elseif (!is_array($sourceDetails)) {
+ $sourceDetails = [];
+ }
+
+ $this->stripeAccountSyncService->seedVatSettingForOrganizerIfMissing(
+ organizerId: (int)$target->getId(),
+ countryCode: $sourceDetails['country'] ?? null,
+ stripeAccountId: $sourcePlatform->getStripeAccountId(),
+ );
+
+ return new CreateStripeConnectAccountResponse(
+ stripeConnectAccountType: $sourcePlatform->getStripeConnectAccountType() ?? '',
+ stripeAccountId: $sourcePlatform->getStripeAccountId() ?? '',
+ organizer: $target,
+ isConnectSetupComplete: $sourcePlatform->getStripeSetupCompletedAt() !== null,
+ );
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandler.php b/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/CreateStripeConnectAccountHandler.php
similarity index 53%
rename from backend/app/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandler.php
rename to backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/CreateStripeConnectAccountHandler.php
index 4f1819e25c..18d19d4b56 100644
--- a/backend/app/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandler.php
+++ b/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/CreateStripeConnectAccountHandler.php
@@ -1,20 +1,21 @@
accountRepository
- ->loadRelation(AccountStripePlatformDomainObject::class)
- ->findById($command->accountId);
+ $organizer = $this->organizerRepository
+ ->loadRelation(OrganizerStripePlatformDomainObject::class)
+ ->findFirstWhere([
+ 'id' => $command->organizerId,
+ 'account_id' => $command->accountId,
+ ]);
+
+ if ($organizer === null) {
+ throw new ResourceNotFoundException(__('Organizer not found.'));
+ }
- // If platform is explicitly specified (e.g., for Ireland migration), use it
- // Otherwise, use the primary platform from environment (Or null for open-source installations)
if ($command->platform) {
$platformToUse = StripePlatform::fromString($command->platform->value);
} else {
$platformToUse = $this->stripeConfigurationService->getPrimaryPlatform();
}
- // Try to find existing platform record for the requested platform
- // This works for both null (open-source) and specific platforms
- $accountStripePlatform = $account->getStripePlatformByType($platformToUse);
+ $organizerStripePlatform = $organizer->getStripePlatformByType($platformToUse);
- // Open-source installations without platform configuration should still work
- // They will use default Stripe keys instead of platform-specific ones
$stripeClient = $this->stripeClientFactory->createForPlatform($platformToUse);
$stripeConnectAccount = $this->getOrCreateStripeConnectAccount(
- account: $account,
- accountStripePlatform: $accountStripePlatform,
+ organizer: $organizer,
+ organizerStripePlatform: $organizerStripePlatform,
stripeClient: $stripeClient,
platform: $platformToUse,
);
@@ -90,20 +94,19 @@ private function createOrGetStripeConnectAccount(CreateStripeConnectAccountDTO $
$response = new CreateStripeConnectAccountResponse(
stripeConnectAccountType: $stripeConnectAccount->type,
stripeAccountId: $stripeConnectAccount->id,
- account: $account,
+ organizer: $organizer,
isConnectSetupComplete: $this->stripeAccountSyncService->isStripeAccountComplete($stripeConnectAccount),
);
if ($response->isConnectSetupComplete) {
- // If setup is complete, but this isn't reflected in the account stripe platform, update it.
- if ($accountStripePlatform && $accountStripePlatform->getStripeSetupCompletedAt() === null) {
- $this->stripeAccountSyncService->markAccountAsComplete($accountStripePlatform, $stripeConnectAccount);
+ if ($organizerStripePlatform && $organizerStripePlatform->getStripeSetupCompletedAt() === null) {
+ $this->stripeAccountSyncService->markAccountAsCompleteForOrganizer($organizerStripePlatform, $stripeConnectAccount);
}
return $response;
}
- $connectUrl = $this->stripeAccountSyncService->createStripeAccountSetupUrl($stripeConnectAccount, $stripeClient);
+ $connectUrl = $this->stripeAccountSyncService->createStripeAccountSetupUrl($stripeConnectAccount, $stripeClient, $organizer->getId());
if ($connectUrl === null) {
throw new CreateStripeConnectAccountLinksFailedException(
message: __('There are issues with creating the Stripe Connect Account Link. Please try again.'),
@@ -119,15 +122,15 @@ private function createOrGetStripeConnectAccount(CreateStripeConnectAccountDTO $
* @throws CreateStripeConnectAccountFailedException
*/
private function getOrCreateStripeConnectAccount(
- AccountDomainObject $account,
- ?AccountStripePlatformDomainObject $accountStripePlatform,
- StripeClient $stripeClient,
- ?StripePlatform $platform
+ OrganizerDomainObject $organizer,
+ ?OrganizerStripePlatformDomainObject $organizerStripePlatform,
+ StripeClient $stripeClient,
+ ?StripePlatform $platform
): Account
{
try {
- if ($accountStripePlatform && $accountStripePlatform->getStripeAccountId() !== null) {
- return $stripeClient->accounts->retrieve($accountStripePlatform->getStripeAccountId());
+ if ($organizerStripePlatform && $organizerStripePlatform->getStripeAccountId() !== null) {
+ return $stripeClient->accounts->retrieve($organizerStripePlatform->getStripeAccountId());
}
$stripeAccount = $stripeClient->accounts->create([
@@ -136,9 +139,8 @@ private function getOrCreateStripeConnectAccount(
]);
} catch (Throwable $e) {
$this->logger->error('Failed to create or fetch Stripe Connect Account: ' . $e->getMessage(), [
- 'accountId' => $account->getId(),
- 'stripeAccountId' => $accountStripePlatform?->getStripeAccountId() ?? 'null',
- 'accountExists' => $accountStripePlatform?->getStripeAccountId() !== null ? 'true' : 'false',
+ 'organizerId' => $organizer->getId(),
+ 'stripeAccountId' => $organizerStripePlatform?->getStripeAccountId() ?? 'null',
'platform' => $platform?->value ?? 'null',
'exception' => $e,
]);
@@ -149,28 +151,25 @@ private function getOrCreateStripeConnectAccount(
);
}
- // Create or update account stripe platform record
- if (!$accountStripePlatform) {
- $this->accountStripePlatformRepository->create([
- AccountStripePlatformDomainObjectAbstract::ACCOUNT_ID => $account->getId(),
- AccountStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id,
- AccountStripePlatformDomainObjectAbstract::STRIPE_CONNECT_ACCOUNT_TYPE => $stripeAccount->type,
- AccountStripePlatformDomainObjectAbstract::STRIPE_CONNECT_PLATFORM => $platform?->value,
+ if (!$organizerStripePlatform) {
+ $this->organizerStripePlatformRepository->create([
+ OrganizerStripePlatformDomainObjectAbstract::ORGANIZER_ID => $organizer->getId(),
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id,
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_CONNECT_ACCOUNT_TYPE => $stripeAccount->type,
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_CONNECT_PLATFORM => $platform?->value,
]);
} else {
- $this->accountStripePlatformRepository->updateWhere(
+ $this->organizerStripePlatformRepository->updateWhere(
attributes: [
- AccountStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id,
- AccountStripePlatformDomainObjectAbstract::STRIPE_CONNECT_ACCOUNT_TYPE => $stripeAccount->type,
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id,
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_CONNECT_ACCOUNT_TYPE => $stripeAccount->type,
],
where: [
- 'id' => $accountStripePlatform->getId(),
+ 'id' => $organizerStripePlatform->getId(),
]
);
}
return $stripeAccount;
}
-
-
}
diff --git a/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/DTO/CopyStripeConnectAccountDTO.php b/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/DTO/CopyStripeConnectAccountDTO.php
new file mode 100644
index 0000000000..f962afc00e
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/DTO/CopyStripeConnectAccountDTO.php
@@ -0,0 +1,16 @@
+organizerRepository
+ ->loadRelation(OrganizerStripePlatformDomainObject::class)
+ ->findFirstWhere([
+ 'id' => $organizerId,
+ 'account_id' => $accountId,
+ ]);
+
+ if ($organizer === null) {
+ throw new ResourceNotFoundException(__('Organizer not found.'));
+ }
+
+ $stripeConnectAccounts = $this->getStripeConnectAccounts($organizer);
+ $primaryStripeAccountId = $organizer->getActiveStripeAccountId();
+ $hasCompletedSetup = $organizer->isStripeSetupComplete();
+ $reusable = $this->getReusableConnections($accountId, $organizerId, $primaryStripeAccountId);
+
+ return new GetStripeConnectAccountsResponseDTO(
+ organizer: $organizer,
+ stripeConnectAccounts: $stripeConnectAccounts,
+ reusableConnections: $reusable,
+ primaryStripeAccountId: $primaryStripeAccountId,
+ hasCompletedSetup: $hasCompletedSetup,
+ );
+ }
+
+ private function getStripeConnectAccounts(OrganizerDomainObject $organizer): Collection
+ {
+ $stripeAccounts = collect();
+ $stripePlatforms = $organizer->getOrganizerStripePlatforms();
+
+ if (!$stripePlatforms || $stripePlatforms->isEmpty()) {
+ return $stripeAccounts;
+ }
+
+ foreach ($stripePlatforms as $stripePlatform) {
+ $stripeAccount = $this->buildStripeAccountDTO($stripePlatform);
+ if ($stripeAccount) {
+ $stripeAccounts->push($stripeAccount);
+ }
+ }
+
+ return $stripeAccounts;
+ }
+
+ private function buildStripeAccountDTO(OrganizerStripePlatformDomainObject $stripePlatform): ?StripeConnectAccountDTO
+ {
+ if (!$stripePlatform->getStripeAccountId()) {
+ return null;
+ }
+
+ try {
+ $platform = $stripePlatform->getStripeConnectPlatform()
+ ? StripePlatform::fromString($stripePlatform->getStripeConnectPlatform())
+ : null;
+
+ $stripeClient = $this->stripeClientFactory->createForPlatform($platform);
+ $stripeAccount = $stripeClient->accounts->retrieve($stripePlatform->getStripeAccountId());
+
+ $isSetupComplete = $this->stripeAccountSyncService->isStripeAccountComplete($stripeAccount);
+ $connectUrl = null;
+
+ if ($isSetupComplete && $stripePlatform->getStripeSetupCompletedAt() === null) {
+ $this->stripeAccountSyncService->markAccountAsCompleteForOrganizer($stripePlatform, $stripeAccount);
+ } else {
+ $this->stripeAccountSyncService->syncStripeAccountDetailsForOrganizer($stripePlatform, $stripeAccount);
+ }
+
+ if (!$isSetupComplete) {
+ $connectUrl = $this->stripeAccountSyncService->createStripeAccountSetupUrl($stripeAccount, $stripeClient, $stripePlatform->getOrganizerId());
+ }
+
+ $details = is_array($stripePlatform->getStripeAccountDetails())
+ ? $stripePlatform->getStripeAccountDetails()
+ : (is_string($stripePlatform->getStripeAccountDetails())
+ ? (json_decode($stripePlatform->getStripeAccountDetails(), true) ?? [])
+ : []);
+
+ return new StripeConnectAccountDTO(
+ stripeAccountId: $stripeAccount->id,
+ connectUrl: $connectUrl,
+ isSetupComplete: $isSetupComplete,
+ platform: $platform,
+ accountType: $stripeAccount->type,
+ isPrimary: $isSetupComplete,
+ country: $stripeAccount->country ?? ($details['country'] ?? null),
+ businessType: $stripeAccount->business_type ?? ($details['business_type'] ?? null),
+ chargesEnabled: (bool)($stripeAccount->charges_enabled ?? false),
+ payoutsEnabled: (bool)($stripeAccount->payouts_enabled ?? false),
+ capabilities: $this->normalizeCapabilities($stripeAccount),
+ requirements: $this->normalizeRequirements($stripeAccount),
+ );
+ } catch (StripeClientConfigurationException $e) {
+ $this->logger->warning('Failed to retrieve Stripe account due to configuration issue', [
+ 'stripe_account_id' => $stripePlatform->getStripeAccountId(),
+ 'platform' => $stripePlatform->getStripeConnectPlatform(),
+ 'error' => $e->getMessage(),
+ ]);
+ return null;
+ } catch (Throwable $e) {
+ $this->logger->error('Failed to retrieve Stripe account', [
+ 'stripe_account_id' => $stripePlatform->getStripeAccountId(),
+ 'platform' => $stripePlatform->getStripeConnectPlatform(),
+ 'error' => $e->getMessage(),
+ ]);
+ return null;
+ }
+ }
+
+ private function normalizeCapabilities(\Stripe\Account $stripeAccount): array
+ {
+ $capabilities = $stripeAccount->capabilities;
+ if (is_array($capabilities)) {
+ return $capabilities;
+ }
+ if ($capabilities && method_exists($capabilities, 'toArray')) {
+ return $capabilities->toArray();
+ }
+ return [];
+ }
+
+ private function normalizeRequirements(\Stripe\Account $stripeAccount): array
+ {
+ $requirements = $stripeAccount->requirements;
+ return [
+ 'currently_due' => $requirements?->currently_due ?? [],
+ 'eventually_due' => $requirements?->eventually_due ?? [],
+ 'past_due' => $requirements?->past_due ?? [],
+ 'pending_verification' => $requirements?->pending_verification ?? [],
+ ];
+ }
+
+ private function getReusableConnections(int $accountId, int $excludeOrganizerId, ?string $currentStripeAccountId): Collection
+ {
+ $rows = $this->organizerStripePlatformRepository->findReusableForAccount(
+ $accountId,
+ $excludeOrganizerId,
+ $currentStripeAccountId,
+ );
+
+ $seen = [];
+ $result = collect();
+
+ foreach ($rows as $row) {
+ $stripeAccountId = $row->stripe_account_id;
+ if (isset($seen[$stripeAccountId])) {
+ continue;
+ }
+ $seen[$stripeAccountId] = true;
+
+ $details = $row->stripe_account_details;
+ if (is_string($details)) {
+ $details = json_decode($details, true) ?? [];
+ }
+ if (!is_array($details)) {
+ $details = [];
+ }
+
+ $result->push(new ReusableStripeConnectionDTO(
+ organizerId: (int)$row->organizer_id,
+ organizerName: (string)$row->organizer_name,
+ stripeAccountId: $stripeAccountId,
+ platform: $row->stripe_connect_platform,
+ country: $details['country'] ?? null,
+ businessType: $details['business_type'] ?? null,
+ ));
+ }
+
+ return $result;
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/Organizer/Vat/DTO/UpsertOrganizerVatSettingDTO.php b/backend/app/Services/Application/Handlers/Organizer/Vat/DTO/UpsertOrganizerVatSettingDTO.php
new file mode 100644
index 0000000000..35ed3d3e0b
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Organizer/Vat/DTO/UpsertOrganizerVatSettingDTO.php
@@ -0,0 +1,17 @@
+vatSettingRepository->findByOrganizerId($organizerId);
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/Account/Vat/UpsertAccountVatSettingHandler.php b/backend/app/Services/Application/Handlers/Organizer/Vat/UpsertOrganizerVatSettingHandler.php
similarity index 75%
rename from backend/app/Services/Application/Handlers/Account/Vat/UpsertAccountVatSettingHandler.php
rename to backend/app/Services/Application/Handlers/Organizer/Vat/UpsertOrganizerVatSettingHandler.php
index 72c7c4029f..198df6d28a 100644
--- a/backend/app/Services/Application/Handlers/Account/Vat/UpsertAccountVatSettingHandler.php
+++ b/backend/app/Services/Application/Handlers/Organizer/Vat/UpsertOrganizerVatSettingHandler.php
@@ -2,31 +2,47 @@
declare(strict_types=1);
-namespace HiEvents\Services\Application\Handlers\Account\Vat;
+namespace HiEvents\Services\Application\Handlers\Organizer\Vat;
-use HiEvents\DomainObjects\AccountVatSettingDomainObject;
+use HiEvents\DomainObjects\OrganizerVatSettingDomainObject;
use HiEvents\DomainObjects\Status\VatValidationStatus;
+use HiEvents\Exceptions\ResourceNotFoundException;
use HiEvents\Jobs\Vat\ValidateVatNumberJob;
-use HiEvents\Repository\Interfaces\AccountVatSettingRepositoryInterface;
-use HiEvents\Services\Application\Handlers\Account\Vat\DTO\UpsertAccountVatSettingDTO;
+use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrganizerVatSettingRepositoryInterface;
+use HiEvents\Services\Application\Handlers\Organizer\Vat\DTO\UpsertOrganizerVatSettingDTO;
use HiEvents\Services\Infrastructure\Vat\ViesValidationService;
use Psr\Log\LoggerInterface;
-class UpsertAccountVatSettingHandler
+class UpsertOrganizerVatSettingHandler
{
public function __construct(
- private readonly AccountVatSettingRepositoryInterface $vatSettingRepository,
- private readonly ViesValidationService $viesValidationService,
- private readonly LoggerInterface $logger,
- ) {
+ private readonly OrganizerVatSettingRepositoryInterface $vatSettingRepository,
+ private readonly OrganizerRepositoryInterface $organizerRepository,
+ private readonly ViesValidationService $viesValidationService,
+ private readonly LoggerInterface $logger,
+ )
+ {
}
- public function handle(UpsertAccountVatSettingDTO $command): AccountVatSettingDomainObject
+ /**
+ * @throws ResourceNotFoundException
+ */
+ public function handle(UpsertOrganizerVatSettingDTO $command): OrganizerVatSettingDomainObject
{
- $existing = $this->vatSettingRepository->findByAccountId($command->accountId);
+ $organizer = $this->organizerRepository->findFirstWhere([
+ 'id' => $command->organizerId,
+ 'account_id' => $command->accountId,
+ ]);
+
+ if ($organizer === null) {
+ throw new ResourceNotFoundException(__('Organizer not found.'));
+ }
+
+ $existing = $this->vatSettingRepository->findByOrganizerId($command->organizerId);
$data = [
- 'account_id' => $command->accountId,
+ 'organizer_id' => $command->organizerId,
'vat_registered' => $command->vatRegistered,
];
@@ -78,8 +94,8 @@ public function handle(UpsertAccountVatSettingDTO $command): AccountVatSettingDo
if ($shouldValidate && $data['vat_validation_status'] === VatValidationStatus::PENDING->value) {
$this->logger->info('Sync validation failed, dispatching VAT validation job', [
- 'account_vat_setting_id' => $vatSetting->getId(),
- 'account_id' => $command->accountId,
+ 'organizer_vat_setting_id' => $vatSetting->getId(),
+ 'organizer_id' => $command->organizerId,
'vat_number_masked' => $this->maskVatNumber($vatNumber),
]);
@@ -101,11 +117,6 @@ private function trySyncValidation(string $vatNumber, array $data): array
$result = $this->viesValidationService->validateVatNumber($vatNumber);
if ($result->valid) {
- $this->logger->info('Sync VAT validation successful', [
- 'vat_number_masked' => $this->maskVatNumber($vatNumber),
- 'business_name' => $result->businessName,
- ]);
-
$data['vat_validated'] = true;
$data['vat_validation_status'] = VatValidationStatus::VALID->value;
$data['vat_validation_error'] = null;
@@ -118,11 +129,6 @@ private function trySyncValidation(string $vatNumber, array $data): array
}
if ($result->isTransientError) {
- $this->logger->info('Sync VAT validation hit transient error, will queue for retry', [
- 'vat_number_masked' => $this->maskVatNumber($vatNumber),
- 'error' => $result->errorMessage,
- ]);
-
$data['vat_validated'] = false;
$data['vat_validation_status'] = VatValidationStatus::PENDING->value;
$data['vat_validation_error'] = $result->errorMessage;
@@ -134,11 +140,6 @@ private function trySyncValidation(string $vatNumber, array $data): array
return $data;
}
- $this->logger->info('Sync VAT validation failed - invalid VAT number', [
- 'vat_number_masked' => $this->maskVatNumber($vatNumber),
- 'error' => $result->errorMessage,
- ]);
-
$data['vat_validated'] = false;
$data['vat_validation_status'] = VatValidationStatus::INVALID->value;
$data['vat_validation_error'] = $result->errorMessage;
diff --git a/backend/app/Services/Application/Handlers/Reports/DTO/GetReportDTO.php b/backend/app/Services/Application/Handlers/Reports/DTO/GetReportDTO.php
index 295c9b4da9..c843eff009 100644
--- a/backend/app/Services/Application/Handlers/Reports/DTO/GetReportDTO.php
+++ b/backend/app/Services/Application/Handlers/Reports/DTO/GetReportDTO.php
@@ -11,7 +11,8 @@ public function __construct(
public readonly int $eventId,
public readonly ReportTypes $reportType,
public readonly ?string $startDate,
- public readonly ?string $endDate
+ public readonly ?string $endDate,
+ public readonly ?int $occurrenceId = null,
)
{
}
diff --git a/backend/app/Services/Application/Handlers/Reports/GetReportHandler.php b/backend/app/Services/Application/Handlers/Reports/GetReportHandler.php
index 9541081ac0..6a59abaea1 100644
--- a/backend/app/Services/Application/Handlers/Reports/GetReportHandler.php
+++ b/backend/app/Services/Application/Handlers/Reports/GetReportHandler.php
@@ -23,6 +23,7 @@ public function handle(GetReportDTO $reportData): Collection
eventId: $reportData->eventId,
startDate: $reportData->startDate ? Carbon::parse($reportData->startDate) : null,
endDate: $reportData->endDate ? Carbon::parse($reportData->endDate) : null,
+ occurrenceId: $reportData->occurrenceId,
);
}
}
diff --git a/backend/app/Services/Application/Handlers/SelfService/ResendAttendeeTicketPublicHandler.php b/backend/app/Services/Application/Handlers/SelfService/ResendAttendeeTicketPublicHandler.php
index e6a1da06e9..75b5e706a1 100644
--- a/backend/app/Services/Application/Handlers/SelfService/ResendAttendeeTicketPublicHandler.php
+++ b/backend/app/Services/Application/Handlers/SelfService/ResendAttendeeTicketPublicHandler.php
@@ -2,7 +2,11 @@
namespace HiEvents\Services\Application\Handlers\SelfService;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\Generated\AttendeeDomainObjectAbstract;
+use HiEvents\DomainObjects\Status\AttendeeStatus;
+use HiEvents\Exceptions\ResourceConflictException;
+use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
@@ -19,28 +23,51 @@ public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly EventRepositoryInterface $eventRepository,
private readonly SelfServiceResendEmailService $selfServiceResendEmailService,
- ) {
- }
+ ) {}
+ /**
+ * @throws ResourceConflictException
+ */
public function handle(ResendEmailPublicDTO $dto): void
{
$this->loadAndValidateEvent($dto->eventId);
$order = $this->loadAndValidateOrder($dto->orderShortId, $dto->eventId);
- if (!$dto->attendeeShortId) {
+ if ($order->isOrderCancelled()) {
+ throw new ResourceConflictException(
+ __('Tickets can\'t be resent for a cancelled order.')
+ );
+ }
+
+ if (! $dto->attendeeShortId) {
throw new ResourceNotFoundException(__('Attendee not found'));
}
- $attendee = $this->attendeeRepository->findFirstWhere([
- AttendeeDomainObjectAbstract::SHORT_ID => $dto->attendeeShortId,
- AttendeeDomainObjectAbstract::ORDER_ID => $order->getId(),
- AttendeeDomainObjectAbstract::EVENT_ID => $dto->eventId,
- ]);
+ $attendee = $this->attendeeRepository
+ ->loadRelation(new Relationship(EventOccurrenceDomainObject::class, name: 'event_occurrence'))
+ ->findFirstWhere([
+ AttendeeDomainObjectAbstract::SHORT_ID => $dto->attendeeShortId,
+ AttendeeDomainObjectAbstract::ORDER_ID => $order->getId(),
+ AttendeeDomainObjectAbstract::EVENT_ID => $dto->eventId,
+ ]);
- if (!$attendee) {
+ if (! $attendee) {
throw new ResourceNotFoundException(__('Attendee not found'));
}
+ if ($attendee->getStatus() === AttendeeStatus::CANCELLED->name) {
+ throw new ResourceConflictException(
+ __('This ticket has been cancelled and can\'t be resent.')
+ );
+ }
+
+ $occurrence = $attendee->getEventOccurrence();
+ if ($occurrence?->isCancelled()) {
+ throw new ResourceConflictException(
+ __('The session for this ticket has been cancelled and can\'t be resent.')
+ );
+ }
+
$this->selfServiceResendEmailService->resendAttendeeTicket(
attendeeId: $attendee->getId(),
orderId: $order->getId(),
diff --git a/backend/app/Services/Application/Handlers/Waitlist/CreateWaitlistEntryHandler.php b/backend/app/Services/Application/Handlers/Waitlist/CreateWaitlistEntryHandler.php
index 7813771413..415a920df2 100644
--- a/backend/app/Services/Application/Handlers/Waitlist/CreateWaitlistEntryHandler.php
+++ b/backend/app/Services/Application/Handlers/Waitlist/CreateWaitlistEntryHandler.php
@@ -3,21 +3,31 @@
namespace HiEvents\Services\Application\Handlers\Waitlist;
use HiEvents\DomainObjects\WaitlistEntryDomainObject;
+use HiEvents\DomainObjects\Enums\EventType;
+use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract;
use HiEvents\Exceptions\ResourceConflictException;
use HiEvents\Exceptions\ResourceNotFoundException;
+use HiEvents\Repository\Eloquent\Value\OrderAndDirection;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
use HiEvents\Services\Application\Handlers\Waitlist\DTO\CreateWaitlistEntryDTO;
+use HiEvents\Services\Domain\EventOccurrence\OccurrencePurchaseEligibilityService;
use HiEvents\Services\Domain\Waitlist\CreateWaitlistEntryService;
+use Illuminate\Validation\ValidationException;
class CreateWaitlistEntryHandler
{
public function __construct(
private readonly CreateWaitlistEntryService $createWaitlistEntryService,
private readonly EventSettingsRepositoryInterface $eventSettingsRepository,
+ private readonly EventRepositoryInterface $eventRepository,
private readonly ProductPriceRepositoryInterface $productPriceRepository,
private readonly ProductRepositoryInterface $productRepository,
+ private readonly EventOccurrenceRepositoryInterface $occurrenceRepository,
+ private readonly OccurrencePurchaseEligibilityService $occurrenceEligibilityService,
)
{
}
@@ -28,6 +38,36 @@ public function __construct(
*/
public function handle(CreateWaitlistEntryDTO $dto): WaitlistEntryDomainObject
{
+ $event = $this->eventRepository->findById($dto->event_id);
+ if ($event !== null && $event->isRecurring() && $dto->event_occurrence_id === null) {
+ throw ValidationException::withMessages([
+ 'event_occurrence_id' => __('An event date must be selected.'),
+ ]);
+ }
+
+ if ($event !== null
+ && $event->getType() === EventType::SINGLE->name
+ && $dto->event_occurrence_id === null
+ ) {
+ $occurrence = $this->occurrenceRepository
+ ->findWhere(
+ where: [
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $dto->event_id,
+ ],
+ orderAndDirections: [
+ new OrderAndDirection(EventOccurrenceDomainObjectAbstract::START_DATE, 'asc'),
+ ],
+ )
+ ->first();
+
+ if ($occurrence !== null) {
+ $dto = CreateWaitlistEntryDTO::fromArray(array_merge(
+ $dto->toArray(),
+ ['event_occurrence_id' => $occurrence->getId()],
+ ));
+ }
+ }
+
$eventSettings = $this->eventSettingsRepository->findFirstWhere([
'event_id' => $dto->event_id,
]);
@@ -43,6 +83,24 @@ public function handle(CreateWaitlistEntryDTO $dto): WaitlistEntryDomainObject
throw new ResourceNotFoundException(__('Product not found for this event'));
}
+ if ($dto->event_occurrence_id !== null) {
+ $occurrence = $this->occurrenceRepository->findFirstWhere([
+ 'id' => $dto->event_occurrence_id,
+ 'event_id' => $dto->event_id,
+ ]);
+
+ if ($occurrence === null || $occurrence->isCancelled() || $occurrence->isPast()) {
+ throw ValidationException::withMessages([
+ 'event_occurrence_id' => __('This event date is no longer available.'),
+ ]);
+ }
+
+ $this->occurrenceEligibilityService->assertProductsVisibleOnOccurrence(
+ $dto->event_occurrence_id,
+ [$product->getId()],
+ );
+ }
+
return $this->createWaitlistEntryService->createEntry($dto, $eventSettings, $product);
}
}
diff --git a/backend/app/Services/Application/Handlers/Waitlist/DTO/CreateWaitlistEntryDTO.php b/backend/app/Services/Application/Handlers/Waitlist/DTO/CreateWaitlistEntryDTO.php
index a8da7ff6e1..b048599f16 100644
--- a/backend/app/Services/Application/Handlers/Waitlist/DTO/CreateWaitlistEntryDTO.php
+++ b/backend/app/Services/Application/Handlers/Waitlist/DTO/CreateWaitlistEntryDTO.php
@@ -13,6 +13,7 @@ public function __construct(
public string $first_name,
public ?string $last_name = null,
public string $locale = 'en',
+ public ?int $event_occurrence_id = null,
)
{
}
diff --git a/backend/app/Services/Application/Handlers/Waitlist/DTO/OfferWaitlistEntryDTO.php b/backend/app/Services/Application/Handlers/Waitlist/DTO/OfferWaitlistEntryDTO.php
index 84e7e1eed8..b8e58cb5c6 100644
--- a/backend/app/Services/Application/Handlers/Waitlist/DTO/OfferWaitlistEntryDTO.php
+++ b/backend/app/Services/Application/Handlers/Waitlist/DTO/OfferWaitlistEntryDTO.php
@@ -11,6 +11,7 @@ public function __construct(
public ?int $product_price_id = null,
public ?int $entry_id = null,
public int $quantity = 1,
+ public ?int $event_occurrence_id = null,
)
{
}
diff --git a/backend/app/Services/Application/Handlers/Waitlist/GetWaitlistStatsHandler.php b/backend/app/Services/Application/Handlers/Waitlist/GetWaitlistStatsHandler.php
index 69e56fab92..a71b9950e9 100644
--- a/backend/app/Services/Application/Handlers/Waitlist/GetWaitlistStatsHandler.php
+++ b/backend/app/Services/Application/Handlers/Waitlist/GetWaitlistStatsHandler.php
@@ -17,12 +17,16 @@ public function __construct(
{
}
- public function handle(int $eventId): WaitlistStatsDTO
+ public function handle(int $eventId, ?int $eventOccurrenceId = null): WaitlistStatsDTO
{
- $stats = $this->waitlistEntryRepository->getStatsByEventId($eventId);
- $productRows = $this->waitlistEntryRepository->getProductStatsByEventId($eventId);
+ $stats = $this->waitlistEntryRepository->getStatsByEventId($eventId, $eventOccurrenceId);
+ $productRows = $this->waitlistEntryRepository->getProductStatsByEventId($eventId, $eventOccurrenceId);
- $quantities = $this->availableQuantitiesService->getAvailableProductQuantities($eventId, ignoreCache: true);
+ $quantities = $this->availableQuantitiesService->getAvailableProductQuantities(
+ $eventId,
+ ignoreCache: true,
+ eventOccurrenceId: $eventOccurrenceId,
+ );
$products = $productRows->map(function ($row) use ($quantities) {
$actualAvailable = $this->getAvailableCountForPrice($quantities, (int) $row->product_price_id);
diff --git a/backend/app/Services/Application/Handlers/Waitlist/OfferWaitlistEntryHandler.php b/backend/app/Services/Application/Handlers/Waitlist/OfferWaitlistEntryHandler.php
index 1a6ad7bf2b..1132c4ada8 100644
--- a/backend/app/Services/Application/Handlers/Waitlist/OfferWaitlistEntryHandler.php
+++ b/backend/app/Services/Application/Handlers/Waitlist/OfferWaitlistEntryHandler.php
@@ -44,6 +44,7 @@ public function handle(OfferWaitlistEntryDTO $dto): Collection
quantity: $dto->quantity,
event: $event,
eventSettings: $eventSettings,
+ eventOccurrenceId: $dto->event_occurrence_id,
);
}
}
diff --git a/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php b/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php
index 7dceaaef96..a1f0b05298 100644
--- a/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php
+++ b/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php
@@ -32,7 +32,8 @@ public function send(
$order,
$event,
$eventSettings,
- $organizer
+ $organizer,
+ $attendee->getEventOccurrence(),
);
$this->mailer
diff --git a/backend/app/Services/Domain/CheckInList/CheckInListDataService.php b/backend/app/Services/Domain/CheckInList/CheckInListDataService.php
index 840a3bd7c1..ead3ccde7c 100644
--- a/backend/app/Services/Domain/CheckInList/CheckInListDataService.php
+++ b/backend/app/Services/Domain/CheckInList/CheckInListDataService.php
@@ -30,15 +30,40 @@ public function verifyAttendeeBelongsToCheckInList(
AttendeeDomainObject $attendee,
): void
{
- $allowedProductIds = $checkInList->getProducts()->map(fn($product) => $product->getId())->toArray() ?? [];
+ $allowedProductIds = $checkInList->getProducts()?->map(fn($product) => $product->getId())->toArray() ?? [];
- if (!in_array($attendee->getProductId(), $allowedProductIds, true)) {
+ // A list with zero product attachments covers every ticket on the event;
+ // we only reject when it has specific product scope AND the attendee's
+ // product isn't in it.
+ if (!empty($allowedProductIds) && !in_array($attendee->getProductId(), $allowedProductIds, true)) {
throw new CannotCheckInException(
__('Attendee :attendee_name is not allowed to check in using this check-in list', [
'attendee_name' => $attendee->getFullName(),
])
);
}
+
+ // Belt-and-braces when the list covers all tickets: the attendee's
+ // event must match the list's event. Normally the data model already
+ // guarantees this via the product FK, but for the empty-attachments
+ // case we have no product chain to rely on.
+ if (empty($allowedProductIds) && $attendee->getEventId() !== $checkInList->getEventId()) {
+ throw new CannotCheckInException(
+ __('Attendee :attendee_name does not belong to this event', [
+ 'attendee_name' => $attendee->getFullName(),
+ ])
+ );
+ }
+
+ if ($checkInList->getEventOccurrenceId() !== null
+ && $attendee->getEventOccurrenceId() !== $checkInList->getEventOccurrenceId()
+ ) {
+ throw new CannotCheckInException(
+ __(':attendee_name\'s ticket is for a different session — check they\'re on the right check-in list.', [
+ 'attendee_name' => $attendee->getFullName(),
+ ])
+ );
+ }
}
/**
diff --git a/backend/app/Services/Domain/CheckInList/CheckInListProductAssociationService.php b/backend/app/Services/Domain/CheckInList/CheckInListProductAssociationService.php
index 2153c44620..0eb0036ef4 100644
--- a/backend/app/Services/Domain/CheckInList/CheckInListProductAssociationService.php
+++ b/backend/app/Services/Domain/CheckInList/CheckInListProductAssociationService.php
@@ -35,16 +35,16 @@ private function associateProductsWithCheckInList(
bool $removePreviousAssignments = true
): void
{
- if (empty($productIds)) {
- return;
- }
-
if ($removePreviousAssignments) {
$this->productRepository->removeCheckInListFromProducts(
checkInListId: $checkInListId,
);
}
+ if (empty($productIds)) {
+ return;
+ }
+
$this->productRepository->addCheckInListToProducts(
checkInListId: $checkInListId,
productIds: array_unique($productIds),
diff --git a/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInService.php b/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInService.php
index 47c25356b6..9f776ca34e 100644
--- a/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInService.php
+++ b/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInService.php
@@ -262,6 +262,7 @@ private function createCheckIn(
AttendeeCheckInDomainObjectAbstract::PRODUCT_ID => $attendee->getProductId(),
AttendeeCheckInDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::CHECK_IN_PREFIX),
AttendeeCheckInDomainObjectAbstract::EVENT_ID => $checkInList->getEventId(),
+ AttendeeCheckInDomainObjectAbstract::EVENT_OCCURRENCE_ID => $attendee->getEventOccurrenceId(),
]);
}
}
diff --git a/backend/app/Services/Domain/CheckInList/CreateCheckInListService.php b/backend/app/Services/Domain/CheckInList/CreateCheckInListService.php
index a4e95f929c..abd12c745a 100644
--- a/backend/app/Services/Domain/CheckInList/CreateCheckInListService.php
+++ b/backend/app/Services/Domain/CheckInList/CreateCheckInListService.php
@@ -15,12 +15,11 @@
class CreateCheckInListService
{
public function __construct(
- private readonly CheckInListRepositoryInterface $checkInListRepository,
- private readonly EventProductValidationService $eventProductValidationService,
+ private readonly CheckInListRepositoryInterface $checkInListRepository,
+ private readonly EventProductValidationService $eventProductValidationService,
private readonly CheckInListProductAssociationService $checkInListProductAssociationService,
private readonly DatabaseManager $databaseManager,
private readonly EventRepositoryInterface $eventRepository,
-
)
{
}
@@ -38,6 +37,7 @@ public function createCheckInList(CheckInListDomainObject $checkInList, array $p
CheckInListDomainObjectAbstract::NAME => $checkInList->getName(),
CheckInListDomainObjectAbstract::DESCRIPTION => $checkInList->getDescription(),
CheckInListDomainObjectAbstract::EVENT_ID => $checkInList->getEventId(),
+ CheckInListDomainObjectAbstract::EVENT_OCCURRENCE_ID => $checkInList->getEventOccurrenceId(),
CheckInListDomainObjectAbstract::EXPIRES_AT => $checkInList->getExpiresAt()
? DateHelper::convertToUTC($checkInList->getExpiresAt(), $event->getTimezone())
: null,
@@ -45,6 +45,9 @@ public function createCheckInList(CheckInListDomainObject $checkInList, array $p
? DateHelper::convertToUTC($checkInList->getActivatesAt(), $event->getTimezone())
: null,
CheckInListDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::CHECK_IN_LIST_PREFIX),
+ CheckInListDomainObjectAbstract::PUBLIC_SHOW_ATTENDEE_NOTES => $checkInList->getPublicShowAttendeeNotes(),
+ CheckInListDomainObjectAbstract::PUBLIC_SHOW_QUESTION_ANSWERS => $checkInList->getPublicShowQuestionAnswers(),
+ CheckInListDomainObjectAbstract::PUBLIC_SHOW_ORDER_DETAILS => $checkInList->getPublicShowOrderDetails(),
]);
$this->checkInListProductAssociationService->addCheckInListToProducts(
diff --git a/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php b/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php
index 11a441deaf..a3c7191518 100644
--- a/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php
+++ b/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php
@@ -37,12 +37,16 @@ public function updateCheckInList(CheckInListDomainObject $checkInList, array $p
CheckInListDomainObjectAbstract::NAME => $checkInList->getName(),
CheckInListDomainObjectAbstract::DESCRIPTION => $checkInList->getDescription(),
CheckInListDomainObjectAbstract::EVENT_ID => $checkInList->getEventId(),
+ CheckInListDomainObjectAbstract::EVENT_OCCURRENCE_ID => $checkInList->getEventOccurrenceId(),
CheckInListDomainObjectAbstract::EXPIRES_AT => $checkInList->getExpiresAt()
? DateHelper::convertToUTC($checkInList->getExpiresAt(), $event->getTimezone())
: null,
CheckInListDomainObjectAbstract::ACTIVATES_AT => $checkInList->getActivatesAt()
? DateHelper::convertToUTC($checkInList->getActivatesAt(), $event->getTimezone())
: null,
+ CheckInListDomainObjectAbstract::PUBLIC_SHOW_ATTENDEE_NOTES => $checkInList->getPublicShowAttendeeNotes(),
+ CheckInListDomainObjectAbstract::PUBLIC_SHOW_QUESTION_ANSWERS => $checkInList->getPublicShowQuestionAnswers(),
+ CheckInListDomainObjectAbstract::PUBLIC_SHOW_ORDER_DETAILS => $checkInList->getPublicShowOrderDetails(),
],
where: [
CheckInListDomainObjectAbstract::ID => $checkInList->getId(),
diff --git a/backend/app/Services/Domain/Email/EmailTemplateService.php b/backend/app/Services/Domain/Email/EmailTemplateService.php
index 9d864dc8c4..797b685575 100644
--- a/backend/app/Services/Domain/Email/EmailTemplateService.php
+++ b/backend/app/Services/Domain/Email/EmailTemplateService.php
@@ -151,6 +151,10 @@ private function getDefaultCTAs(): array
'label' => __('View Ticket'),
'url_token' => 'ticket.url',
],
+ EmailTemplateType::OCCURRENCE_CANCELLATION->value => [
+ 'label' => __('View Event'),
+ 'url_token' => 'event.url',
+ ],
];
}
@@ -191,6 +195,23 @@ private function getDefaultTemplates(): array
If you have any questions or need assistance, please contact {{ settings.support_email }} .
+Best regards,
+{{ organizer.name }}
+LIQUID
+ ],
+ EmailTemplateType::OCCURRENCE_CANCELLATION->value => [
+ 'subject' => '{{ event.title }} on {{ occurrence.start_date }} has been cancelled',
+ 'body' => <<<'LIQUID'
+Hello,
+
+We're sorry to let you know that {{ event.title }} scheduled for {{ occurrence.start_date }} at {{ occurrence.start_time }} has been cancelled.
+
+{% if cancellation.refund_issued %}
+A refund for your order will be processed automatically. Please allow a few business days for the refund to appear on your statement.
+{% else %}
+If you have any questions about your order, please respond to this email or contact {{ settings.support_email }} .
+{% endif %}
+
Best regards,
{{ organizer.name }}
LIQUID
diff --git a/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php b/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php
index c0f71aa1a1..d9ea3e0d46 100644
--- a/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php
+++ b/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php
@@ -6,6 +6,7 @@
use HiEvents\DomainObjects\AttendeeDomainObject;
use HiEvents\DomainObjects\Enums\PaymentProviders;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
@@ -23,18 +24,21 @@ public function buildOrderConfirmationContext(
OrderDomainObject $order,
EventDomainObject $event,
OrganizerDomainObject $organizer,
- EventSettingDomainObject $eventSettings
+ EventSettingDomainObject $eventSettings,
+ ?EventOccurrenceDomainObject $occurrence = null,
): array
{
- $eventStartDate = new Carbon(DateHelper::convertFromUTC($event->getStartDate(), $event->getTimezone()));
- $eventEndDate = $event->getEndDate() ? new Carbon(DateHelper::convertFromUTC($event->getEndDate(), $event->getTimezone())) : null;
+ $startDateRaw = $occurrence?->getStartDate() ?? $event->getStartDate();
+ $endDateRaw = $occurrence?->getEndDate() ?? $event->getEndDate();
+
+ $eventStartDate = $startDateRaw ? new Carbon(DateHelper::convertFromUTC($startDateRaw, $event->getTimezone())) : null;
+ $eventEndDate = $endDateRaw ? new Carbon(DateHelper::convertFromUTC($endDateRaw, $event->getTimezone())) : null;
return [
- // Event object
'event' => [
- 'title' => $event->getTitle(),
- 'date' => $eventStartDate->format('F j, Y'),
- 'time' => $eventStartDate->format('g:i A'),
+ 'title' => $event->getTitle() . ($occurrence?->getLabel() ? ' - ' . $occurrence->getLabel() : ''),
+ 'date' => $eventStartDate?->format('F j, Y') ?? '',
+ 'time' => $eventStartDate?->format('g:i A') ?? '',
'end_date' => $eventEndDate?->format('F j, Y') ?? '',
'end_time' => $eventEndDate?->format('g:i A') ?? '',
'full_address' => $eventSettings->getLocationDetails() ? AddressHelper::formatAddress($eventSettings->getLocationDetails()) : '',
@@ -43,7 +47,6 @@ public function buildOrderConfirmationContext(
'timezone' => $event->getTimezone(),
],
- // Order object
'order' => [
'url' => sprintf(
Url::getFrontEndUrlFromConfig(Url::ORDER_SUMMARY),
@@ -53,8 +56,8 @@ public function buildOrderConfirmationContext(
'number' => $order->getPublicId(),
'total' => Currency::format($order->getTotalGross(), $event->getCurrency()),
'date' => (new Carbon($order->getCreatedAt()))->format('F j, Y'),
- 'currency' => $order->getCurrency(), // added
- 'locale' => $order->getLocale(), // added
+ 'currency' => $order->getCurrency(),
+ 'locale' => $order->getLocale(),
'first_name' => $order->getFirstName() ?? '',
'last_name' => $order->getLastName() ?? '',
'email' => $order->getEmail() ?? '',
@@ -62,18 +65,24 @@ public function buildOrderConfirmationContext(
'is_offline_payment' => $order->getPaymentProvider() === PaymentProviders::OFFLINE->value,
],
- // Organizer object
'organizer' => [
'name' => $organizer->getName() ?? '',
'email' => $organizer->getEmail() ?? '',
],
- // Settings object
'settings' => [
'support_email' => $eventSettings->getSupportEmail() ?? $organizer->getEmail() ?? '',
'offline_payment_instructions' => $eventSettings->getOfflinePaymentInstructions() ?? '',
'post_checkout_message' => $eventSettings->getPostCheckoutMessage() ?? '',
],
+
+ 'occurrence' => [
+ 'start_date' => $eventStartDate?->format('F j, Y') ?? '',
+ 'start_time' => $eventStartDate?->format('g:i A') ?? '',
+ 'end_date' => $eventEndDate?->format('F j, Y') ?? '',
+ 'end_time' => $eventEndDate?->format('g:i A') ?? '',
+ 'label' => $occurrence?->getLabel() ?? '',
+ ],
];
}
@@ -82,10 +91,11 @@ public function buildAttendeeTicketContext(
OrderDomainObject $order,
EventDomainObject $event,
OrganizerDomainObject $organizer,
- EventSettingDomainObject $eventSettings
+ EventSettingDomainObject $eventSettings,
+ ?EventOccurrenceDomainObject $occurrence = null,
): array
{
- $baseContext = $this->buildOrderConfirmationContext($order, $event, $organizer, $eventSettings);
+ $baseContext = $this->buildOrderConfirmationContext($order, $event, $organizer, $eventSettings, $occurrence);
/** @var OrderItemDomainObject $orderItem */
$orderItem = $order->getOrderItems()->first(fn(OrderItemDomainObject $item) => $item->getProductPriceId() === $attendee->getProductPriceId());
@@ -112,6 +122,61 @@ public function buildAttendeeTicketContext(
return $baseContext;
}
+ public function buildOccurrenceCancellationContext(
+ EventDomainObject $event,
+ EventOccurrenceDomainObject $occurrence,
+ OrganizerDomainObject $organizer,
+ EventSettingDomainObject $eventSettings,
+ bool $refundOrders = false,
+ ): array
+ {
+ $startDateRaw = $occurrence->getStartDate();
+ $endDateRaw = $occurrence->getEndDate();
+
+ $eventStartDate = $startDateRaw ? new Carbon(DateHelper::convertFromUTC($startDateRaw, $event->getTimezone())) : null;
+ $eventEndDate = $endDateRaw ? new Carbon(DateHelper::convertFromUTC($endDateRaw, $event->getTimezone())) : null;
+
+ return [
+ 'event' => [
+ 'title' => $event->getTitle() . ($occurrence->getLabel() ? ' - ' . $occurrence->getLabel() : ''),
+ 'date' => $eventStartDate?->format('F j, Y') ?? '',
+ 'time' => $eventStartDate?->format('g:i A') ?? '',
+ 'end_date' => $eventEndDate?->format('F j, Y') ?? '',
+ 'end_time' => $eventEndDate?->format('g:i A') ?? '',
+ 'full_address' => $eventSettings->getLocationDetails() ? AddressHelper::formatAddress($eventSettings->getLocationDetails()) : '',
+ 'location_details' => $eventSettings->getLocationDetails(),
+ 'description' => $event->getDescription() ?? '',
+ 'timezone' => $event->getTimezone(),
+ 'url' => sprintf(
+ Url::getFrontEndUrlFromConfig(Url::EVENT_HOMEPAGE),
+ $event->getId(),
+ $event->getSlug(),
+ ),
+ ],
+
+ 'occurrence' => [
+ 'start_date' => $eventStartDate?->format('F j, Y') ?? '',
+ 'start_time' => $eventStartDate?->format('g:i A') ?? '',
+ 'end_date' => $eventEndDate?->format('F j, Y') ?? '',
+ 'end_time' => $eventEndDate?->format('g:i A') ?? '',
+ 'label' => $occurrence->getLabel() ?? '',
+ ],
+
+ 'organizer' => [
+ 'name' => $organizer->getName() ?? '',
+ 'email' => $organizer->getEmail() ?? '',
+ ],
+
+ 'settings' => [
+ 'support_email' => $eventSettings->getSupportEmail() ?? $organizer->getEmail() ?? '',
+ ],
+
+ 'cancellation' => [
+ 'refund_issued' => $refundOrders,
+ ],
+ ];
+ }
+
public function buildPreviewContext(string $templateType): array
{
$baseContext = [
@@ -158,6 +223,14 @@ public function buildPreviewContext(string $templateType): array
],
];
+ $baseContext['occurrence'] = [
+ 'start_date' => 'April 25, 2029',
+ 'start_time' => '7:00 PM',
+ 'end_date' => 'April 26, 2029',
+ 'end_time' => '11:00 PM',
+ 'label' => 'Session A',
+ ];
+
if ($templateType === 'attendee_ticket') {
$baseContext['attendee'] = [
'name' => 'John Smith',
@@ -170,6 +243,13 @@ public function buildPreviewContext(string $templateType): array
];
}
+ if ($templateType === 'occurrence_cancellation') {
+ $baseContext['cancellation'] = [
+ 'refund_issued' => true,
+ ];
+ $baseContext['event']['url'] = 'https://example.com/event/123/summer-fest';
+ }
+
return $baseContext;
}
}
diff --git a/backend/app/Services/Domain/Email/MailBuilderService.php b/backend/app/Services/Domain/Email/MailBuilderService.php
index 59ef8806b3..135612260f 100644
--- a/backend/app/Services/Domain/Email/MailBuilderService.php
+++ b/backend/app/Services/Domain/Email/MailBuilderService.php
@@ -2,14 +2,18 @@
namespace HiEvents\Services\Domain\Email;
+use Carbon\Carbon;
use HiEvents\DomainObjects\AttendeeDomainObject;
use HiEvents\DomainObjects\Enums\EmailTemplateType;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\InvoiceDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
+use HiEvents\Helper\DateHelper;
use HiEvents\Mail\Attendee\AttendeeTicketMail;
+use HiEvents\Mail\Occurrence\OccurrenceCancellationMail;
use HiEvents\Mail\Order\OrderSummary;
use HiEvents\Services\Domain\Email\DTO\RenderedEmailTemplateDTO;
@@ -26,14 +30,16 @@ public function buildAttendeeTicketMail(
OrderDomainObject $order,
EventDomainObject $event,
EventSettingDomainObject $eventSettings,
- OrganizerDomainObject $organizer
+ OrganizerDomainObject $organizer,
+ ?EventOccurrenceDomainObject $occurrence = null,
): AttendeeTicketMail {
$renderedTemplate = $this->renderAttendeeTicketTemplate(
$attendee,
$order,
$event,
$eventSettings,
- $organizer
+ $organizer,
+ $occurrence,
);
return new AttendeeTicketMail(
@@ -43,6 +49,7 @@ public function buildAttendeeTicketMail(
eventSettings: $eventSettings,
organizer: $organizer,
renderedTemplate: $renderedTemplate,
+ occurrence: $occurrence,
);
}
@@ -51,13 +58,15 @@ public function buildOrderSummaryMail(
EventDomainObject $event,
EventSettingDomainObject $eventSettings,
OrganizerDomainObject $organizer,
- ?InvoiceDomainObject $invoice = null
+ ?InvoiceDomainObject $invoice = null,
+ ?EventOccurrenceDomainObject $occurrence = null,
): OrderSummary {
$renderedTemplate = $this->renderOrderSummaryTemplate(
$order,
$event,
$eventSettings,
- $organizer
+ $organizer,
+ $occurrence,
);
return new OrderSummary(
@@ -66,6 +75,7 @@ public function buildOrderSummaryMail(
organizer: $organizer,
eventSettings: $eventSettings,
invoice: $invoice,
+ occurrence: $occurrence,
renderedTemplate: $renderedTemplate,
);
}
@@ -75,7 +85,8 @@ private function renderAttendeeTicketTemplate(
OrderDomainObject $order,
EventDomainObject $event,
EventSettingDomainObject $eventSettings,
- OrganizerDomainObject $organizer
+ OrganizerDomainObject $organizer,
+ ?EventOccurrenceDomainObject $occurrence = null,
): ?RenderedEmailTemplateDTO {
$template = $this->emailTemplateService->getTemplateByType(
type: EmailTemplateType::ATTENDEE_TICKET,
@@ -93,7 +104,8 @@ private function renderAttendeeTicketTemplate(
$order,
$event,
$organizer,
- $eventSettings
+ $eventSettings,
+ $occurrence,
);
return $this->emailTemplateService->renderTemplate($template, $context);
@@ -103,7 +115,8 @@ private function renderOrderSummaryTemplate(
OrderDomainObject $order,
EventDomainObject $event,
EventSettingDomainObject $eventSettings,
- OrganizerDomainObject $organizer
+ OrganizerDomainObject $organizer,
+ ?EventOccurrenceDomainObject $occurrence = null,
): ?RenderedEmailTemplateDTO {
$template = $this->emailTemplateService->getTemplateByType(
type: EmailTemplateType::ORDER_CONFIRMATION,
@@ -120,7 +133,66 @@ private function renderOrderSummaryTemplate(
$order,
$event,
$organizer,
- $eventSettings
+ $eventSettings,
+ $occurrence,
+ );
+
+ return $this->emailTemplateService->renderTemplate($template, $context);
+ }
+
+ public function buildOccurrenceCancellationMail(
+ EventDomainObject $event,
+ EventOccurrenceDomainObject $occurrence,
+ OrganizerDomainObject $organizer,
+ EventSettingDomainObject $eventSettings,
+ bool $refundOrders = false,
+ ): OccurrenceCancellationMail {
+ $renderedTemplate = $this->renderOccurrenceCancellationTemplate(
+ $event,
+ $occurrence,
+ $eventSettings,
+ $organizer,
+ $refundOrders,
+ );
+
+ $startDate = DateHelper::convertFromUTC($occurrence->getStartDate(), $event->getTimezone());
+ $formattedDate = (new Carbon($startDate))->format('F j, Y g:i A');
+
+ return new OccurrenceCancellationMail(
+ event: $event,
+ occurrence: $occurrence,
+ organizer: $organizer,
+ eventSettings: $eventSettings,
+ formattedDate: $formattedDate,
+ refundOrders: $refundOrders,
+ renderedTemplate: $renderedTemplate,
+ );
+ }
+
+ private function renderOccurrenceCancellationTemplate(
+ EventDomainObject $event,
+ EventOccurrenceDomainObject $occurrence,
+ EventSettingDomainObject $eventSettings,
+ OrganizerDomainObject $organizer,
+ bool $refundOrders = false,
+ ): ?RenderedEmailTemplateDTO {
+ $template = $this->emailTemplateService->getTemplateByType(
+ type: EmailTemplateType::OCCURRENCE_CANCELLATION,
+ accountId: $event->getAccountId(),
+ eventId: $event->getId(),
+ organizerId: $organizer->getId()
+ );
+
+ if (!$template) {
+ return null;
+ }
+
+ $context = $this->tokenContextBuilder->buildOccurrenceCancellationContext(
+ $event,
+ $occurrence,
+ $organizer,
+ $eventSettings,
+ $refundOrders,
);
return $this->emailTemplateService->renderTemplate($template, $context);
diff --git a/backend/app/Services/Domain/Event/CreateEventService.php b/backend/app/Services/Domain/Event/CreateEventService.php
index 7126eda943..ed3f57ba8c 100644
--- a/backend/app/Services/Domain/Event/CreateEventService.php
+++ b/backend/app/Services/Domain/Event/CreateEventService.php
@@ -2,6 +2,8 @@
namespace HiEvents\Services\Domain\Event;
+use HiEvents\DomainObjects\Enums\AttendeeDetailsCollectionMethod;
+use HiEvents\DomainObjects\Enums\EventType;
use HiEvents\DomainObjects\Enums\HomepageBackgroundType;
use HiEvents\DomainObjects\Enums\ImageType;
use HiEvents\DomainObjects\Enums\PaymentProviders;
@@ -12,6 +14,8 @@
use HiEvents\Exceptions\OrganizerNotFoundException;
use HiEvents\Helper\DateHelper;
use HiEvents\Helper\IdHelper;
+use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface;
use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface;
@@ -26,34 +30,35 @@
class CreateEventService
{
public function __construct(
- private readonly EventRepositoryInterface $eventRepository,
- private readonly EventSettingsRepositoryInterface $eventSettingsRepository,
- private readonly OrganizerRepositoryInterface $organizerRepository,
- private readonly DatabaseManager $databaseManager,
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly EventSettingsRepositoryInterface $eventSettingsRepository,
+ private readonly OrganizerRepositoryInterface $organizerRepository,
+ private readonly DatabaseManager $databaseManager,
private readonly EventStatisticRepositoryInterface $eventStatisticsRepository,
- private readonly HtmlPurifierService $purifier,
- private readonly ImageRepositoryInterface $imageRepository,
- private readonly Repository $config,
- private readonly FilesystemManager $filesystemManager,
- )
- {
- }
+ private readonly HtmlPurifierService $purifier,
+ private readonly ImageRepositoryInterface $imageRepository,
+ private readonly Repository $config,
+ private readonly FilesystemManager $filesystemManager,
+ private readonly EventOccurrenceRepositoryInterface $occurrenceRepository,
+ private readonly CheckInListRepositoryInterface $checkInListRepository,
+ ) {}
/**
* @throws Throwable
*/
public function createEvent(
- EventDomainObject $eventData,
- ?EventSettingDomainObject $eventSettings = null
- ): EventDomainObject
- {
- return $this->databaseManager->transaction(function () use ($eventData, $eventSettings) {
+ EventDomainObject $eventData,
+ ?string $startDate = null,
+ ?string $endDate = null,
+ ?EventSettingDomainObject $eventSettings = null,
+ ): EventDomainObject {
+ return $this->databaseManager->transaction(function () use ($eventData, $startDate, $endDate, $eventSettings) {
$organizer = $this->getOrganizer(
organizerId: $eventData->getOrganizerId(),
accountId: $eventData->getAccountId()
);
- $event = $this->handleEventCreate($eventData);
+ $event = $this->handleEventCreate($eventData, $startDate, $endDate);
$eventCoverCreated = $this->createEventCover($event);
@@ -66,10 +71,29 @@ public function createEvent(
$this->createEventStatistics($event);
+ $this->createSystemDefaultCheckInList($event);
+
return $event;
});
}
+ /**
+ * Every event gets a default "covers every ticket" check-in list at creation
+ * time so staff can open check-in the moment tickets exist.
+ */
+ private function createSystemDefaultCheckInList(EventDomainObject $event): void
+ {
+ $this->checkInListRepository->create([
+ 'event_id' => $event->getId(),
+ 'short_id' => IdHelper::shortId(IdHelper::CHECK_IN_LIST_PREFIX),
+ 'name' => __('Default check-in'),
+ 'is_system_default' => true,
+ 'public_show_attendee_notes' => true,
+ 'public_show_question_answers' => true,
+ 'public_show_order_details' => true,
+ ]);
+ }
+
/**
* @throws OrganizerNotFoundException
*/
@@ -91,15 +115,11 @@ private function getOrganizer(int $organizerId, int $accountId): OrganizerDomain
return $organizer;
}
- private function handleEventCreate(EventDomainObject $eventData): EventDomainObject
+ private function handleEventCreate(EventDomainObject $eventData, ?string $startDate = null, ?string $endDate = null): EventDomainObject
{
- return $this->eventRepository->create([
+ $event = $this->eventRepository->create([
'title' => $eventData->getTitle(),
'organizer_id' => $eventData->getOrganizerId(),
- 'start_date' => DateHelper::convertToUTC($eventData->getStartDate(), $eventData->getTimezone()),
- 'end_date' => $eventData->getEndDate()
- ? DateHelper::convertToUTC($eventData->getEndDate(), $eventData->getTimezone())
- : null,
'description' => $this->purifier->purify($eventData->getDescription()),
'timezone' => $eventData->getTimezone(),
'currency' => $eventData->getCurrency(),
@@ -110,7 +130,23 @@ private function handleEventCreate(EventDomainObject $eventData): EventDomainObj
'status' => $eventData->getStatus(),
'short_id' => IdHelper::shortId(IdHelper::EVENT_PREFIX),
'attributes' => $eventData->getAttributes(),
+ 'type' => $eventData->getType() ?? EventType::SINGLE->name,
+ 'recurrence_rule' => $eventData->getRecurrenceRule(),
]);
+
+ if (($eventData->getType() ?? EventType::SINGLE->name) === EventType::SINGLE->name && $startDate !== null) {
+ $this->occurrenceRepository->create([
+ 'event_id' => $event->getId(),
+ 'short_id' => IdHelper::shortId(IdHelper::OCCURRENCE_PREFIX),
+ 'start_date' => DateHelper::convertToUTC($startDate, $eventData->getTimezone()),
+ 'end_date' => $endDate ? DateHelper::convertToUTC($endDate, $eventData->getTimezone()) : null,
+ 'status' => 'ACTIVE',
+ 'used_capacity' => 0,
+ 'is_overridden' => false,
+ ]);
+ }
+
+ return $event;
}
private function createEventStatistics(EventDomainObject $event): void
@@ -128,19 +164,16 @@ private function createEventStatistics(EventDomainObject $event): void
/**
* If a default cover image exists for the event category, it will be created.
- *
- * @param EventDomainObject $event
- * @return bool
*/
private function createEventCover(EventDomainObject $event): bool
{
$disk = $this->config->get('filesystems.public');
$defaultCoversPath = $this->config->get('app.event_categories_cover_images_path');
- $imageFilename = $event->getCategory() . '.jpg';
- $imagePath = $defaultCoversPath . '/' . $imageFilename;
+ $imageFilename = $event->getCategory().'.jpg';
+ $imagePath = $defaultCoversPath.'/'.$imageFilename;
- if (!$this->filesystemManager->disk($disk)->exists($imagePath)) {
+ if (! $this->filesystemManager->disk($disk)->exists($imagePath)) {
return false;
}
@@ -161,11 +194,10 @@ private function createEventCover(EventDomainObject $event): bool
private function createEventSettings(
?EventSettingDomainObject $eventSettings,
- EventDomainObject $event,
- OrganizerDomainObject $organizer,
- bool $eventCoverCreated = false
- ): void
- {
+ EventDomainObject $event,
+ OrganizerDomainObject $organizer,
+ bool $eventCoverCreated = false
+ ): void {
if ($eventSettings !== null) {
$eventSettings->setEventId($event->getId());
$eventSettingsArray = $eventSettings->toArray();
@@ -220,7 +252,12 @@ private function createEventSettings(
'organization_address' => null,
'invoice_tax_details' => null,
- 'attendee_details_collection_method' => $organizerSettings->getDefaultAttendeeDetailsCollectionMethod(),
+ // Recurring events default to per-order collection — each order typically
+ // covers multiple sessions, and collecting per-attendee details every time
+ // is high friction. Single events inherit the organizer-level default.
+ 'attendee_details_collection_method' => $event->getType() === EventType::RECURRING->name
+ ? AttendeeDetailsCollectionMethod::PER_ORDER->value
+ : $organizerSettings->getDefaultAttendeeDetailsCollectionMethod(),
'show_marketing_opt_in' => $organizerSettings->getDefaultShowMarketingOptIn(),
'pass_platform_fee_to_buyer' => $organizerSettings->getDefaultPassPlatformFeeToBuyer(),
'allow_attendee_self_edit' => $organizerSettings->getDefaultAllowAttendeeSelfEdit() ?? false,
diff --git a/backend/app/Services/Domain/Event/DTO/DuplicateEventDataDTO.php b/backend/app/Services/Domain/Event/DTO/DuplicateEventDataDTO.php
index 003ac4ba20..c8af055156 100644
--- a/backend/app/Services/Domain/Event/DTO/DuplicateEventDataDTO.php
+++ b/backend/app/Services/Domain/Event/DTO/DuplicateEventDataDTO.php
@@ -21,6 +21,7 @@ public function __construct(
public bool $duplicateTicketLogo = true,
public bool $duplicateWebhooks = true,
public bool $duplicateAffiliates = true,
+ public bool $duplicateOccurrences = true,
public ?string $description = null,
public ?string $endDate = null,
)
diff --git a/backend/app/Services/Domain/Event/DuplicateEventService.php b/backend/app/Services/Domain/Event/DuplicateEventService.php
index 22326cb041..8fb8f6caa5 100644
--- a/backend/app/Services/Domain/Event/DuplicateEventService.php
+++ b/backend/app/Services/Domain/Event/DuplicateEventService.php
@@ -5,9 +5,11 @@
use HiEvents\DomainObjects\AffiliateDomainObject;
use HiEvents\DomainObjects\CapacityAssignmentDomainObject;
use HiEvents\DomainObjects\CheckInListDomainObject;
+use HiEvents\DomainObjects\Enums\EventType;
use HiEvents\DomainObjects\Enums\ImageType;
use HiEvents\DomainObjects\Enums\QuestionBelongsTo;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\ImageDomainObject;
use HiEvents\DomainObjects\ProductCategoryDomainObject;
@@ -15,13 +17,18 @@
use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\DomainObjects\PromoCodeDomainObject;
use HiEvents\DomainObjects\QuestionDomainObject;
+use HiEvents\DomainObjects\Status\EventOccurrenceStatus;
use HiEvents\DomainObjects\Status\EventStatus;
use HiEvents\DomainObjects\TaxAndFeesDomainObject;
use HiEvents\DomainObjects\WebhookDomainObject;
+use HiEvents\Helper\IdHelper;
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\AffiliateRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\ImageRepositoryInterface;
+use HiEvents\Repository\Interfaces\ProductOccurrenceVisibilityRepositoryInterface;
+use HiEvents\Repository\Interfaces\ProductPriceOccurrenceOverrideRepositoryInterface;
use HiEvents\Services\Domain\CapacityAssignment\CreateCapacityAssignmentService;
use HiEvents\Services\Domain\CheckInList\CreateCheckInListService;
use HiEvents\Services\Domain\CreateWebhookService;
@@ -36,45 +43,46 @@
class DuplicateEventService
{
public function __construct(
- private readonly EventRepositoryInterface $eventRepository,
- private readonly CreateEventService $createEventService,
- private readonly CreateProductService $createProductService,
- private readonly CreateQuestionService $createQuestionService,
- private readonly CreatePromoCodeService $createPromoCodeService,
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly CreateEventService $createEventService,
+ private readonly CreateProductService $createProductService,
+ private readonly CreateQuestionService $createQuestionService,
+ private readonly CreatePromoCodeService $createPromoCodeService,
private readonly CreateCapacityAssignmentService $createCapacityAssignmentService,
- private readonly CreateCheckInListService $createCheckInListService,
- private readonly ImageRepositoryInterface $imageRepository,
- private readonly DatabaseManager $databaseManager,
- private readonly HtmlPurifierService $purifier,
- private readonly CreateProductCategoryService $createProductCategoryService,
- private readonly CreateWebhookService $createWebhookService,
- private readonly AffiliateRepositoryInterface $affiliateRepository,
- )
- {
- }
+ private readonly CreateCheckInListService $createCheckInListService,
+ private readonly ImageRepositoryInterface $imageRepository,
+ private readonly DatabaseManager $databaseManager,
+ private readonly HtmlPurifierService $purifier,
+ private readonly CreateProductCategoryService $createProductCategoryService,
+ private readonly CreateWebhookService $createWebhookService,
+ private readonly AffiliateRepositoryInterface $affiliateRepository,
+ private readonly EventOccurrenceRepositoryInterface $eventOccurrenceRepository,
+ private readonly ProductPriceOccurrenceOverrideRepositoryInterface $priceOverrideRepository,
+ private readonly ProductOccurrenceVisibilityRepositoryInterface $visibilityRepository,
+ ) {}
/**
* @throws Throwable
*/
public function duplicateEvent(
- string $eventId,
- string $accountId,
- string $title,
- string $startDate,
- bool $duplicateProducts = true,
- bool $duplicateQuestions = true,
- bool $duplicateSettings = true,
- bool $duplicatePromoCodes = true,
- bool $duplicateCapacityAssignments = true,
- bool $duplicateCheckInLists = true,
- bool $duplicateEventCoverImage = true,
- bool $duplicateTicketLogo = true,
- bool $duplicateWebhooks = true,
- bool $duplicateAffiliates = true,
+ string $eventId,
+ string $accountId,
+ string $title,
+ string $startDate,
+ bool $duplicateProducts = true,
+ bool $duplicateQuestions = true,
+ bool $duplicateSettings = true,
+ bool $duplicatePromoCodes = true,
+ bool $duplicateCapacityAssignments = true,
+ bool $duplicateCheckInLists = true,
+ bool $duplicateEventCoverImage = true,
+ bool $duplicateTicketLogo = true,
+ bool $duplicateWebhooks = true,
+ bool $duplicateAffiliates = true,
+ bool $duplicateOccurrences = true,
?string $description = null,
?string $endDate = null,
- ): EventDomainObject
- {
+ ): EventDomainObject {
try {
$this->databaseManager->beginTransaction();
@@ -82,33 +90,45 @@ public function duplicateEvent(
$event
->setTitle($title)
- ->setStartDate($startDate)
- ->setEndDate($endDate)
->setDescription($this->purifier->purify($description))
->setStatus(EventStatus::DRAFT->name);
$newEvent = $this->cloneExistingEvent(
event: $event,
cloneEventSettings: $duplicateSettings,
+ startDate: $startDate,
+ endDate: $endDate,
);
+ $oldToNewOccurrenceMap = [];
+ if ($duplicateOccurrences && $event->getType() === EventType::RECURRING->name) {
+ $oldToNewOccurrenceMap = $this->cloneOccurrences($event, $newEvent->getId());
+ }
+
if ($duplicateQuestions) {
$this->clonePerOrderQuestions($event, $newEvent->getId());
}
+ $oldPriceToNewPriceMap = [];
+ $oldProductToNewProductMap = [];
if ($duplicateProducts) {
- $this->cloneExistingProducts(
+ [$oldProductToNewProductMap, $oldPriceToNewPriceMap] = $this->cloneExistingProducts(
event: $event,
newEventId: $newEvent->getId(),
duplicateQuestions: $duplicateQuestions,
duplicatePromoCodes: $duplicatePromoCodes,
duplicateCapacityAssignments: $duplicateCapacityAssignments,
duplicateCheckInLists: $duplicateCheckInLists,
+ oldToNewOccurrenceMap: $oldToNewOccurrenceMap,
);
} else {
$this->createProductCategoryService->createDefaultProductCategory($newEvent);
}
+ if ($duplicateOccurrences && $duplicateProducts && ! empty($oldToNewOccurrenceMap)) {
+ $this->cloneOccurrenceProductSettings($oldToNewOccurrenceMap, $oldProductToNewProductMap, $oldPriceToNewPriceMap);
+ }
+
if ($duplicateEventCoverImage) {
$this->cloneEventCoverImage($event, $newEvent->getId());
}
@@ -135,48 +155,74 @@ public function duplicateEvent(
}
/**
- * @param EventDomainObject $event
- * @param bool $cloneEventSettings
- * @return EventDomainObject
* @throws Throwable
*/
- private function cloneExistingEvent(EventDomainObject $event, bool $cloneEventSettings): EventDomainObject
- {
+ private function cloneExistingEvent(
+ EventDomainObject $event,
+ bool $cloneEventSettings,
+ ?string $startDate = null,
+ ?string $endDate = null,
+ ): EventDomainObject {
return $this->createEventService->createEvent(
- eventData: (new EventDomainObject())
+ eventData: (new EventDomainObject)
->setOrganizerId($event->getOrganizerId())
->setAccountId($event->getAccountId())
->setUserId($event->getUserId())
->setTitle($event->getTitle())
->setCategory($event->getCategory())
- ->setStartDate($event->getStartDate())
- ->setEndDate($event->getEndDate())
->setDescription($event->getDescription())
->setAttributes($event->getAttributes())
->setTimezone($event->getTimezone())
->setCurrency($event->getCurrency())
- ->setStatus($event->getStatus()),
+ ->setStatus($event->getStatus())
+ ->setType($event->getType())
+ ->setRecurrenceRule($this->stripStaleExclusions($event->getRecurrenceRule())),
+ startDate: $startDate,
+ endDate: $endDate,
eventSettings: $cloneEventSettings ? $event->getEventSettings() : null,
);
}
+ /**
+ * The source's `excluded_dates` and `excluded_occurrences` both correspond
+ * to occurrences that were cancelled or deleted on the original event —
+ * those records aren't cloned (we only clone ACTIVE occurrences), so
+ * carrying their dates as exclusions on the duplicate would forever block
+ * those dates from regenerating with no record explaining why. Strip both
+ * on clone — additional_dates stay since they represent rule additions,
+ * not cancellations.
+ */
+ private function stripStaleExclusions(?array $rule): ?array
+ {
+ if ($rule === null) {
+ return null;
+ }
+ unset($rule['excluded_dates'], $rule['excluded_occurrences']);
+
+ return $rule;
+ }
+
/**
* @throws Throwable
*/
+ /**
+ * @return array{0: array, 1: array} [$oldProductToNewProductMap, $oldPriceToNewPriceMap]
+ */
private function cloneExistingProducts(
EventDomainObject $event,
- int $newEventId,
- bool $duplicateQuestions,
- bool $duplicatePromoCodes,
- bool $duplicateCapacityAssignments,
- bool $duplicateCheckInLists,
- ): void
- {
+ int $newEventId,
+ bool $duplicateQuestions,
+ bool $duplicatePromoCodes,
+ bool $duplicateCapacityAssignments,
+ bool $duplicateCheckInLists,
+ array $oldToNewOccurrenceMap = [],
+ ): array {
$oldProductToNewProductMap = [];
+ $oldPriceToNewPriceMap = [];
- $event->getProductCategories()?->each(function (ProductCategoryDomainObject $productCategory) use ($event, $newEventId, &$oldProductToNewProductMap) {
+ $event->getProductCategories()?->each(function (ProductCategoryDomainObject $productCategory) use ($event, $newEventId, &$oldProductToNewProductMap, &$oldPriceToNewPriceMap) {
$newCategory = $this->createProductCategoryService->createCategory(
- (new ProductCategoryDomainObject())
+ (new ProductCategoryDomainObject)
->setName($productCategory->getName())
->setNoProductsMessage($productCategory->getNoProductsMessage())
->setDescription($productCategory->getDescription())
@@ -191,9 +237,17 @@ private function cloneExistingProducts(
$newProduct = $this->createProductService->createProduct(
product: $product,
accountId: $event->getAccountId(),
- taxAndFeeIds: $product->getTaxAndFees()?->map(fn($taxAndFee) => $taxAndFee->getId())?->toArray(),
+ taxAndFeeIds: $product->getTaxAndFees()?->map(fn ($taxAndFee) => $taxAndFee->getId())?->toArray(),
);
$oldProductToNewProductMap[$product->getId()] = $newProduct->getId();
+
+ $oldPrices = $product->getProductPrices()?->all() ?? [];
+ $newPrices = $newProduct->getProductPrices()?->all() ?? [];
+ foreach ($oldPrices as $index => $oldPrice) {
+ if (isset($newPrices[$index])) {
+ $oldPriceToNewPriceMap[$oldPrice->getId()] = $newPrices[$index]->getId();
+ }
+ }
}
});
@@ -210,8 +264,10 @@ private function cloneExistingProducts(
}
if ($duplicateCheckInLists) {
- $this->cloneCheckInLists($event, $newEventId, $oldProductToNewProductMap);
+ $this->cloneCheckInLists($event, $newEventId, $oldProductToNewProductMap, $oldToNewOccurrenceMap);
}
+
+ return [$oldProductToNewProductMap, $oldPriceToNewPriceMap];
}
/**
@@ -222,7 +278,7 @@ private function clonePerProductQuestions(EventDomainObject $event, int $newEven
foreach ($event->getQuestions() as $question) {
if ($question->getBelongsTo() === QuestionBelongsTo::PRODUCT->name) {
$this->createQuestionService->createQuestion(
- (new QuestionDomainObject())
+ (new QuestionDomainObject)
->setTitle($question->getTitle())
->setEventId($newEventId)
->setBelongsTo($question->getBelongsTo())
@@ -231,7 +287,7 @@ private function clonePerProductQuestions(EventDomainObject $event, int $newEven
->setOptions($question->getOptions())
->setIsHidden($question->getIsHidden()),
array_map(
- static fn(ProductDomainObject $product) => $oldProductToNewProductMap[$product->getId()],
+ static fn (ProductDomainObject $product) => $oldProductToNewProductMap[$product->getId()],
$question->getProducts()?->all(),
),
);
@@ -247,7 +303,7 @@ private function clonePerOrderQuestions(EventDomainObject $event, int $newEventI
foreach ($event->getQuestions() as $question) {
if ($question->getBelongsTo() === QuestionBelongsTo::ORDER->name) {
$this->createQuestionService->createQuestion(
- (new QuestionDomainObject())
+ (new QuestionDomainObject)
->setTitle($question->getTitle())
->setDescription($question->getDescription())
->setEventId($newEventId)
@@ -269,11 +325,11 @@ private function clonePromoCodes(EventDomainObject $event, int $newEventId, arra
{
foreach ($event->getPromoCodes() as $promoCode) {
$this->createPromoCodeService->createPromoCode(
- (new PromoCodeDomainObject())
+ (new PromoCodeDomainObject)
->setCode($promoCode->getCode())
->setEventId($newEventId)
->setApplicableProductIds(array_map(
- static fn($productId) => $oldProductToNewProductMap[$productId],
+ static fn ($productId) => $oldProductToNewProductMap[$productId],
$promoCode->getApplicableProductIds() ?? [],
))
->setDiscountType($promoCode->getDiscountType())
@@ -289,30 +345,51 @@ private function cloneCapacityAssignments(EventDomainObject $event, int $newEven
/** @var CapacityAssignmentDomainObject $capacityAssignment */
foreach ($event->getCapacityAssignments() as $capacityAssignment) {
$this->createCapacityAssignmentService->createCapacityAssignment(
- capacityAssignment: (new CapacityAssignmentDomainObject())
+ capacityAssignment: (new CapacityAssignmentDomainObject)
->setName($capacityAssignment->getName())
->setEventId($newEventId)
->setCapacity($capacityAssignment->getCapacity())
->setAppliesTo($capacityAssignment->getAppliesTo())
->setStatus($capacityAssignment->getStatus()),
productIds: $capacityAssignment->getProducts()
- ?->map(fn($product) => $oldProductToNewProductMap[$product->getId()])?->toArray() ?? [],
+ ?->map(fn ($product) => $oldProductToNewProductMap[$product->getId()])?->toArray() ?? [],
);
}
}
- private function cloneCheckInLists(EventDomainObject $event, int $newEventId, $oldProductToNewProductMap): void
- {
+ private function cloneCheckInLists(
+ EventDomainObject $event,
+ int $newEventId,
+ array $oldProductToNewProductMap,
+ array $oldToNewOccurrenceMap = [],
+ ): void {
foreach ($event->getCheckInLists() as $checkInList) {
+ // CreateEventService already auto-created a system_default list on the
+ // new event — cloning the source's would produce two equivalent
+ // "covers every ticket" lists. Skip it.
+ if ($checkInList->getIsSystemDefault()) {
+ continue;
+ }
+
+ // Preserve occurrence scope: a list scoped to one source occurrence
+ // should map to the cloned occurrence on the duplicate. If the
+ // source occurrence was filtered out of the clone (cancelled or
+ // past), drop the scope rather than leaving it stale.
+ $sourceOccurrenceId = $checkInList->getEventOccurrenceId();
+ $newOccurrenceId = $sourceOccurrenceId !== null
+ ? ($oldToNewOccurrenceMap[$sourceOccurrenceId] ?? null)
+ : null;
+
$this->createCheckInListService->createCheckInList(
- checkInList: (new CheckInListDomainObject())
+ checkInList: (new CheckInListDomainObject)
->setName($checkInList->getName())
->setDescription($checkInList->getDescription())
->setExpiresAt($checkInList->getExpiresAt())
->setActivatesAt($checkInList->getActivatesAt())
+ ->setEventOccurrenceId($newOccurrenceId)
->setEventId($newEventId),
productIds: $checkInList->getProducts()
- ?->map(fn($product) => $oldProductToNewProductMap[$product->getId()])?->toArray() ?? [],
+ ?->map(fn ($product) => $oldProductToNewProductMap[$product->getId()])?->toArray() ?? [],
);
}
}
@@ -320,7 +397,7 @@ private function cloneCheckInLists(EventDomainObject $event, int $newEventId, $o
private function cloneEventCoverImage(EventDomainObject $event, int $newEventId): void
{
/** @var ImageDomainObject $coverImage */
- $coverImage = $event->getImages()?->first(fn(ImageDomainObject $image) => $image->getType() === ImageType::EVENT_COVER->name);
+ $coverImage = $event->getImages()?->first(fn (ImageDomainObject $image) => $image->getType() === ImageType::EVENT_COVER->name);
if ($coverImage) {
$this->imageRepository->create([
'entity_id' => $newEventId,
@@ -338,7 +415,7 @@ private function cloneEventCoverImage(EventDomainObject $event, int $newEventId)
private function cloneTicketLogo(EventDomainObject $event, int $newEventId): void
{
/** @var ImageDomainObject $ticketLogo */
- $ticketLogo = $event->getImages()?->first(fn(ImageDomainObject $image) => $image->getType() === ImageType::TICKET_LOGO->name);
+ $ticketLogo = $event->getImages()?->first(fn (ImageDomainObject $image) => $image->getType() === ImageType::TICKET_LOGO->name);
if ($ticketLogo) {
$this->imageRepository->create([
'entity_id' => $newEventId,
@@ -356,6 +433,7 @@ private function cloneTicketLogo(EventDomainObject $event, int $newEventId): voi
private function getEventWithRelations(string $eventId, string $accountId): EventDomainObject
{
return $this->eventRepository
+ ->loadRelation(EventOccurrenceDomainObject::class)
->loadRelation(EventSettingDomainObject::class)
->loadRelation(
new Relationship(ProductCategoryDomainObject::class, [
@@ -388,7 +466,7 @@ private function duplicateWebhooks(EventDomainObject $event, EventDomainObject $
{
$event->getWebhooks()?->each(function (WebhookDomainObject $webhook) use ($newEvent) {
$this->createWebhookService->createWebhook(
- (new WebhookDomainObject())
+ (new WebhookDomainObject)
->setEventId($newEvent->getId())
->setUrl($webhook->getUrl())
->setSecret($webhook->getSecret())
@@ -413,4 +491,75 @@ private function duplicateAffiliates(EventDomainObject $event, EventDomainObject
]);
});
}
+
+ /**
+ * @return array Map of old occurrence IDs to new occurrence IDs
+ */
+ private function cloneOccurrences(EventDomainObject $event, int $newEventId): array
+ {
+ $now = now()->toDateTimeString();
+ $oldToNewOccurrenceMap = [];
+
+ $event->getEventOccurrences()
+ ?->filter(fn (EventOccurrenceDomainObject $occurrence) => $occurrence->getStartDate() >= $now
+ && $occurrence->getStatus() !== EventOccurrenceStatus::CANCELLED->name
+ )
+ ->each(function (EventOccurrenceDomainObject $occurrence) use ($newEventId, &$oldToNewOccurrenceMap) {
+ $newOccurrence = $this->eventOccurrenceRepository->create([
+ 'event_id' => $newEventId,
+ 'start_date' => $occurrence->getStartDate(),
+ 'end_date' => $occurrence->getEndDate(),
+ 'status' => $occurrence->getStatus(),
+ 'capacity' => $occurrence->getCapacity(),
+ 'used_capacity' => 0,
+ 'label' => $occurrence->getLabel(),
+ 'is_overridden' => $occurrence->getIsOverridden(),
+ 'short_id' => IdHelper::shortId(IdHelper::OCCURRENCE_PREFIX),
+ ]);
+ $oldToNewOccurrenceMap[$occurrence->getId()] = $newOccurrence->getId();
+ });
+
+ return $oldToNewOccurrenceMap;
+ }
+
+ private function cloneOccurrenceProductSettings(
+ array $oldToNewOccurrenceMap,
+ array $oldProductToNewProductMap,
+ array $oldPriceToNewPriceMap,
+ ): void {
+ foreach ($oldToNewOccurrenceMap as $oldOccurrenceId => $newOccurrenceId) {
+ $priceOverrides = $this->priceOverrideRepository->findWhere([
+ 'event_occurrence_id' => $oldOccurrenceId,
+ ]);
+
+ foreach ($priceOverrides as $override) {
+ $newPriceId = $oldPriceToNewPriceMap[$override->getProductPriceId()] ?? null;
+ if ($newPriceId === null) {
+ continue;
+ }
+
+ $this->priceOverrideRepository->create([
+ 'event_occurrence_id' => $newOccurrenceId,
+ 'product_price_id' => $newPriceId,
+ 'price' => $override->getPrice(),
+ ]);
+ }
+
+ $visibilityRecords = $this->visibilityRepository->findWhere([
+ 'event_occurrence_id' => $oldOccurrenceId,
+ ]);
+
+ foreach ($visibilityRecords as $visibility) {
+ $newProductId = $oldProductToNewProductMap[$visibility->getProductId()] ?? null;
+ if ($newProductId === null) {
+ continue;
+ }
+
+ $this->visibilityRepository->create([
+ 'event_occurrence_id' => $newOccurrenceId,
+ 'product_id' => $newProductId,
+ ]);
+ }
+ }
+ }
}
diff --git a/backend/app/Services/Domain/Event/EventOccurrenceGeneratorService.php b/backend/app/Services/Domain/Event/EventOccurrenceGeneratorService.php
new file mode 100644
index 0000000000..fad6265c72
--- /dev/null
+++ b/backend/app/Services/Domain/Event/EventOccurrenceGeneratorService.php
@@ -0,0 +1,191 @@
+ruleParser->parse($recurrenceRule, $event->getTimezone() ?? 'UTC');
+
+ $existingOccurrences = $this->occurrenceRepository->findWhere([
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $event->getId(),
+ ]);
+
+ $existingByStartDate = collect($existingOccurrences)->keyBy(
+ fn (EventOccurrenceDomainObject $occ) => $occ->getStartDate()
+ );
+
+ $existingIds = collect($existingOccurrences)
+ ->map(fn (EventOccurrenceDomainObject $occ) => $occ->getId())
+ ->all();
+ // Anything attendee-bearing is "in use" for regeneration purposes,
+ // mirroring the single/bulk delete handlers which both block on
+ // attendees OR order_items. Normal checkout creates the two together,
+ // but attendees-without-order-items can exist (manual creation flows,
+ // imports, partial restores) and we must not silently soft-delete
+ // their occurrences just because no order_item row points at them.
+ $occurrenceIdsInUse = $this->getOccurrenceIdsInUse($existingIds);
+
+ $result = collect();
+ $matchedExistingIds = [];
+
+ foreach ($candidates as $candidate) {
+ $startDateKey = $candidate['start']->toDateTimeString();
+
+ $existing = $existingByStartDate->get($startDateKey);
+
+ if ($existing) {
+ $matchedExistingIds[] = $existing->getId();
+
+ if ($occurrenceIdsInUse->contains($existing->getId()) || $existing->getIsOverridden()) {
+ $result->push($existing);
+
+ continue;
+ }
+
+ $this->occurrenceRepository->updateWhere(
+ attributes: [
+ EventOccurrenceDomainObjectAbstract::START_DATE => $candidate['start']->toDateTimeString(),
+ EventOccurrenceDomainObjectAbstract::END_DATE => $candidate['end']?->toDateTimeString(),
+ EventOccurrenceDomainObjectAbstract::CAPACITY => $candidate['capacity'],
+ EventOccurrenceDomainObjectAbstract::LABEL => $candidate['label'] ?? null,
+ ],
+ where: [EventOccurrenceDomainObjectAbstract::ID => $existing->getId()]
+ );
+
+ $updated = $this->occurrenceRepository->findById($existing->getId());
+ $result->push($updated);
+ } else {
+ $newOccurrence = $this->occurrenceRepository->create([
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $event->getId(),
+ EventOccurrenceDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::OCCURRENCE_PREFIX),
+ EventOccurrenceDomainObjectAbstract::START_DATE => $candidate['start']->toDateTimeString(),
+ EventOccurrenceDomainObjectAbstract::END_DATE => $candidate['end']?->toDateTimeString(),
+ EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::ACTIVE->name,
+ EventOccurrenceDomainObjectAbstract::CAPACITY => $candidate['capacity'],
+ EventOccurrenceDomainObjectAbstract::USED_CAPACITY => 0,
+ EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => false,
+ EventOccurrenceDomainObjectAbstract::LABEL => $candidate['label'] ?? null,
+ ]);
+
+ $result->push($newOccurrence);
+ }
+ }
+
+ $this->removeStaleOccurrences($existingOccurrences, $matchedExistingIds, $occurrenceIdsInUse);
+
+ return $result;
+ }
+
+ private function removeStaleOccurrences(
+ Collection $existingOccurrences,
+ array $matchedExistingIds,
+ Collection $occurrenceIdsInUse,
+ ): void {
+ $idsToDelete = [];
+ $eventIdsToDelete = [];
+
+ foreach ($existingOccurrences as $existing) {
+ if (in_array($existing->getId(), $matchedExistingIds, true)) {
+ continue;
+ }
+
+ if ($existing->getIsOverridden()) {
+ continue;
+ }
+
+ if ($occurrenceIdsInUse->contains($existing->getId())) {
+ $this->occurrenceRepository->updateWhere(
+ attributes: [
+ EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => true,
+ ],
+ where: [EventOccurrenceDomainObjectAbstract::ID => $existing->getId()]
+ );
+
+ continue;
+ }
+
+ if ($existing->getStatus() === EventOccurrenceStatus::CANCELLED->name) {
+ continue;
+ }
+
+ $idsToDelete[] = $existing->getId();
+ $eventIdsToDelete[$existing->getEventId()] = true;
+ }
+
+ if ($idsToDelete === []) {
+ return;
+ }
+
+ // Mirror the single/bulk delete handlers: cancel WAITING/OFFERED waitlist
+ // entries scoped to the soft-deleted occurrences first. The FK is
+ // nullOnDelete which only fires on hard deletes, so without this the
+ // entries are left pointing at soft-deleted rows and crash
+ // ProcessWaitlistService on the next CapacityChangedEvent.
+ $this->waitlistEntryRepository->updateWhere(
+ attributes: [
+ 'status' => WaitlistEntryStatus::CANCELLED->name,
+ ],
+ where: [
+ ['event_id', 'in', array_keys($eventIdsToDelete)],
+ ['event_occurrence_id', 'in', $idsToDelete],
+ ['status', 'in', [
+ WaitlistEntryStatus::WAITING->name,
+ WaitlistEntryStatus::OFFERED->name,
+ ]],
+ ],
+ );
+
+ $this->occurrenceRepository->deleteWhere([
+ [EventOccurrenceDomainObjectAbstract::ID, 'in', $idsToDelete],
+ ]);
+ }
+
+ /**
+ * Returns the subset of given occurrence ids that have either an order_item
+ * or an attendee currently pointing at them. This is the "do not delete"
+ * set for regeneration. Mirrors the single/bulk delete handlers which both
+ * block on attendees OR order_items so the three paths agree on what
+ * counts as in-use.
+ */
+ private function getOccurrenceIdsInUse(array $occurrenceIds): Collection
+ {
+ if (empty($occurrenceIds)) {
+ return collect();
+ }
+
+ $withOrderItems = DB::table('order_items')
+ ->whereIn('event_occurrence_id', $occurrenceIds)
+ ->whereNull('deleted_at')
+ ->distinct()
+ ->pluck('event_occurrence_id');
+
+ $withAttendees = DB::table('attendees')
+ ->whereIn('event_occurrence_id', $occurrenceIds)
+ ->whereNull('deleted_at')
+ ->distinct()
+ ->pluck('event_occurrence_id');
+
+ return $withOrderItems->merge($withAttendees)->unique()->values();
+ }
+}
diff --git a/backend/app/Services/Domain/Event/EventStatsFetchService.php b/backend/app/Services/Domain/Event/EventStatsFetchService.php
index 071fe836ed..518ebcf031 100644
--- a/backend/app/Services/Domain/Event/EventStatsFetchService.php
+++ b/backend/app/Services/Domain/Event/EventStatsFetchService.php
@@ -16,9 +16,7 @@
public function __construct(
private DatabaseManager $db,
private EventRepositoryInterface $eventRepository,
- )
- {
- }
+ ) {}
public function getEventStats(EventStatsRequestDTO $requestData): EventStatsResponseDTO
{
@@ -32,28 +30,50 @@ public function getEventStats(EventStatsRequestDTO $requestData): EventStatsResp
}
$eventId = $requestData->event_id;
+ $occurrenceId = $requestData->occurrence_id;
+
+ if ($occurrenceId !== null) {
+ // event_id is bound here so an organiser with access to event A
+ // cannot pass an occurrence id belonging to event B and read its
+ // stats. Action-level authorization gates eventId; this keeps the
+ // query honest about that scope.
+ $totalsQuery = <<<'SQL'
+ SELECT
+ COALESCE(SUM(eos.products_sold), 0) AS total_products_sold,
+ COALESCE(SUM(eos.orders_created), 0) AS total_orders,
+ COALESCE(SUM(eos.sales_total_gross), 0) AS total_gross_sales,
+ COALESCE(SUM(eos.total_tax), 0) AS total_tax,
+ COALESCE(SUM(eos.total_fee), 0) AS total_fees,
+ 0 AS total_views,
+ COALESCE(SUM(eos.total_refunded), 0) AS total_refunded,
+ COALESCE(SUM(eos.attendees_registered), 0) AS attendees_registered
+ FROM event_occurrence_statistics eos
+ WHERE eos.event_occurrence_id = :occurrenceId
+ AND eos.event_id = :eventId
+ AND eos.deleted_at IS NULL;
+ SQL;
+ $totalsResult = $this->db->selectOne($totalsQuery, [
+ 'occurrenceId' => $occurrenceId,
+ 'eventId' => $eventId,
+ ]);
+ } else {
+ $totalsQuery = <<<'SQL'
+ SELECT
+ COALESCE(SUM(eos.products_sold), 0) AS total_products_sold,
+ COALESCE(SUM(eos.orders_created), 0) AS total_orders,
+ COALESCE(SUM(eos.sales_total_gross), 0) AS total_gross_sales,
+ COALESCE(SUM(eos.total_tax), 0) AS total_tax,
+ COALESCE(SUM(eos.total_fee), 0) AS total_fees,
+ COALESCE((SELECT SUM(es.total_views) FROM event_statistics es WHERE es.event_id = :eventIdViews AND es.deleted_at IS NULL), 0) AS total_views,
+ COALESCE(SUM(eos.total_refunded), 0) AS total_refunded,
+ COALESCE(SUM(eos.attendees_registered), 0) AS attendees_registered
+ FROM event_occurrence_statistics eos
+ WHERE eos.event_id = :eventId
+ AND eos.deleted_at IS NULL;
+ SQL;
+ $totalsResult = $this->db->selectOne($totalsQuery, ['eventId' => $eventId, 'eventIdViews' => $eventId]);
+ }
- // Aggregate total statistics for the event for all time
- $totalsQuery = <<db->selectOne($totalsQuery, ['eventId' => $eventId]);
-
- // Use the results to populate the response DTO
return new EventStatsResponseDTO(
daily_stats: $this->getDailyEventStats($requestData),
start_date: $requestData->start_date,
@@ -72,10 +92,20 @@ public function getEventStats(EventStatsRequestDTO $requestData): EventStatsResp
public function getDailyEventStats(EventStatsRequestDTO $requestData): Collection
{
$eventId = $requestData->event_id;
-
+ $occurrenceId = $requestData->occurrence_id;
$startDate = $requestData->start_date;
$endDate = $requestData->end_date;
+ if ($occurrenceId !== null) {
+ // event_id is bound alongside occurrence_id so cross-event ids
+ // produce zero rows rather than another event's stats.
+ $whereClause = 'eods.event_occurrence_id = :occurrenceId AND eods.event_id = :eventId';
+ $bindings = ['startDate' => $startDate, 'endDate' => $endDate, 'occurrenceId' => $occurrenceId, 'eventId' => $eventId];
+ } else {
+ $whereClause = 'eods.event_id = :eventId';
+ $bindings = ['startDate' => $startDate, 'endDate' => $endDate, 'eventId' => $eventId];
+ }
+
$query = <<db->select($query, [
- 'startDate' => $startDate,
- 'endDate' => $endDate,
- 'eventId' => $eventId,
- ]);
+ $results = $this->db->select($query, $bindings);
$currentTime = Carbon::now('UTC')->toTimeString();
return collect($results)->map(function (object $result) use ($currentTime) {
- $dateTimeWithCurrentTime = (new Carbon($result->date))->setTimezone('UTC')->format('Y-m-d') . ' ' . $currentTime;
+ $dateTimeWithCurrentTime = (new Carbon($result->date))->setTimezone('UTC')->format('Y-m-d').' '.$currentTime;
return new EventDailyStatsResponseDTO(
date: $dateTimeWithCurrentTime,
@@ -156,7 +182,7 @@ private function resolveStatsDateRange(int $eventId, string $preset): array
$endCandidates = array_filter([
$eventEnd,
$bounds?->max_date ? Carbon::parse($bounds->max_date) : null,
- (!$eventEnd || $eventEnd->isFuture()) ? Carbon::now() : null,
+ (! $eventEnd || $eventEnd->isFuture()) ? Carbon::now() : null,
]);
$endDate = $endCandidates ? max($endCandidates) : Carbon::now();
break;
@@ -171,20 +197,29 @@ private function resolveStatsDateRange(int $eventId, string $preset): array
];
}
- public function getCheckedInStats(int $eventId): EventCheckInStatsResponseDTO
+ public function getCheckedInStats(int $eventId, ?int $occurrenceId = null): EventCheckInStatsResponseDTO
{
+ $bindings = ['eventId' => $eventId];
+
+ $occurrenceFilter = '';
+ if ($occurrenceId !== null) {
+ $occurrenceFilter = 'AND attendees.event_occurrence_id = :occurrenceId';
+ $bindings['occurrenceId'] = $occurrenceId;
+ }
+
$query = <<db->select($query)[0];
+ $result = $this->db->select($query, $bindings)[0];
return new EventCheckInStatsResponseDTO(
total_checked_in_attendees: $result->checked_in_count ?? 0,
diff --git a/backend/app/Services/Domain/Event/RecurrenceRuleExclusionService.php b/backend/app/Services/Domain/Event/RecurrenceRuleExclusionService.php
new file mode 100644
index 0000000000..21f3c6fb30
--- /dev/null
+++ b/backend/app/Services/Domain/Event/RecurrenceRuleExclusionService.php
@@ -0,0 +1,112 @@
+ $startDates UTC datetime strings of cancelled occurrences
+ */
+ public function addExclusions(int $eventId, array $startDates): void
+ {
+ $event = $this->eventRepository->findByIdLocked($eventId);
+
+ if ($event->getType() !== EventType::RECURRING->name) {
+ return;
+ }
+
+ $rule = $this->extractRule($event);
+ $excluded = $rule['excluded_occurrences'] ?? [];
+ $changed = false;
+
+ foreach (array_unique($startDates) as $startDate) {
+ $datetime = $this->formatExclusion($startDate, $event->getTimezone() ?? 'UTC');
+
+ if (! in_array($datetime, $excluded, true)) {
+ $excluded[] = $datetime;
+ $changed = true;
+ }
+ }
+
+ if (! $changed) {
+ return;
+ }
+
+ $rule['excluded_occurrences'] = $excluded;
+
+ $this->eventRepository->updateFromArray(
+ id: $eventId,
+ attributes: [
+ EventDomainObjectAbstract::RECURRENCE_RULE => $rule,
+ ],
+ );
+ }
+
+ public function removeExclusion(int $eventId, string $startDate): void
+ {
+ $event = $this->eventRepository->findByIdLocked($eventId);
+
+ if ($event->getType() !== EventType::RECURRING->name) {
+ return;
+ }
+
+ $rule = $this->extractRule($event);
+ $datetime = $this->formatExclusion($startDate, $event->getTimezone() ?? 'UTC');
+ $legacyDate = substr($datetime, 0, 10);
+
+ $excludedOccurrences = $rule['excluded_occurrences'] ?? [];
+ $excludedDates = $rule['excluded_dates'] ?? [];
+
+ if (! in_array($datetime, $excludedOccurrences, true)
+ && ! in_array($legacyDate, $excludedDates, true)
+ ) {
+ return;
+ }
+
+ $rule['excluded_occurrences'] = array_values(array_filter(
+ $excludedOccurrences,
+ static fn (string $dt) => $dt !== $datetime,
+ ));
+ $rule['excluded_dates'] = array_values(array_filter(
+ $excludedDates,
+ static fn (string $d) => $d !== $legacyDate,
+ ));
+
+ $this->eventRepository->updateFromArray(
+ id: $eventId,
+ attributes: [
+ EventDomainObjectAbstract::RECURRENCE_RULE => $rule,
+ ],
+ );
+ }
+
+ private function extractRule(EventDomainObject $event): array
+ {
+ $rule = $event->getRecurrenceRule() ?? [];
+
+ if (is_string($rule)) {
+ $rule = json_decode($rule, true, 512, JSON_THROW_ON_ERROR);
+ }
+
+ return $rule;
+ }
+
+ private function formatExclusion(string $startDate, string $timezone): string
+ {
+ return CarbonImmutable::parse($startDate, 'UTC')
+ ->setTimezone($timezone)
+ ->format('Y-m-d H:i');
+ }
+}
diff --git a/backend/app/Services/Domain/Event/RecurrenceRuleParserService.php b/backend/app/Services/Domain/Event/RecurrenceRuleParserService.php
new file mode 100644
index 0000000000..29a377f0e7
--- /dev/null
+++ b/backend/app/Services/Domain/Event/RecurrenceRuleParserService.php
@@ -0,0 +1,456 @@
+
+ */
+ public function parse(array $rule, string $timezone): Collection
+ {
+ $candidates = collect();
+
+ if (! isset($rule['frequency'])) {
+ throw new InvalidRecurrenceRuleException(__('Recurrence rule must include a frequency'));
+ }
+
+ $frequency = $rule['frequency'];
+ $interval = $rule['interval'] ?? 1;
+ $rawTimes = $rule['times_of_day'] ?? ['00:00'];
+ $fallbackDuration = $rule['duration_minutes'] ?? null;
+ $defaultCapacity = $rule['default_capacity'] ?? null;
+ $excludedDates = collect($rule['excluded_dates'] ?? []);
+ $excludedOccurrences = collect($rule['excluded_occurrences'] ?? []);
+ $additionalDates = collect($rule['additional_dates'] ?? []);
+
+ $timeSlots = $this->normalizeTimeSlots($rawTimes, $fallbackDuration);
+
+ $rangeType = $rule['range']['type'] ?? 'count';
+ // For `until` ranges we ask the generator for one more date than the
+ // hard cap so the overflow check below (`> MAX_OCCURRENCES`) can
+ // actually fire on a single-timeslot rule that would otherwise stop
+ // exactly at MAX. Without this, a daily/until rule that resolves to
+ // 1500 dates silently truncates to 1200 and the handler's overflow
+ // guard never sees a value > MAX. For `count` ranges the user has
+ // explicitly named a number — generate exactly that many.
+ $maxCount = $rangeType === 'count'
+ ? ($rule['range']['count'] ?? 10)
+ : self::MAX_OCCURRENCES + 1;
+ $untilDate = $rangeType === 'until'
+ ? CarbonImmutable::parse($rule['range']['until'], $timezone)->endOfDay()
+ : null;
+
+ $dates = $this->generateDates($rule, $frequency, $interval, $timezone, $maxCount, $untilDate);
+
+ foreach ($dates as $date) {
+ foreach ($timeSlots as $slot) {
+ // Allow one candidate beyond MAX so the caller's `> MAX` overflow
+ // check can fire — without this the parser silently truncated
+ // to exactly MAX and the validation in the handler was dead.
+ if ($candidates->count() > self::MAX_OCCURRENCES) {
+ break 2;
+ }
+
+ $parts = explode(':', $slot['time']);
+ $start = $date->setTime((int) $parts[0], (int) $parts[1], 0);
+ if ($this->isExcluded($start, $excludedDates, $excludedOccurrences)) {
+ continue;
+ }
+
+ $duration = $slot['duration_minutes'];
+ $end = $duration ? $start->addMinutes($duration) : null;
+
+ $startUtc = $start->setTimezone('UTC');
+ $endUtc = $end ? $end->setTimezone('UTC') : null;
+
+ $candidates->push([
+ 'start' => $startUtc,
+ 'end' => $endUtc,
+ 'capacity' => $defaultCapacity,
+ 'label' => $slot['label'],
+ ]);
+ }
+ }
+
+ foreach ($additionalDates as $additional) {
+ if ($candidates->count() > self::MAX_OCCURRENCES) {
+ break;
+ }
+
+ if (! is_array($additional) || ! isset($additional['date'])) {
+ throw new InvalidRecurrenceRuleException(__('Additional recurrence dates must include a date'));
+ }
+
+ $addDate = CarbonImmutable::parse($additional['date'], $timezone);
+ $additionalTime = $additional['time'] ?? '00:00';
+ if (! preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $additionalTime)) {
+ throw new InvalidRecurrenceRuleException(
+ __('Recurrence additional_dates time must be in HH:MM 24-hour format')
+ );
+ }
+ $parts = explode(':', $additionalTime);
+ $start = $addDate->setTime((int) $parts[0], (int) $parts[1], 0);
+ $end = $fallbackDuration ? $start->addMinutes($fallbackDuration) : null;
+
+ $startUtc = $start->setTimezone('UTC');
+ $endUtc = $end ? $end->setTimezone('UTC') : null;
+
+ $candidates->push([
+ 'start' => $startUtc,
+ 'end' => $endUtc,
+ 'capacity' => $defaultCapacity,
+ 'label' => null,
+ ]);
+ }
+
+ return $candidates
+ ->sortBy(fn (array $candidate) => $candidate['start']->getTimestamp())
+ ->unique(fn (array $candidate) => $candidate['start']->toDateTimeString())
+ ->values();
+ }
+
+ /**
+ * @return array
+ */
+ private function normalizeTimeSlots(array $rawTimes, ?int $fallbackDuration): array
+ {
+ return array_map(function ($entry) use ($fallbackDuration) {
+ $time = is_string($entry) ? $entry : ($entry['time'] ?? null);
+
+ if (! is_string($time) || ! preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $time)) {
+ throw new InvalidRecurrenceRuleException(
+ __('Recurrence times_of_day entries must be in HH:MM 24-hour format')
+ );
+ }
+
+ if (is_string($entry)) {
+ return [
+ 'time' => $entry,
+ 'label' => null,
+ 'duration_minutes' => $fallbackDuration,
+ ];
+ }
+
+ if (! is_array($entry) || ! isset($entry['time'])) {
+ throw new InvalidRecurrenceRuleException(__('Recurrence time slots must include a time'));
+ }
+
+ return [
+ 'time' => $entry['time'],
+ 'label' => $entry['label'] ?? null,
+ 'duration_minutes' => $entry['duration_minutes'] ?? $fallbackDuration,
+ ];
+ }, $rawTimes);
+ }
+
+ private function generateDates(
+ array $rule,
+ string $frequency,
+ int $interval,
+ string $timezone,
+ int $maxCount,
+ ?CarbonImmutable $untilDate,
+ ): Collection {
+ return match ($frequency) {
+ 'daily' => $this->generateDailyDates($rule, $interval, $timezone, $maxCount, $untilDate),
+ 'weekly' => $this->generateWeeklyDates($rule, $interval, $timezone, $maxCount, $untilDate),
+ 'monthly' => $this->generateMonthlyDates($rule, $interval, $timezone, $maxCount, $untilDate),
+ 'yearly' => $this->generateYearlyDates($rule, $interval, $timezone, $maxCount, $untilDate),
+ default => throw new InvalidRecurrenceRuleException(__('Unsupported recurrence frequency')),
+ };
+ }
+
+ private function isExcluded(CarbonImmutable $start, Collection $excludedDates, Collection $excludedOccurrences): bool
+ {
+ return $excludedDates->contains($start->format('Y-m-d'))
+ || $excludedOccurrences->contains($start->format('Y-m-d H:i'));
+ }
+
+ private function generateDailyDates(
+ array $rule,
+ int $interval,
+ string $timezone,
+ int $maxCount,
+ ?CarbonImmutable $untilDate,
+ ): Collection {
+ $dates = collect();
+ $startDate = $this->getStartDate($rule, $timezone);
+ $current = $startDate;
+
+ while ($dates->count() < $maxCount) {
+ if ($untilDate && $current->greaterThan($untilDate)) {
+ break;
+ }
+
+ $dates->push($current);
+ $current = $current->addDays($interval);
+ }
+
+ return $dates;
+ }
+
+ private function generateWeeklyDates(
+ array $rule,
+ int $interval,
+ string $timezone,
+ int $maxCount,
+ ?CarbonImmutable $untilDate,
+ ): Collection {
+ $dates = collect();
+ $daysOfWeek = $rule['days_of_week'] ?? [];
+ $startDate = $this->getStartDate($rule, $timezone);
+ $current = $startDate->startOfWeek(Carbon::MONDAY);
+
+ $dayMap = [
+ 'monday' => Carbon::MONDAY,
+ 'tuesday' => Carbon::TUESDAY,
+ 'wednesday' => Carbon::WEDNESDAY,
+ 'thursday' => Carbon::THURSDAY,
+ 'friday' => Carbon::FRIDAY,
+ 'saturday' => Carbon::SATURDAY,
+ 'sunday' => Carbon::SUNDAY,
+ ];
+
+ // Bare ->filter() drops "falsy" values, which includes Carbon::SUNDAY (= 0),
+ // so a weekly Sunday rule was silently producing zero dates. Compare to
+ // null explicitly to keep Sunday in the set.
+ $dayNumbers = collect($daysOfWeek)
+ ->map(fn (string $day) => $dayMap[strtolower($day)] ?? null)
+ ->filter(fn (?int $dayNumber) => $dayNumber !== null)
+ ->sort()
+ ->values();
+
+ if ($dayNumbers->isEmpty()) {
+ return $dates;
+ }
+
+ while ($dates->count() < $maxCount) {
+ foreach ($dayNumbers as $dayNumber) {
+ $daysFromMonday = $dayNumber - CarbonInterface::MONDAY;
+ if ($daysFromMonday < 0) {
+ $daysFromMonday += 7;
+ }
+ $candidate = $current->addDays($daysFromMonday);
+
+ if ($candidate->lessThan($startDate)) {
+ continue;
+ }
+
+ if ($untilDate && $candidate->greaterThan($untilDate)) {
+ return $dates;
+ }
+
+ $dates->push($candidate);
+
+ if ($dates->count() >= $maxCount) {
+ return $dates;
+ }
+ }
+
+ $current = $current->addWeeks($interval);
+ }
+
+ return $dates;
+ }
+
+ private function generateMonthlyDates(
+ array $rule,
+ int $interval,
+ string $timezone,
+ int $maxCount,
+ ?CarbonImmutable $untilDate,
+ ): Collection {
+ $pattern = $rule['monthly_pattern'] ?? 'by_day_of_month';
+
+ return match ($pattern) {
+ 'by_day_of_month' => $this->generateMonthlyByDayOfMonth($rule, $interval, $timezone, $maxCount, $untilDate),
+ 'by_day_of_week' => $this->generateMonthlyByDayOfWeek($rule, $interval, $timezone, $maxCount, $untilDate),
+ default => collect(),
+ };
+ }
+
+ private function generateMonthlyByDayOfMonth(
+ array $rule,
+ int $interval,
+ string $timezone,
+ int $maxCount,
+ ?CarbonImmutable $untilDate,
+ ): Collection {
+ $dates = collect();
+ $daysOfMonth = $rule['days_of_month'] ?? [1];
+ $startDate = $this->getStartDate($rule, $timezone);
+ $current = $startDate->startOfMonth();
+ $safetyLimit = $maxCount * 4;
+ $iterations = 0;
+
+ while ($dates->count() < $maxCount && $iterations < $safetyLimit) {
+ $iterations++;
+
+ foreach ($daysOfMonth as $day) {
+ $daysInMonth = $current->daysInMonth;
+ if ($day > $daysInMonth) {
+ continue;
+ }
+
+ $candidate = $current->setDay($day);
+
+ if ($candidate->lessThan($startDate)) {
+ continue;
+ }
+
+ if ($untilDate && $candidate->greaterThan($untilDate)) {
+ return $dates;
+ }
+
+ $dates->push($candidate);
+
+ if ($dates->count() >= $maxCount) {
+ return $dates;
+ }
+ }
+
+ $current = $current->addMonths($interval);
+ }
+
+ return $dates;
+ }
+
+ private function generateMonthlyByDayOfWeek(
+ array $rule,
+ int $interval,
+ string $timezone,
+ int $maxCount,
+ ?CarbonImmutable $untilDate,
+ ): Collection {
+ $dates = collect();
+ $dayOfWeek = $rule['day_of_week'] ?? 'monday';
+ $weekPosition = $rule['week_position'] ?? 1;
+ $startDate = $this->getStartDate($rule, $timezone);
+ $current = $startDate->startOfMonth();
+
+ $dayMap = [
+ 'monday' => Carbon::MONDAY,
+ 'tuesday' => Carbon::TUESDAY,
+ 'wednesday' => Carbon::WEDNESDAY,
+ 'thursday' => Carbon::THURSDAY,
+ 'friday' => Carbon::FRIDAY,
+ 'saturday' => Carbon::SATURDAY,
+ 'sunday' => Carbon::SUNDAY,
+ ];
+
+ $carbonDay = $dayMap[strtolower($dayOfWeek)] ?? Carbon::MONDAY;
+ $safetyLimit = $maxCount * 4;
+ $iterations = 0;
+
+ while ($dates->count() < $maxCount && $iterations < $safetyLimit) {
+ $iterations++;
+ $candidate = $this->getNthDayOfWeekInMonth($current, $carbonDay, $weekPosition);
+
+ if ($candidate !== null && $candidate->greaterThanOrEqualTo($startDate)) {
+ if ($untilDate && $candidate->greaterThan($untilDate)) {
+ return $dates;
+ }
+
+ $dates->push($candidate);
+
+ if ($dates->count() >= $maxCount) {
+ return $dates;
+ }
+ }
+
+ $current = $current->addMonths($interval);
+ }
+
+ return $dates;
+ }
+
+ private function getNthDayOfWeekInMonth(
+ CarbonImmutable $monthStart,
+ int $carbonDay,
+ int $weekPosition,
+ ): ?CarbonImmutable {
+ $firstOfMonth = $monthStart->startOfMonth();
+
+ // The day-of-week constants on Carbon (e.g. Carbon::SUNDAY = 0) follow
+ // PHP's date('w') convention, NOT ISO-8601. dayOfWeekIso returns 1..7
+ // (Sun = 7), which never equals 0 — that mismatch turned monthly nth/last
+ // Sunday rules into infinite loops walking days that never matched.
+ // dayOfWeek returns 0..6 and lines up with the Carbon::SUNDAY constant.
+ if ($weekPosition === -1) {
+ $lastOfMonth = $firstOfMonth->endOfMonth();
+ $candidate = $lastOfMonth;
+ while ($candidate->dayOfWeek !== $carbonDay) {
+ $candidate = $candidate->subDay();
+ }
+
+ return $candidate->startOfDay();
+ }
+
+ $candidate = $firstOfMonth;
+ while ($candidate->dayOfWeek !== $carbonDay) {
+ $candidate = $candidate->addDay();
+ }
+
+ $candidate = $candidate->addWeeks($weekPosition - 1);
+
+ if ($candidate->month !== $firstOfMonth->month) {
+ return null;
+ }
+
+ return $candidate->startOfDay();
+ }
+
+ private function generateYearlyDates(
+ array $rule,
+ int $interval,
+ string $timezone,
+ int $maxCount,
+ ?CarbonImmutable $untilDate,
+ ): Collection {
+ $dates = collect();
+ $startDate = $this->getStartDate($rule, $timezone);
+ $month = $rule['month'] ?? $startDate->month;
+ $dayOfMonth = ($rule['days_of_month'] ?? [$startDate->day])[0] ?? $startDate->day;
+
+ $current = $startDate->startOfYear()->month($month);
+ $daysInMonth = $current->daysInMonth;
+ $current = $current->day(min($dayOfMonth, $daysInMonth));
+
+ if ($current->lessThan($startDate)) {
+ $current = $current->addYears($interval);
+ }
+
+ while ($dates->count() < $maxCount) {
+ if ($untilDate && $current->greaterThan($untilDate)) {
+ break;
+ }
+
+ $dates->push($current);
+ $nextYear = $current->addYears($interval);
+ $daysInTargetMonth = $nextYear->month($month)->daysInMonth;
+ $current = $nextYear->month($month)->day(min($dayOfMonth, $daysInTargetMonth));
+ }
+
+ return $dates;
+ }
+
+ private function getStartDate(array $rule, string $timezone): CarbonImmutable
+ {
+ if (isset($rule['range']['start'])) {
+ return CarbonImmutable::parse($rule['range']['start'], $timezone)->startOfDay();
+ }
+
+ return CarbonImmutable::now($timezone)->startOfDay();
+ }
+}
diff --git a/backend/app/Services/Domain/EventOccurrence/CancelOccurrenceAttendeesService.php b/backend/app/Services/Domain/EventOccurrence/CancelOccurrenceAttendeesService.php
new file mode 100644
index 0000000000..a570069ab0
--- /dev/null
+++ b/backend/app/Services/Domain/EventOccurrence/CancelOccurrenceAttendeesService.php
@@ -0,0 +1,171 @@
+name, AttendeeStatus::AWAITING_PAYMENT->name];
+
+ $attendees = $this->attendeeRepository->findWhere([
+ AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrenceId,
+ [AttendeeDomainObjectAbstract::STATUS, 'in', $statusesToCancel],
+ ]);
+
+ if ($attendees->isEmpty()) {
+ return;
+ }
+
+ $this->attendeeRepository->updateWhere(
+ attributes: [AttendeeDomainObjectAbstract::STATUS => AttendeeStatus::CANCELLED->name],
+ where: [
+ AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrenceId,
+ [AttendeeDomainObjectAbstract::STATUS, 'in', $statusesToCancel],
+ ],
+ );
+
+ $soldCountsByProductPrice = $attendees
+ ->map(fn (AttendeeDomainObject $attendee) => $attendee->getProductPriceId())
+ ->countBy();
+
+ foreach ($soldCountsByProductPrice as $productPriceId => $count) {
+ $this->productQuantityService->decreaseQuantitySold(
+ priceId: (int) $productPriceId,
+ adjustment: $count,
+ eventOccurrenceId: $occurrenceId,
+ );
+ }
+
+ // Mirror PartialEditAttendeeHandler's per-attendee stats decrement so
+ // attendees_registered tracks reality after a bulk occurrence cancel.
+ // Without this, the later refund flow's order-level decrement looks at
+ // attendees that are already CANCELLED, finds zero "active" rows, and
+ // decrements by zero — leaving attendees_registered inflated. Grouped
+ // by order to amortise the per-order date lookup and version bumps.
+ $this->decrementStatisticsForCancelledAttendees($eventId, $occurrenceId, $attendees);
+
+ foreach ($attendees as $attendee) {
+ $this->domainEventDispatcherService->dispatch(new AttendeeEvent(
+ type: DomainEventType::ATTENDEE_CANCELLED,
+ attendeeId: $attendee->getId(),
+ ));
+ }
+
+ $productIds = $attendees
+ ->map(fn (AttendeeDomainObject $attendee) => $attendee->getProductId())
+ ->unique()
+ ->values()
+ ->all();
+
+ foreach ($productIds as $productId) {
+ event(new CapacityChangedEvent(
+ eventId: $eventId,
+ direction: CapacityChangeDirection::INCREASED,
+ productId: $productId,
+ eventOccurrenceId: $occurrenceId,
+ ));
+ }
+ }
+
+ /**
+ * Calls EventStatisticsCancellationService::decrementForCancelledAttendee
+ * once per source order, summing attendees in that order tied to the
+ * cancelled occurrence. The service needs the order's created_at to find
+ * the daily-statistics row to decrement.
+ *
+ * Intentionally swallows version-mismatch / not-found errors at this
+ * boundary: the attendees are already cancelled and inventory adjusted,
+ * and a stats discrepancy is recoverable through reconciliation but a
+ * raised exception here would roll the cancel transaction back.
+ *
+ * @param \Illuminate\Support\Collection $attendees
+ */
+ private function decrementStatisticsForCancelledAttendees(
+ int $eventId,
+ int $occurrenceId,
+ \Illuminate\Support\Collection $attendees,
+ ): void {
+ $countsByOrderId = $attendees
+ ->groupBy(fn (AttendeeDomainObject $attendee) => $attendee->getOrderId())
+ ->map->count();
+
+ $orderIds = $countsByOrderId->keys()->all();
+ if (empty($orderIds)) {
+ return;
+ }
+
+ $orders = $this->orderRepository->findWhereIn('id', $orderIds)
+ ->keyBy(fn (OrderDomainObject $order) => $order->getId());
+
+ foreach ($countsByOrderId as $orderId => $attendeeCount) {
+ $order = $orders->get((int) $orderId);
+ if ($order === null) {
+ continue;
+ }
+
+ try {
+ $this->statisticsCancellationService->decrementForCancelledAttendee(
+ eventId: $eventId,
+ orderDate: $order->getCreatedAt(),
+ attendeeCount: $attendeeCount,
+ occurrenceId: $occurrenceId,
+ );
+ } catch (Throwable $e) {
+ $this->logger->error(
+ 'Failed to decrement attendee statistics during occurrence cancellation',
+ [
+ 'event_id' => $eventId,
+ 'occurrence_id' => $occurrenceId,
+ 'order_id' => $orderId,
+ 'attendee_count' => $attendeeCount,
+ 'exception' => $e::class,
+ 'message' => $e->getMessage(),
+ ],
+ );
+ }
+ }
+ }
+}
diff --git a/backend/app/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityService.php b/backend/app/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityService.php
new file mode 100644
index 0000000000..03c37c28d0
--- /dev/null
+++ b/backend/app/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityService.php
@@ -0,0 +1,140 @@
+occurrenceRepository->findFirstWhere([
+ 'id' => $occurrenceId,
+ 'event_id' => $eventId,
+ ]);
+
+ if ($occurrence === null) {
+ throw ValidationException::withMessages([
+ 'event_occurrence_id' => __('The specified event occurrence was not found'),
+ ]);
+ }
+
+ if ($occurrence->isCancelled()) {
+ throw ValidationException::withMessages([
+ 'event_occurrence_id' => __('This event occurrence has been cancelled'),
+ ]);
+ }
+
+ // Past dates are blocked even when capacity is overridden — selling or
+ // manually issuing tickets for a session that has already ended is
+ // never the intended behaviour, and the public payload already filters
+ // these out so any request reaching here is stale or hand-crafted.
+ if ($occurrence->isPast()) {
+ throw ValidationException::withMessages([
+ 'event_occurrence_id' => __('This event occurrence has already ended'),
+ ]);
+ }
+
+ // SOLD_OUT is a capacity-derived status (ProductQuantityUpdateService
+ // flips it whenever used_capacity >= capacity), so blocking it here
+ // before the capacity-override branch would defeat the override flag in
+ // the most common case it was added for. Treat SOLD_OUT as a normal
+ // capacity gate that the override can bypass.
+ if (! $overrideCapacity && $occurrence->isSoldOut()) {
+ throw ValidationException::withMessages([
+ 'event_occurrence_id' => __('This event occurrence is sold out'),
+ ]);
+ }
+
+ if (! $overrideCapacity && $occurrence->getCapacity() !== null) {
+ $reservedForOccurrence = $this->orderItemRepository
+ ->getReservedQuantityForOccurrence($occurrenceId);
+
+ $available = $occurrence->getCapacity() - $occurrence->getUsedCapacity() - $reservedForOccurrence;
+ if ($additionalQuantity > $available) {
+ throw ValidationException::withMessages([
+ 'event_occurrence_id' => __('Not enough capacity available for this occurrence'),
+ ]);
+ }
+ }
+
+ return $occurrence;
+ }
+
+ /**
+ * Verifies the product is visible on the occurrence. Visibility rules are an
+ * allow-list — an occurrence with no rules is treated as "all products
+ * visible" (the default), matching `ProductFilterService::filterByOccurrenceVisibility`
+ * so the validator and the storefront filter agree.
+ *
+ * @param int[] $productIds
+ *
+ * @throws ValidationException
+ */
+ public function assertProductsVisibleOnOccurrence(int $occurrenceId, array $productIds): void
+ {
+ if ($productIds === []) {
+ return;
+ }
+
+ $rules = $this->productOccurrenceVisibilityRepository
+ ->findWhereIn('event_occurrence_id', [$occurrenceId]);
+
+ if ($rules->isEmpty()) {
+ return;
+ }
+
+ $visibleProductIds = $rules
+ ->map(fn ($rule) => $rule->getProductId())
+ ->all();
+
+ foreach ($productIds as $productId) {
+ if (! in_array($productId, $visibleProductIds, true)) {
+ throw ValidationException::withMessages([
+ 'event_occurrence_id' => __('One or more selected products are not available for this occurrence'),
+ ]);
+ }
+ }
+ }
+}
diff --git a/backend/app/Services/Domain/EventStatistics/EventStatisticsCancellationService.php b/backend/app/Services/Domain/EventStatistics/EventStatisticsCancellationService.php
index c0e84410a0..2f2b72dcde 100644
--- a/backend/app/Services/Domain/EventStatistics/EventStatisticsCancellationService.php
+++ b/backend/app/Services/Domain/EventStatistics/EventStatisticsCancellationService.php
@@ -11,6 +11,8 @@
use HiEvents\Exceptions\EventStatisticsVersionMismatchException;
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceDailyStatisticRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceStatisticRepositoryInterface;
use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Services\Infrastructure\Utlitiy\Retry\Retrier;
@@ -24,8 +26,10 @@ class EventStatisticsCancellationService
{
public function __construct(
private readonly EventStatisticRepositoryInterface $eventStatisticsRepository,
- private readonly EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository,
- private readonly AttendeeRepositoryInterface $attendeeRepository,
+ private readonly EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository,
+ private readonly EventOccurrenceStatisticRepositoryInterface $eventOccurrenceStatisticRepository,
+ private readonly EventOccurrenceDailyStatisticRepositoryInterface $eventOccurrenceDailyStatisticRepository,
+ private readonly AttendeeRepositoryInterface $attendeeRepository,
private readonly OrderRepositoryInterface $orderRepository,
private readonly LoggerInterface $logger,
private readonly DatabaseManager $databaseManager,
@@ -84,6 +88,10 @@ public function decrementForCancelledOrder(OrderDomainObject $order): void
// Decrement daily statistics
$this->decrementDailyStatistics($order, $counts, $attempt);
+ // Decrement occurrence statistics
+ $this->decrementOccurrenceStatistics($order);
+ $this->decrementOccurrenceDailyStatistics($order);
+
// Mark statistics as decremented
$this->markStatisticsAsDecremented($order);
});
@@ -110,16 +118,17 @@ public function decrementForCancelledOrder(OrderDomainObject $order): void
* @throws EventStatisticsVersionMismatchException
* @throws Throwable
*/
- public function decrementForCancelledAttendee(int $eventId, string $orderDate, int $attendeeCount = 1): void
+ public function decrementForCancelledAttendee(int $eventId, string $orderDate, int $attendeeCount = 1, ?int $occurrenceId = null): void
{
$this->retrier->retry(
- callableAction: function () use ($eventId, $orderDate, $attendeeCount): void {
- $this->databaseManager->transaction(function () use ($eventId, $orderDate, $attendeeCount): void {
- // Decrement aggregate statistics
+ callableAction: function () use ($eventId, $orderDate, $attendeeCount, $occurrenceId): void {
+ $this->databaseManager->transaction(function () use ($eventId, $orderDate, $attendeeCount, $occurrenceId): void {
$this->decrementAggregateAttendeeStatistics($eventId, $attendeeCount);
-
- // Decrement daily statistics
$this->decrementDailyAttendeeStatistics($eventId, $orderDate, $attendeeCount);
+ if ($occurrenceId !== null) {
+ $this->decrementOccurrenceAttendeeStatistics($occurrenceId, $attendeeCount);
+ $this->decrementOccurrenceDailyAttendeeStatistics($occurrenceId, $orderDate, $attendeeCount);
+ }
});
},
onFailure: function (int $attempt, Throwable $e) use ($eventId, $orderDate, $attendeeCount): void {
@@ -393,6 +402,192 @@ private function decrementDailyAttendeeStatistics(int $eventId, string $orderDat
);
}
+ /**
+ * @throws EventStatisticsVersionMismatchException
+ */
+ private function decrementOccurrenceAttendeeStatistics(int $occurrenceId, int $attendeeCount): void
+ {
+ $existing = $this->eventOccurrenceStatisticRepository->findFirstWhere([
+ 'event_occurrence_id' => $occurrenceId,
+ ]);
+
+ if (!$existing) {
+ return;
+ }
+
+ $updates = [
+ 'attendees_registered' => max(0, $existing->getAttendeesRegistered() - $attendeeCount),
+ 'version' => $existing->getVersion() + 1,
+ ];
+
+ $updated = $this->eventOccurrenceStatisticRepository->updateWhere(
+ attributes: $updates,
+ where: [
+ 'event_occurrence_id' => $occurrenceId,
+ 'version' => $existing->getVersion(),
+ ]
+ );
+
+ if ($updated === 0) {
+ throw new EventStatisticsVersionMismatchException(
+ 'Occurrence statistics version mismatch for occurrence ' . $occurrenceId
+ );
+ }
+ }
+
+ /**
+ * @throws EventStatisticsVersionMismatchException
+ */
+ private function decrementOccurrenceStatistics(OrderDomainObject $order): void
+ {
+ $itemsByOccurrence = [];
+ foreach ($order->getOrderItems() as $orderItem) {
+ $occId = $orderItem->getEventOccurrenceId();
+ if ($occId === null) {
+ continue;
+ }
+ $itemsByOccurrence[$occId][] = $orderItem;
+ }
+
+ foreach ($itemsByOccurrence as $occurrenceId => $items) {
+ $existing = $this->eventOccurrenceStatisticRepository->findFirstWhere([
+ 'event_occurrence_id' => $occurrenceId,
+ ]);
+
+ if (!$existing) {
+ continue;
+ }
+
+ $productsSold = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getQuantity(), $items));
+ $attendeesRegistered = $this->countActiveAttendeesForOccurrence($order->getId(), $occurrenceId);
+
+ $updates = [
+ 'attendees_registered' => max(0, $existing->getAttendeesRegistered() - $attendeesRegistered),
+ 'products_sold' => max(0, $existing->getProductsSold() - $productsSold),
+ 'orders_created' => max(0, $existing->getOrdersCreated() - 1),
+ 'orders_cancelled' => ($existing->getOrdersCancelled() ?? 0) + 1,
+ 'version' => $existing->getVersion() + 1,
+ ];
+
+ $updated = $this->eventOccurrenceStatisticRepository->updateWhere(
+ attributes: $updates,
+ where: [
+ 'event_occurrence_id' => $occurrenceId,
+ 'version' => $existing->getVersion(),
+ ]
+ );
+
+ if ($updated === 0) {
+ throw new EventStatisticsVersionMismatchException(
+ 'Occurrence statistics version mismatch for occurrence ' . $occurrenceId
+ );
+ }
+ }
+ }
+
+ private function countActiveAttendeesForOccurrence(int $orderId, int $occurrenceId): int
+ {
+ return $this->attendeeRepository->findWhereIn(
+ field: 'status',
+ values: [AttendeeStatus::ACTIVE->name, AttendeeStatus::AWAITING_PAYMENT->name],
+ additionalWhere: [
+ 'order_id' => $orderId,
+ 'event_occurrence_id' => $occurrenceId,
+ ],
+ )->count();
+ }
+
+ /**
+ * @throws EventStatisticsVersionMismatchException
+ */
+ private function decrementOccurrenceDailyStatistics(OrderDomainObject $order): void
+ {
+ $orderDate = (new Carbon($order->getCreatedAt()))->format('Y-m-d');
+
+ $itemsByOccurrence = [];
+ foreach ($order->getOrderItems() as $orderItem) {
+ $occId = $orderItem->getEventOccurrenceId();
+ if ($occId === null) {
+ continue;
+ }
+ $itemsByOccurrence[$occId][] = $orderItem;
+ }
+
+ foreach ($itemsByOccurrence as $occurrenceId => $items) {
+ $existing = $this->eventOccurrenceDailyStatisticRepository->findFirstWhere([
+ 'event_occurrence_id' => $occurrenceId,
+ 'date' => $orderDate,
+ ]);
+
+ if (!$existing) {
+ continue;
+ }
+
+ $productsSold = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getQuantity(), $items));
+ $attendeesRegistered = $this->countActiveAttendeesForOccurrence($order->getId(), $occurrenceId);
+
+ $updates = [
+ 'attendees_registered' => max(0, $existing->getAttendeesRegistered() - $attendeesRegistered),
+ 'products_sold' => max(0, $existing->getProductsSold() - $productsSold),
+ 'orders_created' => max(0, $existing->getOrdersCreated() - 1),
+ 'orders_cancelled' => ($existing->getOrdersCancelled() ?? 0) + 1,
+ 'version' => $existing->getVersion() + 1,
+ ];
+
+ $updated = $this->eventOccurrenceDailyStatisticRepository->updateWhere(
+ attributes: $updates,
+ where: [
+ 'event_occurrence_id' => $occurrenceId,
+ 'date' => $orderDate,
+ 'version' => $existing->getVersion(),
+ ]
+ );
+
+ if ($updated === 0) {
+ throw new EventStatisticsVersionMismatchException(
+ 'Occurrence daily statistics version mismatch for occurrence ' . $occurrenceId
+ );
+ }
+ }
+ }
+
+ /**
+ * @throws EventStatisticsVersionMismatchException
+ */
+ private function decrementOccurrenceDailyAttendeeStatistics(int $occurrenceId, string $orderDate, int $attendeeCount): void
+ {
+ $formattedDate = (new Carbon($orderDate))->format('Y-m-d');
+
+ $existing = $this->eventOccurrenceDailyStatisticRepository->findFirstWhere([
+ 'event_occurrence_id' => $occurrenceId,
+ 'date' => $formattedDate,
+ ]);
+
+ if (!$existing) {
+ return;
+ }
+
+ $updates = [
+ 'attendees_registered' => max(0, $existing->getAttendeesRegistered() - $attendeeCount),
+ 'version' => $existing->getVersion() + 1,
+ ];
+
+ $updated = $this->eventOccurrenceDailyStatisticRepository->updateWhere(
+ attributes: $updates,
+ where: [
+ 'event_occurrence_id' => $occurrenceId,
+ 'date' => $formattedDate,
+ 'version' => $existing->getVersion(),
+ ]
+ );
+
+ if ($updated === 0) {
+ throw new EventStatisticsVersionMismatchException(
+ 'Occurrence daily statistics version mismatch for occurrence ' . $occurrenceId
+ );
+ }
+ }
+
/**
* Mark that statistics have been decremented for this order
*/
diff --git a/backend/app/Services/Domain/EventStatistics/EventStatisticsIncrementService.php b/backend/app/Services/Domain/EventStatistics/EventStatisticsIncrementService.php
index d6b7a1b826..e5930ba000 100644
--- a/backend/app/Services/Domain/EventStatistics/EventStatisticsIncrementService.php
+++ b/backend/app/Services/Domain/EventStatistics/EventStatisticsIncrementService.php
@@ -4,12 +4,15 @@
namespace HiEvents\Services\Domain\EventStatistics;
+use HiEvents\DomainObjects\Enums\ProductType;
use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\Exceptions\EventStatisticsVersionMismatchException;
use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceDailyStatisticRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceStatisticRepositoryInterface;
use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
@@ -26,8 +29,10 @@ public function __construct(
private readonly PromoCodeRepositoryInterface $promoCodeRepository,
private readonly ProductRepositoryInterface $productRepository,
private readonly EventStatisticRepositoryInterface $eventStatisticsRepository,
- private readonly EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository,
- private readonly DatabaseManager $databaseManager,
+ private readonly EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository,
+ private readonly EventOccurrenceStatisticRepositoryInterface $eventOccurrenceStatisticRepository,
+ private readonly EventOccurrenceDailyStatisticRepositoryInterface $eventOccurrenceDailyStatisticRepository,
+ private readonly DatabaseManager $databaseManager,
private readonly OrderRepositoryInterface $orderRepository,
private readonly LoggerInterface $logger,
private readonly Retrier $retrier,
@@ -52,6 +57,8 @@ public function incrementForOrder(OrderDomainObject $order): void
$this->databaseManager->transaction(function () use ($order): void {
$this->incrementAggregateStatistics($order);
$this->incrementDailyStatistics($order);
+ $this->incrementOccurrenceStatistics($order);
+ $this->incrementOccurrenceDailyStatistics($order);
$this->incrementPromoCodeUsage($order);
$this->incrementProductStatistics($order);
});
@@ -241,6 +248,158 @@ private function incrementDailyStatistics(OrderDomainObject $order): void
);
}
+ /**
+ * Increment occurrence statistics, grouped by occurrence_id from order items
+ *
+ * @throws EventStatisticsVersionMismatchException
+ */
+ private function incrementOccurrenceStatistics(OrderDomainObject $order): void
+ {
+ $itemsByOccurrence = [];
+ foreach ($order->getOrderItems() as $orderItem) {
+ $occId = $orderItem->getEventOccurrenceId();
+ if ($occId === null) {
+ continue;
+ }
+ $itemsByOccurrence[$occId][] = $orderItem;
+ }
+
+ foreach ($itemsByOccurrence as $occurrenceId => $items) {
+ $productsSold = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getQuantity(), $items));
+ $attendeesRegistered = array_sum(array_map(
+ fn(OrderItemDomainObject $i) => $i->getProductType() === ProductType::TICKET->name ? $i->getQuantity() : 0,
+ $items,
+ ));
+ $totalGross = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalGross(), $items));
+ $totalBeforeAdditions = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalBeforeAdditions(), $items));
+ $totalTax = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalTax() ?? 0, $items));
+ $totalFee = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalServiceFee() ?? 0, $items));
+
+ $existing = $this->eventOccurrenceStatisticRepository->findFirstWhere([
+ 'event_id' => $order->getEventId(),
+ 'event_occurrence_id' => $occurrenceId,
+ ]);
+
+ if ($existing === null) {
+ $this->eventOccurrenceStatisticRepository->create([
+ 'event_id' => $order->getEventId(),
+ 'event_occurrence_id' => $occurrenceId,
+ 'products_sold' => $productsSold,
+ 'attendees_registered' => $attendeesRegistered,
+ 'sales_total_gross' => $totalGross,
+ 'sales_total_before_additions' => $totalBeforeAdditions,
+ 'total_tax' => $totalTax,
+ 'total_fee' => $totalFee,
+ 'orders_created' => 1,
+ 'orders_cancelled' => 0,
+ ]);
+ continue;
+ }
+
+ $updates = [
+ 'products_sold' => $existing->getProductsSold() + $productsSold,
+ 'attendees_registered' => $existing->getAttendeesRegistered() + $attendeesRegistered,
+ 'sales_total_gross' => $existing->getSalesTotalGross() + $totalGross,
+ 'sales_total_before_additions' => $existing->getSalesTotalBeforeAdditions() + $totalBeforeAdditions,
+ 'total_tax' => $existing->getTotalTax() + $totalTax,
+ 'total_fee' => $existing->getTotalFee() + $totalFee,
+ 'orders_created' => $existing->getOrdersCreated() + 1,
+ 'version' => $existing->getVersion() + 1,
+ ];
+
+ $updated = $this->eventOccurrenceStatisticRepository->updateWhere(
+ attributes: $updates,
+ where: [
+ 'event_occurrence_id' => $occurrenceId,
+ 'version' => $existing->getVersion(),
+ ]
+ );
+
+ if ($updated === 0) {
+ throw new EventStatisticsVersionMismatchException(
+ 'Occurrence statistics version mismatch for occurrence ' . $occurrenceId
+ );
+ }
+ }
+ }
+
+ /**
+ * @throws EventStatisticsVersionMismatchException
+ */
+ private function incrementOccurrenceDailyStatistics(OrderDomainObject $order): void
+ {
+ $orderDate = (new Carbon($order->getCreatedAt()))->format('Y-m-d');
+
+ $itemsByOccurrence = [];
+ foreach ($order->getOrderItems() as $orderItem) {
+ $occId = $orderItem->getEventOccurrenceId();
+ if ($occId === null) {
+ continue;
+ }
+ $itemsByOccurrence[$occId][] = $orderItem;
+ }
+
+ foreach ($itemsByOccurrence as $occurrenceId => $items) {
+ $productsSold = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getQuantity(), $items));
+ $attendeesRegistered = array_sum(array_map(
+ fn(OrderItemDomainObject $i) => $i->getProductType() === ProductType::TICKET->name ? $i->getQuantity() : 0,
+ $items,
+ ));
+ $totalGross = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalGross(), $items));
+ $totalBeforeAdditions = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalBeforeAdditions(), $items));
+ $totalTax = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalTax() ?? 0, $items));
+ $totalFee = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalServiceFee() ?? 0, $items));
+
+ $existing = $this->eventOccurrenceDailyStatisticRepository->findFirstWhere([
+ 'event_occurrence_id' => $occurrenceId,
+ 'date' => $orderDate,
+ ]);
+
+ if ($existing === null) {
+ $this->eventOccurrenceDailyStatisticRepository->create([
+ 'event_id' => $order->getEventId(),
+ 'event_occurrence_id' => $occurrenceId,
+ 'date' => $orderDate,
+ 'products_sold' => $productsSold,
+ 'attendees_registered' => $attendeesRegistered,
+ 'sales_total_gross' => $totalGross,
+ 'sales_total_before_additions' => $totalBeforeAdditions,
+ 'total_tax' => $totalTax,
+ 'total_fee' => $totalFee,
+ 'orders_created' => 1,
+ 'orders_cancelled' => 0,
+ ]);
+ continue;
+ }
+
+ $updates = [
+ 'products_sold' => $existing->getProductsSold() + $productsSold,
+ 'attendees_registered' => $existing->getAttendeesRegistered() + $attendeesRegistered,
+ 'sales_total_gross' => $existing->getSalesTotalGross() + $totalGross,
+ 'sales_total_before_additions' => $existing->getSalesTotalBeforeAdditions() + $totalBeforeAdditions,
+ 'total_tax' => $existing->getTotalTax() + $totalTax,
+ 'total_fee' => $existing->getTotalFee() + $totalFee,
+ 'orders_created' => $existing->getOrdersCreated() + 1,
+ 'version' => $existing->getVersion() + 1,
+ ];
+
+ $updated = $this->eventOccurrenceDailyStatisticRepository->updateWhere(
+ attributes: $updates,
+ where: [
+ 'event_occurrence_id' => $occurrenceId,
+ 'date' => $orderDate,
+ 'version' => $existing->getVersion(),
+ ]
+ );
+
+ if ($updated === 0) {
+ throw new EventStatisticsVersionMismatchException(
+ 'Occurrence daily statistics version mismatch for occurrence ' . $occurrenceId
+ );
+ }
+ }
+ }
+
/**
* Increment promo code usage counts
*/
diff --git a/backend/app/Services/Domain/EventStatistics/EventStatisticsReactivationService.php b/backend/app/Services/Domain/EventStatistics/EventStatisticsReactivationService.php
index 61428ec45b..8e55c7dab5 100644
--- a/backend/app/Services/Domain/EventStatistics/EventStatisticsReactivationService.php
+++ b/backend/app/Services/Domain/EventStatistics/EventStatisticsReactivationService.php
@@ -6,6 +6,8 @@
use HiEvents\Exceptions\EventStatisticsVersionMismatchException;
use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceDailyStatisticRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceStatisticRepositoryInterface;
use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface;
use HiEvents\Services\Infrastructure\Utlitiy\Retry\Retrier;
use Illuminate\Database\DatabaseManager;
@@ -18,8 +20,10 @@ class EventStatisticsReactivationService
{
public function __construct(
private readonly EventStatisticRepositoryInterface $eventStatisticsRepository,
- private readonly EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository,
- private readonly LoggerInterface $logger,
+ private readonly EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository,
+ private readonly EventOccurrenceStatisticRepositoryInterface $eventOccurrenceStatisticRepository,
+ private readonly EventOccurrenceDailyStatisticRepositoryInterface $eventOccurrenceDailyStatisticRepository,
+ private readonly LoggerInterface $logger,
private readonly DatabaseManager $databaseManager,
private readonly Retrier $retrier,
)
@@ -30,13 +34,17 @@ public function __construct(
* @throws EventStatisticsVersionMismatchException
* @throws Throwable
*/
- public function incrementForReactivatedAttendee(int $eventId, string $orderDate, int $attendeeCount = 1): void
+ public function incrementForReactivatedAttendee(int $eventId, string $orderDate, int $attendeeCount = 1, ?int $occurrenceId = null): void
{
$this->retrier->retry(
- callableAction: function () use ($eventId, $orderDate, $attendeeCount): void {
- $this->databaseManager->transaction(function () use ($eventId, $orderDate, $attendeeCount): void {
+ callableAction: function () use ($eventId, $orderDate, $attendeeCount, $occurrenceId): void {
+ $this->databaseManager->transaction(function () use ($eventId, $orderDate, $attendeeCount, $occurrenceId): void {
$this->incrementAggregateAttendeeStatistics($eventId, $attendeeCount);
$this->incrementDailyAttendeeStatistics($eventId, $orderDate, $attendeeCount);
+ if ($occurrenceId !== null) {
+ $this->incrementOccurrenceAttendeeStatistics($occurrenceId, $attendeeCount);
+ $this->incrementOccurrenceDailyAttendeeStatistics($occurrenceId, $orderDate, $attendeeCount);
+ }
});
},
onFailure: function (int $attempt, Throwable $e) use ($eventId, $orderDate, $attendeeCount): void {
@@ -153,4 +161,74 @@ private function incrementDailyAttendeeStatistics(int $eventId, string $orderDat
]
);
}
+
+ /**
+ * @throws EventStatisticsVersionMismatchException
+ */
+ private function incrementOccurrenceAttendeeStatistics(int $occurrenceId, int $attendeeCount): void
+ {
+ $existing = $this->eventOccurrenceStatisticRepository->findFirstWhere([
+ 'event_occurrence_id' => $occurrenceId,
+ ]);
+
+ if (!$existing) {
+ return;
+ }
+
+ $updates = [
+ 'attendees_registered' => $existing->getAttendeesRegistered() + $attendeeCount,
+ 'version' => $existing->getVersion() + 1,
+ ];
+
+ $updated = $this->eventOccurrenceStatisticRepository->updateWhere(
+ attributes: $updates,
+ where: [
+ 'event_occurrence_id' => $occurrenceId,
+ 'version' => $existing->getVersion(),
+ ]
+ );
+
+ if ($updated === 0) {
+ throw new EventStatisticsVersionMismatchException(
+ 'Occurrence statistics version mismatch for occurrence ' . $occurrenceId
+ );
+ }
+ }
+
+ /**
+ * @throws EventStatisticsVersionMismatchException
+ */
+ private function incrementOccurrenceDailyAttendeeStatistics(int $occurrenceId, string $orderDate, int $attendeeCount): void
+ {
+ $formattedDate = (new Carbon($orderDate))->format('Y-m-d');
+
+ $existing = $this->eventOccurrenceDailyStatisticRepository->findFirstWhere([
+ 'event_occurrence_id' => $occurrenceId,
+ 'date' => $formattedDate,
+ ]);
+
+ if (!$existing) {
+ return;
+ }
+
+ $updates = [
+ 'attendees_registered' => $existing->getAttendeesRegistered() + $attendeeCount,
+ 'version' => $existing->getVersion() + 1,
+ ];
+
+ $updated = $this->eventOccurrenceDailyStatisticRepository->updateWhere(
+ attributes: $updates,
+ where: [
+ 'event_occurrence_id' => $occurrenceId,
+ 'date' => $formattedDate,
+ 'version' => $existing->getVersion(),
+ ]
+ );
+
+ if ($updated === 0) {
+ throw new EventStatisticsVersionMismatchException(
+ 'Occurrence daily statistics version mismatch for occurrence ' . $occurrenceId
+ );
+ }
+ }
}
diff --git a/backend/app/Services/Domain/EventStatistics/EventStatisticsRefundService.php b/backend/app/Services/Domain/EventStatistics/EventStatisticsRefundService.php
index 75dbfa6a7e..7e7397b154 100644
--- a/backend/app/Services/Domain/EventStatistics/EventStatisticsRefundService.php
+++ b/backend/app/Services/Domain/EventStatistics/EventStatisticsRefundService.php
@@ -5,19 +5,27 @@
namespace HiEvents\Services\Domain\EventStatistics;
use HiEvents\DomainObjects\OrderDomainObject;
+use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceDailyStatisticRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceStatisticRepositoryInterface;
use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Values\MoneyValue;
use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\DB;
use Psr\Log\LoggerInterface;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
class EventStatisticsRefundService
{
public function __construct(
- private readonly EventStatisticRepositoryInterface $eventStatisticsRepository,
- private readonly EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository,
- private readonly LoggerInterface $logger,
+ private readonly EventStatisticRepositoryInterface $eventStatisticsRepository,
+ private readonly EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository,
+ private readonly EventOccurrenceStatisticRepositoryInterface $eventOccurrenceStatisticRepository,
+ private readonly EventOccurrenceDailyStatisticRepositoryInterface $eventOccurrenceDailyStatisticRepository,
+ private readonly OrderRepositoryInterface $orderRepository,
+ private readonly LoggerInterface $logger,
)
{
}
@@ -29,6 +37,29 @@ public function updateForRefund(OrderDomainObject $order, MoneyValue $refundAmou
{
$this->updateAggregateStatisticsForRefund($order, $refundAmount);
$this->updateDailyStatisticsForRefund($order, $refundAmount);
+
+ // Occurrence stats need order items eager-loaded; the aggregate / daily paths
+ // do not. Load + group once and pass the result down so the per-occurrence and
+ // per-occurrence-per-day updates do not each repeat the SELECT and the in-memory
+ // grouping. Skips the occurrence pass entirely for non-recurring orders.
+ $orderWithItems = $this->orderRepository
+ ->loadRelation(OrderItemDomainObject::class)
+ ->findById($order->getId());
+
+ if ($orderWithItems->getTotalGross() <= 0) {
+ return;
+ }
+
+ $itemsByOccurrence = $this->groupItemsByOccurrence($orderWithItems);
+ if (empty($itemsByOccurrence)) {
+ return;
+ }
+
+ $refundProportion = $refundAmount->toFloat() / $orderWithItems->getTotalGross();
+ $orderDate = (new Carbon($orderWithItems->getCreatedAt()))->format('Y-m-d');
+
+ $this->updateOccurrenceStatisticsForRefund($itemsByOccurrence, $refundProportion);
+ $this->updateOccurrenceDailyStatisticsForRefund($itemsByOccurrence, $refundProportion, $orderDate);
}
/**
@@ -141,4 +172,95 @@ private function updateDailyStatisticsForRefund(OrderDomainObject $order, MoneyV
]
);
}
+
+ /**
+ * Atomically applies the refund delta to per-occurrence stats. Uses raw SQL increments
+ * (rather than read-modify-write) so concurrent refunds on the same occurrence cannot
+ * lose updates. Version is bumped so any concurrent reader using optimistic locking
+ * (e.g. EventStatisticsIncrementService) detects the change.
+ *
+ * @param array $itemsByOccurrence
+ */
+ private function updateOccurrenceStatisticsForRefund(array $itemsByOccurrence, float $refundProportion): void
+ {
+ foreach ($itemsByOccurrence as $occurrenceId => $items) {
+ $occurrenceGross = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalGross() ?? 0, $items));
+ $occurrenceTax = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalTax() ?? 0, $items));
+ $occurrenceFee = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalServiceFee() ?? 0, $items));
+
+ $grossDelta = $this->formatDelta($occurrenceGross * $refundProportion);
+ $taxDelta = $this->formatDelta($occurrenceTax * $refundProportion);
+ $feeDelta = $this->formatDelta($occurrenceFee * $refundProportion);
+
+ $this->eventOccurrenceStatisticRepository->updateWhere(
+ attributes: [
+ 'sales_total_gross' => DB::raw("GREATEST(0, sales_total_gross - {$grossDelta})"),
+ 'total_refunded' => DB::raw("total_refunded + {$grossDelta}"),
+ 'total_tax' => DB::raw("GREATEST(0, total_tax - {$taxDelta})"),
+ 'total_fee' => DB::raw("GREATEST(0, total_fee - {$feeDelta})"),
+ 'version' => DB::raw('version + 1'),
+ ],
+ where: [
+ 'event_occurrence_id' => $occurrenceId,
+ ]
+ );
+ }
+ }
+
+ /**
+ * Atomic per-occurrence-per-day refund stats update. See updateOccurrenceStatisticsForRefund
+ * for the rationale behind raw SQL increments.
+ *
+ * @param array $itemsByOccurrence
+ */
+ private function updateOccurrenceDailyStatisticsForRefund(array $itemsByOccurrence, float $refundProportion, string $orderDate): void
+ {
+ foreach ($itemsByOccurrence as $occurrenceId => $items) {
+ $occurrenceGross = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalGross() ?? 0, $items));
+ $occurrenceTax = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalTax() ?? 0, $items));
+ $occurrenceFee = array_sum(array_map(fn(OrderItemDomainObject $i) => $i->getTotalServiceFee() ?? 0, $items));
+
+ $grossDelta = $this->formatDelta($occurrenceGross * $refundProportion);
+ $taxDelta = $this->formatDelta($occurrenceTax * $refundProportion);
+ $feeDelta = $this->formatDelta($occurrenceFee * $refundProportion);
+
+ $this->eventOccurrenceDailyStatisticRepository->updateWhere(
+ attributes: [
+ 'sales_total_gross' => DB::raw("GREATEST(0, sales_total_gross - {$grossDelta})"),
+ 'total_refunded' => DB::raw("total_refunded + {$grossDelta}"),
+ 'total_tax' => DB::raw("GREATEST(0, total_tax - {$taxDelta})"),
+ 'total_fee' => DB::raw("GREATEST(0, total_fee - {$feeDelta})"),
+ 'version' => DB::raw('version + 1'),
+ ],
+ where: [
+ 'event_occurrence_id' => $occurrenceId,
+ 'date' => $orderDate,
+ ]
+ );
+ }
+ }
+
+ /**
+ * Locale-safe float-to-SQL formatter for inline numeric literals.
+ */
+ private function formatDelta(float $value): string
+ {
+ return number_format($value, 4, '.', '');
+ }
+
+ /**
+ * @return array
+ */
+ private function groupItemsByOccurrence(OrderDomainObject $order): array
+ {
+ $itemsByOccurrence = [];
+ foreach ($order->getOrderItems() as $orderItem) {
+ $occId = $orderItem->getEventOccurrenceId();
+ if ($occId === null) {
+ continue;
+ }
+ $itemsByOccurrence[$occId][] = $orderItem;
+ }
+ return $itemsByOccurrence;
+ }
}
diff --git a/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php b/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php
index 833ac9b164..aba0eecc05 100644
--- a/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php
+++ b/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php
@@ -29,16 +29,14 @@ class SendEventEmailMessagesService
private array $sentEmails = [];
public function __construct(
- private readonly OrderRepositoryInterface $orderRepository,
+ private readonly OrderRepositoryInterface $orderRepository,
private readonly AttendeeRepositoryInterface $attendeeRepository,
- private readonly EventRepositoryInterface $eventRepository,
- private readonly MessageRepositoryInterface $messageRepository,
- private readonly UserRepositoryInterface $userRepository,
- private readonly Logger $logger,
- private readonly Dispatcher $dispatcher,
- )
- {
- }
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly MessageRepositoryInterface $messageRepository,
+ private readonly UserRepositoryInterface $userRepository,
+ private readonly Logger $logger,
+ private readonly Dispatcher $dispatcher,
+ ) {}
/**
* @throws UnableToSendMessageException
@@ -58,7 +56,7 @@ public function send(SendMessageDTO $messageData): void
'event_id' => $messageData->event_id,
]);
- if ((!$order && $messageData->type === MessageTypeEnum::ORDER_OWNER) || !$messageData->id) {
+ if ((! $order && $messageData->type === MessageTypeEnum::ORDER_OWNER) || ! $messageData->id) {
$message = 'Unable to send message. Order or message ID not present.';
$this->logger->error($message, $messageData->toArray());
$this->updateMessageStatus($messageData, MessageStatus::FAILED);
@@ -103,13 +101,15 @@ private function sendAttendeeMessages(SendMessageDTO $messageData, EventDomainOb
private function sendTicketHolderMessages(SendMessageDTO $messageData, EventDomainObject $event): void
{
+ $additionalWhere = array_merge([
+ 'event_id' => $messageData->event_id,
+ 'status' => AttendeeStatus::ACTIVE->name,
+ ], $this->occurrenceWhere($messageData));
+
$attendees = $this->attendeeRepository->findWhereIn(
field: 'product_id',
values: $messageData->product_ids,
- additionalWhere: [
- 'event_id' => $messageData->event_id,
- 'status' => AttendeeStatus::ACTIVE->name,
- ],
+ additionalWhere: $additionalWhere,
columns: ['first_name', 'last_name', 'email']
);
@@ -117,11 +117,10 @@ private function sendTicketHolderMessages(SendMessageDTO $messageData, EventDoma
}
private function sendOrderMessages(
- SendMessageDTO $messageData,
+ SendMessageDTO $messageData,
EventDomainObject $event,
OrderDomainObject $order,
- ): void
- {
+ ): void {
$this->sendEmailToMessageSender($messageData, $event);
$this->sendMessage(
@@ -133,11 +132,10 @@ private function sendOrderMessages(
}
private function emailAttendees(
- Collection $attendees,
- SendMessageDTO $messageData,
+ Collection $attendees,
+ SendMessageDTO $messageData,
EventDomainObject $event,
- ): void
- {
+ ): void {
$this->sendEmailToMessageSender($messageData, $event);
if ($messageData->is_test) {
@@ -184,20 +182,39 @@ private function updateMessageStatus(SendMessageDTO $messageData, MessageStatus
*/
private function sendEventMessages(SendMessageDTO $messageData, EventDomainObject $event): void
{
+ $where = array_merge([
+ 'event_id' => $messageData->event_id,
+ 'status' => AttendeeStatus::ACTIVE->name,
+ ], $this->occurrenceWhere($messageData));
+
$attendees = $this->attendeeRepository->findWhere(
- where: [
- 'event_id' => $messageData->event_id,
- 'status' => AttendeeStatus::ACTIVE->name,
- ],
+ where: $where,
columns: ['first_name', 'last_name', 'email']
);
$this->emailAttendees($attendees, $messageData, $event);
}
+ /**
+ * Returns the `where` fragment scoping to the target occurrences.
+ * event_occurrence_ids wins over event_occurrence_id when both are set
+ * (the validator forbids that combo anyway).
+ */
+ private function occurrenceWhere(SendMessageDTO $messageData): array
+ {
+ if (! empty($messageData->event_occurrence_ids)) {
+ return [['event_occurrence_id', 'in', $messageData->event_occurrence_ids]];
+ }
+ if ($messageData->event_occurrence_id) {
+ return ['event_occurrence_id' => $messageData->event_occurrence_id];
+ }
+
+ return [];
+ }
+
private function sendEmailToMessageSender(SendMessageDTO $messageData, EventDomainObject $event): void
{
- if (!$messageData->send_copy_to_current_user && !$messageData->is_test) {
+ if (! $messageData->send_copy_to_current_user && ! $messageData->is_test) {
return;
}
@@ -216,7 +233,9 @@ private function sendProductMessages(SendMessageDTO $messageData, EventDomainObj
$orders = $this->orderRepository->findOrdersAssociatedWithProducts(
eventId: $messageData->event_id,
productIds: $messageData->product_ids,
- orderStatuses: $messageData->order_statuses
+ orderStatuses: $messageData->order_statuses,
+ eventOccurrenceId: $messageData->event_occurrence_id,
+ eventOccurrenceIds: $messageData->event_occurrence_ids,
);
if ($orders->isEmpty()) {
@@ -236,12 +255,11 @@ private function sendProductMessages(SendMessageDTO $messageData, EventDomainObj
}
private function sendMessage(
- string $emailAddress,
- string $fullName,
- SendMessageDTO $messageData,
+ string $emailAddress,
+ string $fullName,
+ SendMessageDTO $messageData,
EventDomainObject $event,
- ): void
- {
+ ): void {
if (in_array($emailAddress, $this->sentEmails, true)) {
return;
}
diff --git a/backend/app/Services/Domain/Mail/SendOrderDetailsService.php b/backend/app/Services/Domain/Mail/SendOrderDetailsService.php
index a1b139d650..6858b6ed45 100644
--- a/backend/app/Services/Domain/Mail/SendOrderDetailsService.php
+++ b/backend/app/Services/Domain/Mail/SendOrderDetailsService.php
@@ -4,13 +4,14 @@
use HiEvents\DomainObjects\AttendeeDomainObject;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\InvoiceDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
+use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\Mail\Order\OrderFailed;
-use HiEvents\Mail\Order\OrderSummary;
use HiEvents\Mail\Organizer\OrderSummaryForOrganizer;
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
@@ -34,14 +35,35 @@ public function __construct(
public function sendOrderSummaryAndTicketEmails(OrderDomainObject $order): void
{
$order = $this->orderRepository
- ->loadRelation(OrderItemDomainObject::class)
- ->loadRelation(AttendeeDomainObject::class)
+ ->loadRelation(new Relationship(
+ domainObject: OrderItemDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ name: 'event_occurrence',
+ ),
+ ],
+ ))
+ ->loadRelation(new Relationship(
+ domainObject: AttendeeDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ name: 'event_occurrence',
+ ),
+ new Relationship(
+ domainObject: ProductDomainObject::class,
+ name: 'product',
+ ),
+ ],
+ ))
->loadRelation(InvoiceDomainObject::class)
->findById($order->getId());
$event = $this->eventRepository
->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer'))
->loadRelation(new Relationship(EventSettingDomainObject::class))
+ ->loadRelation(new Relationship(EventOccurrenceDomainObject::class))
->findById($order->getEventId());
if ($order->isOrderCompleted() || $order->isOrderAwaitingOfflinePayment()) {
@@ -63,11 +85,12 @@ public function sendOrderSummaryAndTicketEmails(OrderDomainObject $order): void
}
public function sendCustomerOrderSummary(
- OrderDomainObject $order,
- EventDomainObject $event,
- OrganizerDomainObject $organizer,
- EventSettingDomainObject $eventSettings,
- ?InvoiceDomainObject $invoice = null
+ OrderDomainObject $order,
+ EventDomainObject $event,
+ OrganizerDomainObject $organizer,
+ EventSettingDomainObject $eventSettings,
+ ?InvoiceDomainObject $invoice = null,
+ ?EventOccurrenceDomainObject $occurrence = null,
): void
{
$mail = $this->mailBuilderService->buildOrderSummaryMail(
@@ -75,7 +98,8 @@ public function sendCustomerOrderSummary(
$event,
$eventSettings,
$organizer,
- $invoice
+ $invoice,
+ $occurrence ?? $this->resolvePrimaryOccurrence($order),
);
$this->mailer
@@ -84,6 +108,26 @@ public function sendCustomerOrderSummary(
->send($mail);
}
+ /**
+ * Single-occurrence orders return that occurrence so the email can show its
+ * date. Multi-occurrence series-pass orders return null (email falls back
+ * to the event-level range).
+ */
+ private function resolvePrimaryOccurrence(OrderDomainObject $order): ?EventOccurrenceDomainObject
+ {
+ $items = $order->getOrderItems();
+ if ($items === null || $items->isEmpty()) {
+ return null;
+ }
+
+ $distinct = $items
+ ->map(fn(OrderItemDomainObject $item) => $item->getEventOccurrence())
+ ->filter()
+ ->unique(fn(EventOccurrenceDomainObject $occ) => $occ->getId());
+
+ return $distinct->count() === 1 ? $distinct->first() : null;
+ }
+
private function sendAttendeeTicketEmails(OrderDomainObject $order, EventDomainObject $event): void
{
$sentEmails = [];
diff --git a/backend/app/Services/Domain/Message/MessageDispatchService.php b/backend/app/Services/Domain/Message/MessageDispatchService.php
index 006e666efa..3cb73f54b9 100644
--- a/backend/app/Services/Domain/Message/MessageDispatchService.php
+++ b/backend/app/Services/Domain/Message/MessageDispatchService.php
@@ -17,22 +17,21 @@ class MessageDispatchService
{
public function __construct(
private readonly MessageRepositoryInterface $messageRepository,
- )
- {
- }
+ ) {}
public function dispatchMessage(MessageDomainObject $message, MessageStatus $expectedStatus = MessageStatus::SCHEDULED): void
{
$sendData = $message->getSendData();
$sendDataArray = is_string($sendData) ? json_decode($sendData, true) : $sendData;
- if (!is_array($sendDataArray) || !isset($sendDataArray['account_id'])) {
+ if (! is_array($sendDataArray) || ! isset($sendDataArray['account_id'])) {
Log::error('Message has invalid send_data, marking as FAILED', [
'message_id' => $message->getId(),
]);
$this->messageRepository->updateFromArray($message->getId(), [
'status' => MessageStatus::FAILED->name,
]);
+
return;
}
@@ -45,6 +44,7 @@ public function dispatchMessage(MessageDomainObject $message, MessageStatus $exp
Log::info('Message status changed before dispatch, skipping', [
'message_id' => $message->getId(),
]);
+
return;
}
@@ -63,6 +63,8 @@ public function dispatchMessage(MessageDomainObject $message, MessageStatus $exp
id: $message->getId(),
attendee_ids: $message->getAttendeeIds() ?? [],
product_ids: $message->getProductIds() ?? [],
+ event_occurrence_id: $message->getEventOccurrenceId(),
+ event_occurrence_ids: $sendDataArray['event_occurrence_ids'] ?? null,
));
} catch (Throwable $e) {
Log::error('Failed to dispatch SendMessagesJob, reverting status', [
diff --git a/backend/app/Services/Domain/Message/MessagingEligibilityService.php b/backend/app/Services/Domain/Message/MessagingEligibilityService.php
index 5a5b1df3bf..f18b9a6d9f 100644
--- a/backend/app/Services/Domain/Message/MessagingEligibilityService.php
+++ b/backend/app/Services/Domain/Message/MessagingEligibilityService.php
@@ -4,14 +4,15 @@
use Carbon\Carbon;
use HiEvents\DomainObjects\AccountMessagingTierDomainObject;
-use HiEvents\DomainObjects\AccountStripePlatformDomainObject;
use HiEvents\DomainObjects\Enums\MessagingEligibilityFailureEnum;
use HiEvents\DomainObjects\Enums\MessagingTierViolationEnum;
+use HiEvents\DomainObjects\OrganizerStripePlatformDomainObject;
use HiEvents\Repository\Interfaces\AccountMessagingTierRepositoryInterface;
use HiEvents\Repository\Interfaces\AccountRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\MessageRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface;
use HiEvents\Services\Domain\Message\DTO\MessagingEligibilityFailureDTO;
use HiEvents\Services\Domain\Message\DTO\MessagingTierViolationDTO;
@@ -25,14 +26,13 @@ public function __construct(
private readonly MessageRepositoryInterface $messageRepository,
private readonly AccountMessagingTierRepositoryInterface $accountMessagingTierRepository,
private readonly OrderRepositoryInterface $orderRepository,
+ private readonly OrganizerRepositoryInterface $organizerRepository,
) {
}
public function checkEligibility(int $accountId, int $eventId): ?MessagingEligibilityFailureDTO
{
- $account = $this->accountRepository
- ->loadRelation(AccountStripePlatformDomainObject::class)
- ->findById($accountId);
+ $account = $this->accountRepository->findById($accountId);
$tier = $this->getAccountMessagingTier($account->getAccountMessagingTierId());
@@ -41,9 +41,15 @@ public function checkEligibility(int $accountId, int $eventId): ?MessagingEligib
return null;
}
+ $event = $this->eventRepository->findById($eventId);
+
+ $organizer = $this->organizerRepository
+ ->loadRelation(OrganizerStripePlatformDomainObject::class)
+ ->findById($event->getOrganizerId());
+
$failures = [];
- if (!$account->isStripeSetupComplete()) {
+ if (!$organizer || !$organizer->isStripeSetupComplete()) {
$failures[] = MessagingEligibilityFailureEnum::STRIPE_NOT_CONNECTED;
}
@@ -51,7 +57,6 @@ public function checkEligibility(int $accountId, int $eventId): ?MessagingEligib
$failures[] = MessagingEligibilityFailureEnum::NO_PAID_ORDERS;
}
- $event = $this->eventRepository->findById($eventId);
if ($this->isEventTooNew($event->getCreatedAt())) {
$failures[] = MessagingEligibilityFailureEnum::EVENT_TOO_NEW;
}
diff --git a/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php b/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php
index e04a5f9187..5878bbd498 100644
--- a/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php
+++ b/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php
@@ -3,16 +3,16 @@
namespace HiEvents\Services\Domain\Order;
use Brick\Math\Exception\MathException;
-use HiEvents\DomainObjects\AccountConfigurationDomainObject;
-use HiEvents\DomainObjects\AccountDomainObject;
use HiEvents\DomainObjects\AttendeeDomainObject;
use HiEvents\DomainObjects\Enums\PaymentProviders;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract;
use HiEvents\DomainObjects\InvoiceDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
+use HiEvents\DomainObjects\OrganizerConfigurationDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\DomainObjects\Status\AttendeeStatus;
use HiEvents\DomainObjects\Status\InvoiceStatus;
@@ -24,6 +24,7 @@
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\AffiliateRepositoryInterface;
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\InvoiceRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
@@ -37,19 +38,18 @@
class MarkOrderAsPaidService
{
public function __construct(
- private readonly OrderRepositoryInterface $orderRepository,
- private readonly DatabaseManager $databaseManager,
- private readonly AffiliateRepositoryInterface $affiliateRepository,
- private readonly InvoiceRepositoryInterface $invoiceRepository,
- private readonly AttendeeRepositoryInterface $attendeeRepository,
- private readonly DomainEventDispatcherService $domainEventDispatcherService,
+ private readonly OrderRepositoryInterface $orderRepository,
+ private readonly DatabaseManager $databaseManager,
+ private readonly AffiliateRepositoryInterface $affiliateRepository,
+ private readonly InvoiceRepositoryInterface $invoiceRepository,
+ private readonly AttendeeRepositoryInterface $attendeeRepository,
+ private readonly DomainEventDispatcherService $domainEventDispatcherService,
private readonly OrderApplicationFeeCalculationService $orderApplicationFeeCalculationService,
- private readonly EventRepositoryInterface $eventRepository,
- private readonly OrderApplicationFeeService $orderApplicationFeeService,
- private readonly SendOrderDetailsService $sendOrderDetailsService,
- )
- {
- }
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly OrderApplicationFeeService $orderApplicationFeeService,
+ private readonly SendOrderDetailsService $sendOrderDetailsService,
+ private readonly EventOccurrenceRepositoryInterface $occurrenceRepository,
+ ) {}
/**
* @throws ResourceConflictException|Throwable
@@ -57,8 +57,7 @@ public function __construct(
public function markOrderAsPaid(
int $orderId,
int $eventId,
- ): OrderDomainObject
- {
+ ): OrderDomainObject {
return $this->databaseManager->transaction(function () use ($orderId, $eventId) {
/** @var OrderDomainObject $order */
$order = $this->orderRepository
@@ -73,18 +72,33 @@ public function markOrderAsPaid(
$event = $this->eventRepository
->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer'))
->loadRelation(new Relationship(EventSettingDomainObject::class))
+ ->loadRelation(new Relationship(EventOccurrenceDomainObject::class))
->findById($order->getEventId());
if ($order->getStatus() !== OrderStatus::AWAITING_OFFLINE_PAYMENT->name) {
throw new ResourceConflictException(__('Order is not awaiting offline payment'));
}
+ // Mirror CompleteOrderHandler::validateOccurrenceStatus — an offline
+ // order can sit AWAITING_OFFLINE_PAYMENT for days and have its
+ // sessions cancelled or pass into the past in the meantime. Marking
+ // it paid would otherwise issue ACTIVE attendees for a dead date.
+ $this->validateOccurrenceStatus($order);
+
$this->updateOrderStatus($orderId);
$this->updateOrderInvoice($orderId);
$updatedOrder = $this->orderRepository
- ->loadRelation(OrderItemDomainObject::class)
+ ->loadRelation(new Relationship(
+ domainObject: OrderItemDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ name: 'event_occurrence',
+ ),
+ ],
+ ))
->findById($orderId);
// Update affiliate sales if this order has an affiliate
@@ -123,6 +137,34 @@ public function markOrderAsPaid(
});
}
+ /**
+ * @throws ResourceConflictException
+ */
+ private function validateOccurrenceStatus(OrderDomainObject $order): void
+ {
+ $occurrenceIds = $order->getOrderItems()
+ ?->map(fn (OrderItemDomainObject $item) => $item->getEventOccurrenceId())
+ ->filter()
+ ->unique()
+ ->values();
+
+ if ($occurrenceIds === null || $occurrenceIds->isEmpty()) {
+ return;
+ }
+
+ $occurrences = $this->occurrenceRepository->findWhereIn('id', $occurrenceIds->toArray());
+
+ foreach ($occurrences as $occurrence) {
+ if ($occurrence->isCancelled()) {
+ throw new ResourceConflictException(__('This event date has been cancelled'));
+ }
+
+ if ($occurrence->isPast()) {
+ throw new ResourceConflictException(__('This event date has already ended'));
+ }
+ }
+ }
+
private function updateOrderInvoice(int $orderId): void
{
$invoice = $this->invoiceRepository->findLatestInvoiceForOrder($orderId);
@@ -163,24 +205,26 @@ private function storeApplicationFeePayment(OrderDomainObject $updatedOrder): vo
/** @var EventDomainObject $event */
$event = $this->eventRepository
->loadRelation(new Relationship(
- domainObject: AccountDomainObject::class,
+ domainObject: OrganizerDomainObject::class,
nested: [
new Relationship(
- domainObject: AccountConfigurationDomainObject::class,
- name: 'configuration',
+ domainObject: OrganizerConfigurationDomainObject::class,
+ name: 'organizer_configuration',
),
],
- name: 'account'
+ name: 'organizer'
))
->findById($updatedOrder->getEventId());
- /** @var AccountConfigurationDomainObject $config */
- $config = $event->getAccount()->getConfiguration();
+ $config = $event->getOrganizer()?->getOrganizerConfiguration();
+ if (!$config) {
+ return;
+ }
$this->orderApplicationFeeService->createOrderApplicationFee(
orderId: $updatedOrder->getId(),
applicationFeeAmountMinorUnit: $this->orderApplicationFeeCalculationService->calculateApplicationFee(
- accountConfiguration: $config,
+ configuration: $config,
order: $updatedOrder,
)?->netApplicationFee?->toMinorUnit() ?? 0,
orderApplicationFeeStatus: OrderApplicationFeeStatus::AWAITING_PAYMENT,
diff --git a/backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php b/backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php
index 69c81ff8b7..c518633626 100644
--- a/backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php
+++ b/backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php
@@ -3,8 +3,8 @@
namespace HiEvents\Services\Domain\Order;
use Brick\Money\Currency;
-use HiEvents\DomainObjects\AccountConfigurationDomainObject;
-use HiEvents\DomainObjects\AccountVatSettingDomainObject;
+use HiEvents\DomainObjects\OrganizerConfigurationDomainObject;
+use HiEvents\DomainObjects\OrganizerVatSettingDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\Services\Domain\Order\DTO\ApplicationFeeValuesDTO;
use HiEvents\Services\Domain\Order\Vat\VatRateDeterminationService;
@@ -23,9 +23,9 @@ public function __construct(
}
public function calculateApplicationFee(
- AccountConfigurationDomainObject $accountConfiguration,
+ OrganizerConfigurationDomainObject $configuration,
OrderDomainObject $order,
- ?AccountVatSettingDomainObject $vatSettings = null
+ ?OrganizerVatSettingDomainObject $vatSettings = null
): ?ApplicationFeeValuesDTO
{
$currency = $order->getCurrency();
@@ -35,8 +35,8 @@ public function calculateApplicationFee(
return null;
}
- $fixedFee = $this->getConvertedFixedFee($accountConfiguration, $currency);
- $percentageFee = $accountConfiguration->getPercentageApplicationFee();
+ $fixedFee = $this->getConvertedFixedFee($configuration, $currency);
+ $percentageFee = $configuration->getPercentageApplicationFee();
$netApplicationFee = MoneyValue::fromFloat(
amount: ($fixedFee->toFloat() * $quantityPurchased) + ($order->getTotalGross() * $percentageFee / 100),
@@ -58,20 +58,20 @@ public function calculateApplicationFee(
}
private function getConvertedFixedFee(
- AccountConfigurationDomainObject $accountConfiguration,
+ OrganizerConfigurationDomainObject $configuration,
string $currency
): MoneyValue
{
- $baseCurrency = $accountConfiguration->getApplicationFeeCurrency();
+ $baseCurrency = $configuration->getApplicationFeeCurrency();
if ($currency === $baseCurrency) {
- return MoneyValue::fromFloat($accountConfiguration->getFixedApplicationFee(), $currency);
+ return MoneyValue::fromFloat($configuration->getFixedApplicationFee(), $currency);
}
return $this->currencyConversionClient->convert(
fromCurrency: Currency::of($baseCurrency),
toCurrency: Currency::of($currency),
- amount: $accountConfiguration->getFixedApplicationFee()
+ amount: $configuration->getFixedApplicationFee()
);
}
@@ -98,7 +98,7 @@ private function getChargeableQuantityPurchased(OrderDomainObject $order): int
* - Gross charged: £0.72 (£0.60 + £0.12)
*/
private function calculateFeeWithVat(
- AccountVatSettingDomainObject $vatSettings,
+ OrganizerVatSettingDomainObject $vatSettings,
MoneyValue $netApplicationFee,
string $currency,
): ApplicationFeeValuesDTO
diff --git a/backend/app/Services/Domain/Order/OrderCancelService.php b/backend/app/Services/Domain/Order/OrderCancelService.php
index 0c0c904431..09b1f0ccf5 100644
--- a/backend/app/Services/Domain/Order/OrderCancelService.php
+++ b/backend/app/Services/Domain/Order/OrderCancelService.php
@@ -103,11 +103,17 @@ private function adjustProductQuantities(OrderDomainObject $order): void
return $attendee->getStatus() === AttendeeStatus::ACTIVE->name;
});
- $productIdCountMap = $attendees
- ->map(fn(AttendeeDomainObject $attendee) => $attendee->getProductPriceId())->countBy();
-
- foreach ($productIdCountMap as $productPriceId => $count) {
- $this->productQuantityService->decreaseQuantitySold($productPriceId, $count);
+ $groupedCounts = $attendees
+ ->map(fn(AttendeeDomainObject $attendee) => $attendee->getProductPriceId() . '_' . $attendee->getEventOccurrenceId())
+ ->countBy();
+
+ foreach ($groupedCounts as $compositeKey => $count) {
+ [$productPriceId, $eventOccurrenceId] = explode('_', (string) $compositeKey);
+ $this->productQuantityService->decreaseQuantitySold(
+ (int) $productPriceId,
+ $count,
+ $eventOccurrenceId ? (int) $eventOccurrenceId : null,
+ );
}
}
@@ -129,15 +135,19 @@ private function dispatchCapacityChangedEvents(OrderDomainObject $order): void
'order_id' => $order->getId(),
]);
- $productIds = $attendees
- ->map(fn(AttendeeDomainObject $attendee) => $attendee->getProductId())
- ->unique();
+ $capacityScopes = $attendees
+ ->map(fn(AttendeeDomainObject $attendee) => [
+ 'product_id' => $attendee->getProductId(),
+ 'event_occurrence_id' => $attendee->getEventOccurrenceId(),
+ ])
+ ->unique(fn (array $scope) => $scope['product_id'].'-'.$scope['event_occurrence_id']);
- foreach ($productIds as $productId) {
+ foreach ($capacityScopes as $scope) {
event(new CapacityChangedEvent(
eventId: $order->getEventId(),
direction: CapacityChangeDirection::INCREASED,
- productId: $productId,
+ productId: $scope['product_id'],
+ eventOccurrenceId: $scope['event_occurrence_id'],
));
}
}
diff --git a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php
index 6df66a3587..7bea1f128d 100644
--- a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php
+++ b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php
@@ -6,13 +6,18 @@
use HiEvents\DomainObjects\CapacityAssignmentDomainObject;
use HiEvents\DomainObjects\Enums\ProductPriceType;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
+use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\Helper\Currency;
+use HiEvents\Repository\Eloquent\Value\OrderAndDirection;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
-use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
+use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface;
+use HiEvents\Services\Domain\EventOccurrence\OccurrencePurchaseEligibilityService;
use HiEvents\Services\Domain\Product\AvailableProductQuantitiesFetchService;
use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesDTO;
use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesResponseDTO;
@@ -26,34 +31,136 @@ class OrderCreateRequestValidationService
private AvailableProductQuantitiesResponseDTO $availableProductQuantities;
public function __construct(
- readonly private ProductRepositoryInterface $productRepository,
- readonly private PromoCodeRepositoryInterface $promoCodeRepository,
- readonly private EventRepositoryInterface $eventRepository,
+ readonly private ProductRepositoryInterface $productRepository,
+ readonly private PromoCodeRepositoryInterface $promoCodeRepository,
+ readonly private EventRepositoryInterface $eventRepository,
+ readonly private EventOccurrenceRepositoryInterface $occurrenceRepository,
readonly private AvailableProductQuantitiesFetchService $fetchAvailableProductQuantitiesService,
- )
- {
- }
+ readonly private OccurrencePurchaseEligibilityService $occurrenceEligibilityService,
+ ) {}
/**
* @throws ValidationException
* @throws Exception
*/
- public function validateRequestData(int $eventId, array $data = []): void
+ public function validateRequestData(int $eventId, array $data = []): array
{
- $this->validateTypes($data);
-
$event = $this->eventRepository->findById($eventId);
+ $data = $this->normalizeOccurrenceIds($event, $data);
+
+ $this->validateTypes($data);
$this->validatePromoCode($eventId, $data);
$this->validateProductSelection($data);
+ $this->validateOccurrence($eventId, $data);
+ // Event-wide snapshot is still needed for `validateOverallCapacity`
+ // (event-level capacity assignments).
$this->availableProductQuantities = $this->fetchAvailableProductQuantitiesService
->getAvailableProductQuantities(
$event->getId(),
ignoreCache: true,
);
- $this->validateOverallCapacity($data);
- $this->validateProductDetails($event, $data);
+ $this->validateOverallCapacity($event, $data);
+
+ // Re-fetch availability per occurrence so the product-quantity check
+ // matches what `CreateOrderHandler::validateProductAvailability` enforces
+ // — previously this used only the event-wide snapshot, letting the
+ // validator wave through orders the handler would later reject (causing
+ // confusing two-step failures during checkout).
+ $this->validateProductDetailsPerOccurrence($event, $data);
+
+ return $data;
+ }
+
+ private function normalizeOccurrenceIds(EventDomainObject $event, array $data): array
+ {
+ if ($event->isRecurring() || empty($data['products']) || ! is_array($data['products'])) {
+ return $data;
+ }
+
+ $missingOccurrenceId = collect($data['products'])
+ ->contains(fn ($product): bool => is_array($product) && empty($product['event_occurrence_id']));
+
+ if (! $missingOccurrenceId) {
+ return $data;
+ }
+
+ $occurrence = $this->getSingleEventOccurrence($event->getId());
+ if ($occurrence === null) {
+ return $data;
+ }
+
+ $data['products'] = collect($data['products'])
+ ->map(function ($product) use ($occurrence) {
+ if (! is_array($product)) {
+ return $product;
+ }
+
+ if (empty($product['event_occurrence_id'])) {
+ $product['event_occurrence_id'] = $occurrence->getId();
+ }
+
+ return $product;
+ })
+ ->all();
+
+ return $data;
+ }
+
+ private function getSingleEventOccurrence(int $eventId): ?EventOccurrenceDomainObject
+ {
+ return $this->occurrenceRepository
+ ->findWhere(
+ where: [
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
+ ],
+ orderAndDirections: [
+ new OrderAndDirection(EventOccurrenceDomainObjectAbstract::START_DATE, 'asc'),
+ ],
+ )
+ ->first();
+ }
+
+ /**
+ * Walk each occurrence's product group and validate against availability
+ * scoped to that occurrence. For non-recurring events there's only one
+ * occurrence, so this is a single iteration with the same per-occurrence
+ * data the order handler uses.
+ */
+ private function validateProductDetailsPerOccurrence(EventDomainObject $event, array $data): void
+ {
+ $eventWideAvailability = $this->availableProductQuantities;
+ $productsByOccurrence = collect($data['products'])->groupBy('event_occurrence_id');
+
+ try {
+ foreach ($productsByOccurrence as $occurrenceId => $products) {
+ $this->availableProductQuantities = $this->fetchAvailableProductQuantitiesService
+ ->getAvailableProductQuantities(
+ $event->getId(),
+ ignoreCache: true,
+ eventOccurrenceId: $occurrenceId !== null && $occurrenceId !== ''
+ ? (int) $occurrenceId
+ : null,
+ );
+
+ foreach ($products as $productAndQuantities) {
+ $allProducts = $this->getProducts(['products' => [$productAndQuantities]]);
+ $productIndex = collect($data['products'])->search(
+ fn ($p) => $p === $productAndQuantities,
+ );
+ $this->validateSingleProductDetails(
+ $event,
+ is_int($productIndex) ? $productIndex : 0,
+ $productAndQuantities,
+ $allProducts,
+ );
+ }
+ }
+ } finally {
+ // Restore so any subsequent caller sees the event-wide snapshot.
+ $this->availableProductQuantities = $eventWideAvailability;
+ }
}
/**
@@ -67,7 +174,7 @@ private function validatePromoCode(int $eventId, array $data): void
PromoCodeDomainObjectAbstract::EVENT_ID => $eventId,
]);
- if (!$promoCode) {
+ if (! $promoCode) {
throw ValidationException::withMessages([
'promo_code' => __('This promo code is invalid'),
]);
@@ -83,8 +190,15 @@ private function validateTypes(array $data): void
$validator = Validator::make($data, [
'products' => 'required|array',
'products.*.product_id' => 'required|integer',
+ 'products.*.event_occurrence_id' => 'required|integer',
'products.*.quantities' => 'required|array',
- 'products.*.quantities.*.quantity' => 'required|integer',
+ // `min:0` blocks the mixed-tier exploit: without it, a single
+ // request with one tier at +2 and another at -1 sums to a positive
+ // selection but persists a negative order_item that distorts
+ // totals/stock and survives downstream availability checks
+ // (validateProductPricesQuantity only guards `quantity > available`,
+ // and `-1 > N` is false).
+ 'products.*.quantities.*.quantity' => 'required|integer|min:0',
'products.*.quantities.*.price_id' => 'required|integer',
'products.*.quantities.*.price' => 'numeric|min:0',
]);
@@ -100,35 +214,54 @@ private function validateTypes(array $data): void
private function validateProductSelection(array $data): void
{
$productData = collect($data['products']);
- if ($productData->isEmpty() || $productData->sum(fn($product) => collect($product['quantities'])->sum('quantity')) === 0) {
+ if ($productData->isEmpty() || $productData->sum(fn ($product) => collect($product['quantities'])->sum('quantity')) === 0) {
throw ValidationException::withMessages([
- 'products' => __('You haven\'t selected any products')
+ 'products' => __('You haven\'t selected any products'),
]);
}
}
/**
- * @throws Exception
+ * @throws ValidationException
*/
- private function getProducts(array $data): Collection
+ private function validateOccurrence(int $eventId, array $data): void
{
- $productIds = collect($data['products'])->pluck('product_id');
- return $this->productRepository
- ->loadRelation(ProductPriceDomainObject::class)
- ->findWhereIn('id', $productIds->toArray());
+ $productsByOccurrence = collect($data['products'])->groupBy('event_occurrence_id');
+
+ foreach ($productsByOccurrence as $occurrenceId => $products) {
+ if ($occurrenceId === null || $occurrenceId === '') {
+ throw ValidationException::withMessages([
+ 'event_occurrence_id' => __('An event occurrence must be specified'),
+ ]);
+ }
+
+ $totalQuantityRequested = (int) $products
+ ->sum(fn ($product) => collect($product['quantities'])->sum('quantity'));
+
+ $this->occurrenceEligibilityService->assertOccurrencePurchasable(
+ eventId: $eventId,
+ occurrenceId: (int) $occurrenceId,
+ additionalQuantity: $totalQuantityRequested,
+ );
+
+ $productIds = $products->pluck('product_id')->map(fn ($id) => (int) $id)->all();
+ $this->occurrenceEligibilityService->assertProductsVisibleOnOccurrence(
+ (int) $occurrenceId,
+ $productIds,
+ );
+ }
}
/**
- * @throws ValidationException
* @throws Exception
*/
- private function validateProductDetails(EventDomainObject $event, array $data): void
+ private function getProducts(array $data): Collection
{
- $products = $this->getProducts($data);
+ $productIds = collect($data['products'])->pluck('product_id');
- foreach ($data['products'] as $productIndex => $productAndQuantities) {
- $this->validateSingleProductDetails($event, $productIndex, $productAndQuantities, $products);
- }
+ return $this->productRepository
+ ->loadRelation(ProductPriceDomainObject::class)
+ ->findWhereIn('id', $productIds->toArray());
}
/**
@@ -144,8 +277,8 @@ private function validateSingleProductDetails(EventDomainObject $event, int $pro
}
/** @var ProductDomainObject $product */
- $product = $products->filter(fn($t) => $t->getId() === $productId)->first();
- if (!$product) {
+ $product = $products->filter(fn ($t) => $t->getId() === $productId)->first();
+ if (! $product) {
throw new NotFoundHttpException(sprintf('Product ID %d not found', $productId));
}
@@ -187,22 +320,22 @@ private function validateSingleProductDetails(EventDomainObject $event, int $pro
private function validateProductQuantity(int $productIndex, array $productAndQuantities, ProductDomainObject $product): void
{
$totalQuantity = collect($productAndQuantities['quantities'])->sum('quantity');
- $maxPerOrder = (int)$product->getMaxPerOrder() ?: 100;
+ $maxPerOrder = (int) $product->getMaxPerOrder() ?: 100;
$capacityMaximum = $this->availableProductQuantities
->productQuantities
->where('product_id', $product->getId())
- ->map(fn(AvailableProductQuantitiesDTO $price) => $price->capacities)
+ ->map(fn (AvailableProductQuantitiesDTO $price) => $price->capacities)
->flatten()
- ->min(fn(CapacityAssignmentDomainObject $capacity) => $capacity->getCapacity());
+ ->min(fn (CapacityAssignmentDomainObject $capacity) => $capacity->getCapacity());
$productAvailableQuantity = $this->availableProductQuantities
->productQuantities
- ->first(fn(AvailableProductQuantitiesDTO $price) => $price->product_id === $product->getId())
+ ->first(fn (AvailableProductQuantitiesDTO $price) => $price->product_id === $product->getId())
->quantity_available;
- # if there are fewer products available than the configured minimum, we allow less than the minimum to be purchased
- $minPerOrder = min((int)$product->getMinPerOrder() ?: 1,
+ // if there are fewer products available than the configured minimum, we allow less than the minimum to be purchased
+ $minPerOrder = min((int) $product->getMinPerOrder() ?: 1,
$capacityMaximum ?: $maxPerOrder,
$productAvailableQuantity ?: $maxPerOrder);
@@ -214,7 +347,7 @@ private function validateProductQuantity(int $productIndex, array $productAndQua
if ($totalQuantity > $maxPerOrder) {
throw ValidationException::withMessages([
- "products.$productIndex" => __("The maximum number of products available for :products is :max", [
+ "products.$productIndex" => __('The maximum number of products available for :products is :max', [
'max' => $maxPerOrder,
'product' => $product->getTitle(),
]),
@@ -223,7 +356,7 @@ private function validateProductQuantity(int $productIndex, array $productAndQua
if ($totalQuantity < $minPerOrder) {
throw ValidationException::withMessages([
- "products.$productIndex" => __("You must order at least :min products for :product", [
+ "products.$productIndex" => __('You must order at least :min products for :product', [
'min' => $minPerOrder,
'product' => $product->getTitle(),
]),
@@ -242,18 +375,17 @@ private function validateProductEvent(EventDomainObject $event, int $productId,
* @throws ValidationException
*/
private function validateProductTypeAndPrice(
- EventDomainObject $event,
- int $productIndex,
- array $productAndQuantities,
+ EventDomainObject $event,
+ int $productIndex,
+ array $productAndQuantities,
ProductDomainObject $product
- ): void
- {
+ ): void {
if ($product->getType() === ProductPriceType::DONATION->name) {
$price = $productAndQuantities['quantities'][0]['price'] ?? 0;
if ($price < $product->getPrice()) {
$formattedPrice = Currency::format($product->getPrice(), $event->getCurrency());
throw ValidationException::withMessages([
- "products.$productIndex.quantities.0.price" => __("The minimum amount is :price", ['price' => $formattedPrice]),
+ "products.$productIndex.quantities.0.price" => __('The minimum amount is :price', ['price' => $formattedPrice]),
]);
}
}
@@ -266,7 +398,7 @@ private function validateSoldOutProducts(int $productId, int $productIndex, Prod
{
if ($product->isSoldOut()) {
throw ValidationException::withMessages([
- "products.$productIndex" => __("The product :product is sold out", [
+ "products.$productIndex" => __('The product :product is sold out', [
'id' => $productId,
'product' => $product->getTitle(),
]),
@@ -285,20 +417,20 @@ private function validatePriceIdAndQuantity(int $productIndex, array $productAnd
$priceId = $quantityData['price_id'] ?? null;
$quantity = $quantityData['quantity'] ?? null;
- if (null === $priceId || null === $quantity) {
- $missingField = null === $priceId ? 'price_id' : 'quantity';
- $errors["products.$productIndex.quantities.$quantityIndex.$missingField"] = __(":field must be specified", [
- 'field' => ucfirst($missingField)
+ if ($priceId === null || $quantity === null) {
+ $missingField = $priceId === null ? 'price_id' : 'quantity';
+ $errors["products.$productIndex.quantities.$quantityIndex.$missingField"] = __(':field must be specified', [
+ 'field' => ucfirst($missingField),
]);
}
- $validPriceIds = $product->getProductPrices()?->map(fn(ProductPriceDomainObject $price) => $price->getId());
- if (!in_array($priceId, $validPriceIds->toArray(), true)) {
+ $validPriceIds = $product->getProductPrices()?->map(fn (ProductPriceDomainObject $price) => $price->getId());
+ if (! in_array($priceId, $validPriceIds->toArray(), true)) {
$errors["products.$productIndex.quantities.$quantityIndex.price_id"] = __('Invalid price ID');
}
}
- if (!empty($errors)) {
+ if (! empty($errors)) {
throw ValidationException::withMessages($errors);
}
}
@@ -321,21 +453,21 @@ private function validateProductPricesQuantity(array $quantities, ProductDomainO
/** @var ProductPriceDomainObject $productPrice */
$productPrice = $product->getProductPrices()
- ?->first(fn(ProductPriceDomainObject $price) => $price->getId() === $productQuantity['price_id']);
+ ?->first(fn (ProductPriceDomainObject $price) => $price->getId() === $productQuantity['price_id']);
if ($productQuantity['quantity'] > $numberAvailable) {
if ($numberAvailable === 0) {
throw ValidationException::withMessages([
- "products.$productIndex" => __("The product :product is sold out", [
- 'product' => $product->getTitle() . ($productPrice->getLabel() ? ' - ' . $productPrice->getLabel() : ''),
+ "products.$productIndex" => __('The product :product is sold out', [
+ 'product' => $product->getTitle().($productPrice->getLabel() ? ' - '.$productPrice->getLabel() : ''),
]),
]);
}
throw ValidationException::withMessages([
- "products.$productIndex" => __("The maximum number of products available for :product is :max", [
+ "products.$productIndex" => __('The maximum number of products available for :product is :max', [
'max' => $numberAvailable,
- 'product' => $product->getTitle() . ($productPrice->getLabel() ? ' - ' . $productPrice->getLabel() : ''),
+ 'product' => $product->getTitle().($productPrice->getLabel() ? ' - '.$productPrice->getLabel() : ''),
]),
]);
}
@@ -345,20 +477,29 @@ private function validateProductPricesQuantity(array $quantities, ProductDomainO
/**
* @throws ValidationException
*/
- private function validateOverallCapacity(array $data): void
+ private function validateOverallCapacity(EventDomainObject $event, array $data): void
{
+ // Capacity assignments are deliberately not enforced for recurring
+ // events — capacity is modelled per-occurrence on the recurrence side
+ // and the assignment itself spans every session, which makes a single
+ // shared total meaningless for a series. Per-occurrence capacity is
+ // still validated in OccurrencePurchaseEligibilityService.
+ if ($event->isRecurring()) {
+ return;
+ }
+
foreach ($this->availableProductQuantities->capacities as $capacity) {
if ($capacity->getProducts() === null) {
continue;
}
- $productIds = $capacity->getProducts()->map(fn(ProductDomainObject $product) => $product->getId());
+ $productIds = $capacity->getProducts()->map(fn (ProductDomainObject $product) => $product->getId());
$totalQuantity = collect($data['products'])
- ->filter(fn($product) => in_array($product['product_id'], $productIds->toArray(), true))
- ->sum(fn($product) => collect($product['quantities'])->sum('quantity'));
+ ->filter(fn ($product) => in_array($product['product_id'], $productIds->toArray(), true))
+ ->sum(fn ($product) => collect($product['quantities'])->sum('quantity'));
$reservedProductQuantities = $capacity->getProducts()
- ->map(fn(ProductDomainObject $product) => $this
+ ->map(fn (ProductDomainObject $product) => $this
->availableProductQuantities
->productQuantities
->where('product_id', $product->getId())
diff --git a/backend/app/Services/Domain/Order/OrderItemProcessingService.php b/backend/app/Services/Domain/Order/OrderItemProcessingService.php
index f34ccd50ff..bb8b122f47 100644
--- a/backend/app/Services/Domain/Order/OrderItemProcessingService.php
+++ b/backend/app/Services/Domain/Order/OrderItemProcessingService.php
@@ -2,19 +2,19 @@
namespace HiEvents\Services\Domain\Order;
-use HiEvents\DomainObjects\AccountConfigurationDomainObject;
use HiEvents\DomainObjects\Enums\TaxCalculationType;
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract;
use HiEvents\DomainObjects\OrderDomainObject;
+use HiEvents\DomainObjects\OrganizerConfigurationDomainObject;
+use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\DomainObjects\PromoCodeDomainObject;
use HiEvents\DomainObjects\TaxAndFeesDomainObject;
use HiEvents\Helper\Currency;
use HiEvents\Repository\Eloquent\Value\Relationship;
-use HiEvents\Repository\Interfaces\AccountRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
@@ -27,7 +27,7 @@
class OrderItemProcessingService
{
- private ?AccountConfigurationDomainObject $accountConfiguration = null;
+ private ?OrganizerConfigurationDomainObject $organizerConfiguration = null;
private ?EventSettingDomainObject $eventSettings = null;
public function __construct(
@@ -36,7 +36,6 @@ public function __construct(
private readonly TaxAndFeeCalculationService $taxCalculationService,
private readonly ProductPriceService $productPriceService,
private readonly OrderPlatformFeePassThroughService $platformFeeService,
- private readonly AccountRepositoryInterface $accountRepository,
private readonly EventRepositoryInterface $eventRepository,
)
{
@@ -53,7 +52,7 @@ public function process(
OrderDomainObject $order,
Collection $productsOrderDetails,
EventDomainObject $event,
- ?PromoCodeDomainObject $promoCode
+ ?PromoCodeDomainObject $promoCode,
): Collection
{
$this->loadPlatformFeeConfiguration($event->getId());
@@ -75,11 +74,13 @@ public function process(
);
}
- $productOrderDetail->quantities->each(function (OrderProductPriceDTO $productPrice) use ($promoCode, $order, $orderItems, $product, $event) {
+ $eventOccurrenceId = $productOrderDetail->event_occurrence_id;
+
+ $productOrderDetail->quantities->each(function (OrderProductPriceDTO $productPrice) use ($promoCode, $order, $orderItems, $product, $event, $eventOccurrenceId) {
if ($productPrice->quantity === 0) {
return;
}
- $orderItemData = $this->calculateOrderItemData($product, $productPrice, $order, $promoCode, $event->getCurrency());
+ $orderItemData = $this->calculateOrderItemData($product, $productPrice, $order, $promoCode, $event->getCurrency(), $eventOccurrenceId);
$orderItems->push($this->orderRepository->addOrderItem($orderItemData));
});
}
@@ -89,20 +90,22 @@ public function process(
private function loadPlatformFeeConfiguration(int $eventId): void
{
- $account = $this->accountRepository
- ->loadRelation(new Relationship(
- domainObject: AccountConfigurationDomainObject::class,
- name: 'configuration',
- ))
- ->findByEventId($eventId);
-
- $this->accountConfiguration = $account->getConfiguration();
-
$event = $this->eventRepository
->loadRelation(EventSettingDomainObject::class)
+ ->loadRelation(new Relationship(
+ domainObject: OrganizerDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: OrganizerConfigurationDomainObject::class,
+ name: 'organizer_configuration',
+ ),
+ ],
+ name: 'organizer',
+ ))
->findById($eventId);
$this->eventSettings = $event->getEventSettings();
+ $this->organizerConfiguration = $event->getOrganizer()?->getOrganizerConfiguration();
}
private function calculateOrderItemData(
@@ -110,10 +113,11 @@ private function calculateOrderItemData(
OrderProductPriceDTO $productPriceDetails,
OrderDomainObject $order,
?PromoCodeDomainObject $promoCode,
- string $currency
+ string $currency,
+ ?int $eventOccurrenceId = null,
): array
{
- $prices = $this->productPriceService->getPrice($product, $productPriceDetails, $promoCode);
+ $prices = $this->productPriceService->getPrice($product, $productPriceDetails, $promoCode, $eventOccurrenceId);
$priceWithDiscount = $prices->price;
$priceBeforeDiscount = $prices->price_before_discount;
@@ -156,17 +160,18 @@ private function calculateOrderItemData(
'total_service_fee' => $totalFee,
'total_gross' => $totalGross,
'taxes_and_fees_rollup' => $rollUp,
+ 'event_occurrence_id' => $eventOccurrenceId,
];
}
private function calculatePlatformFee(float $total, int $quantity, string $currency): float
{
- if ($this->accountConfiguration === null || $this->eventSettings === null) {
+ if ($this->organizerConfiguration === null || $this->eventSettings === null) {
return 0.0;
}
return $this->platformFeeService->calculatePlatformFee(
- $this->accountConfiguration,
+ $this->organizerConfiguration,
$this->eventSettings,
$total,
$quantity,
diff --git a/backend/app/Services/Domain/Order/OrderPlatformFeePassThroughService.php b/backend/app/Services/Domain/Order/OrderPlatformFeePassThroughService.php
index 7f87def40b..df85a228b6 100644
--- a/backend/app/Services/Domain/Order/OrderPlatformFeePassThroughService.php
+++ b/backend/app/Services/Domain/Order/OrderPlatformFeePassThroughService.php
@@ -3,7 +3,7 @@
namespace HiEvents\Services\Domain\Order;
use Brick\Money\Currency as BrickCurrency;
-use HiEvents\DomainObjects\AccountConfigurationDomainObject;
+use HiEvents\DomainObjects\OrganizerConfigurationDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\Helper\Currency;
use HiEvents\Services\Infrastructure\CurrencyConversion\CurrencyConversionClientInterface;
@@ -45,7 +45,7 @@ public function isEnabled(EventSettingDomainObject $eventSettings): bool
* In other words: application_fee(total + P) = P
*/
public function calculatePlatformFee(
- AccountConfigurationDomainObject $accountConfiguration,
+ OrganizerConfigurationDomainObject $organizerConfiguration,
EventSettingDomainObject $eventSettings,
float $total,
int $quantity,
@@ -56,8 +56,8 @@ public function calculatePlatformFee(
return 0.0;
}
- $fixedFee = $this->getConvertedFixedFee($accountConfiguration, $currency);
- $percentageRate = $accountConfiguration->getPercentageApplicationFee() / 100;
+ $fixedFee = $this->getConvertedFixedFee($organizerConfiguration, $currency);
+ $percentageRate = $organizerConfiguration->getPercentageApplicationFee() / 100;
if ($percentageRate >= 1) {
return Currency::round(($fixedFee * $quantity) + ($total * $percentageRate));
@@ -70,12 +70,12 @@ public function calculatePlatformFee(
}
private function getConvertedFixedFee(
- AccountConfigurationDomainObject $accountConfiguration,
+ OrganizerConfigurationDomainObject $organizerConfiguration,
string $currency
): float
{
- $baseFee = $accountConfiguration->getFixedApplicationFee();
- $baseCurrency = $accountConfiguration->getApplicationFeeCurrency();
+ $baseFee = $organizerConfiguration->getFixedApplicationFee();
+ $baseCurrency = $organizerConfiguration->getApplicationFeeCurrency();
if ($currency === $baseCurrency) {
return $baseFee;
diff --git a/backend/app/Services/Domain/Order/Vat/VatRateDeterminationService.php b/backend/app/Services/Domain/Order/Vat/VatRateDeterminationService.php
index 22b5213273..f75a2401c2 100644
--- a/backend/app/Services/Domain/Order/Vat/VatRateDeterminationService.php
+++ b/backend/app/Services/Domain/Order/Vat/VatRateDeterminationService.php
@@ -2,8 +2,8 @@
namespace HiEvents\Services\Domain\Order\Vat;
-use HiEvents\DomainObjects\AccountVatSettingDomainObject;
use HiEvents\DomainObjects\Enums\CountryCode;
+use HiEvents\DomainObjects\OrganizerVatSettingDomainObject;
use Illuminate\Config\Repository;
use ValueError;
@@ -21,7 +21,7 @@ public function __construct(
$this->defaultVatCountry = $this->config->get('app.tax.default_vat_country', CountryCode::IE->value);
}
- public function determineVatRatePercentage(AccountVatSettingDomainObject $vatSetting): float
+ public function determineVatRatePercentage(OrganizerVatSettingDomainObject $vatSetting): float
{
$country = $vatSetting->getVatCountryCode();
diff --git a/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentRequestDTO.php b/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentRequestDTO.php
index d6fdb9ea3e..5536db99cd 100644
--- a/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentRequestDTO.php
+++ b/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentRequestDTO.php
@@ -4,20 +4,22 @@
use HiEvents\DataTransferObjects\BaseDTO;
use HiEvents\DomainObjects\AccountDomainObject;
-use HiEvents\DomainObjects\AccountVatSettingDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
+use HiEvents\DomainObjects\OrganizerConfigurationDomainObject;
+use HiEvents\DomainObjects\OrganizerVatSettingDomainObject;
use HiEvents\Values\MoneyValue;
class CreatePaymentIntentRequestDTO extends BaseDTO
{
public function __construct(
- public readonly MoneyValue $amount,
- public readonly string $currencyCode,
- public readonly AccountDomainObject $account,
- public readonly OrderDomainObject $order,
- public readonly ?string $stripeAccountId = null,
- public readonly ?AccountVatSettingDomainObject $vatSettings = null,
- public readonly ?string $description = null,
+ public readonly MoneyValue $amount,
+ public readonly string $currencyCode,
+ public readonly AccountDomainObject $account,
+ public readonly OrderDomainObject $order,
+ public readonly ?OrganizerConfigurationDomainObject $configuration = null,
+ public readonly ?string $stripeAccountId = null,
+ public readonly ?OrganizerVatSettingDomainObject $vatSettings = null,
+ public readonly ?string $description = null,
)
{
}
diff --git a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/AccountUpdateHandler.php b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/AccountUpdateHandler.php
index 98fbfaa1b8..6d4a6efbb7 100644
--- a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/AccountUpdateHandler.php
+++ b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/AccountUpdateHandler.php
@@ -2,35 +2,19 @@
namespace HiEvents\Services\Domain\Payment\Stripe\EventHandlers;
-use HiEvents\DomainObjects\AccountStripePlatformDomainObject;
-use HiEvents\DomainObjects\Generated\AccountStripePlatformDomainObjectAbstract;
-use HiEvents\Repository\Interfaces\AccountStripePlatformRepositoryInterface;
use HiEvents\Services\Domain\Payment\Stripe\StripeAccountSyncService;
use Stripe\Account;
-use Symfony\Component\Routing\Exception\ResourceNotFoundException;
class AccountUpdateHandler
{
public function __construct(
- private readonly AccountStripePlatformRepositoryInterface $accountStripePlatformRepository,
- private readonly StripeAccountSyncService $stripeAccountSyncService,
+ private readonly StripeAccountSyncService $stripeAccountSyncService,
)
{
}
public function handleEvent(Account $stripeAccount): void
{
- /** @var AccountStripePlatformDomainObject $accountStripePlatform */
- $accountStripePlatform = $this->accountStripePlatformRepository->findFirstWhere([
- AccountStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id,
- ]);
-
- if ($accountStripePlatform === null) {
- throw new ResourceNotFoundException(
- sprintf('Account stripe platform with stripe account id %s not found', $stripeAccount->id)
- );
- }
-
- $this->stripeAccountSyncService->syncStripeAccountStatus($accountStripePlatform, $stripeAccount);
+ $this->stripeAccountSyncService->syncStripeAccountStatusByAccountId($stripeAccount);
}
}
diff --git a/backend/app/Services/Domain/Payment/Stripe/StripeAccountSyncService.php b/backend/app/Services/Domain/Payment/Stripe/StripeAccountSyncService.php
index 47c4881700..89be87154b 100644
--- a/backend/app/Services/Domain/Payment/Stripe/StripeAccountSyncService.php
+++ b/backend/app/Services/Domain/Payment/Stripe/StripeAccountSyncService.php
@@ -2,14 +2,15 @@
namespace HiEvents\Services\Domain\Payment\Stripe;
-use HiEvents\DomainObjects\AccountStripePlatformDomainObject;
use HiEvents\DomainObjects\Enums\CountryCode;
-use HiEvents\DomainObjects\Generated\AccountStripePlatformDomainObjectAbstract;
-use HiEvents\DomainObjects\Generated\AccountVatSettingDomainObjectAbstract;
+use HiEvents\DomainObjects\Generated\OrganizerStripePlatformDomainObjectAbstract;
+use HiEvents\DomainObjects\Generated\OrganizerVatSettingDomainObjectAbstract;
+use HiEvents\DomainObjects\OrganizerStripePlatformDomainObject;
use HiEvents\Helper\Url;
use HiEvents\Repository\Interfaces\AccountRepositoryInterface;
-use HiEvents\Repository\Interfaces\AccountStripePlatformRepositoryInterface;
-use HiEvents\Repository\Interfaces\AccountVatSettingRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrganizerStripePlatformRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrganizerVatSettingRepositoryInterface;
use Illuminate\Config\Repository;
use Psr\Log\LoggerInterface;
use Stripe\Account;
@@ -19,95 +20,185 @@
class StripeAccountSyncService
{
public function __construct(
- private readonly LoggerInterface $logger,
- private readonly AccountRepositoryInterface $accountRepository,
- private readonly AccountStripePlatformRepositoryInterface $accountStripePlatformRepository,
- private readonly AccountVatSettingRepositoryInterface $vatSettingRepository,
- private readonly Repository $config,
+ private readonly LoggerInterface $logger,
+ private readonly AccountRepositoryInterface $accountRepository,
+ private readonly OrganizerRepositoryInterface $organizerRepository,
+ private readonly OrganizerStripePlatformRepositoryInterface $organizerStripePlatformRepository,
+ private readonly OrganizerVatSettingRepositoryInterface $vatSettingRepository,
+ private readonly Repository $config,
)
{
}
- /**
- * Sync Stripe account status and details to our database
- */
- public function syncStripeAccountStatus(
- AccountStripePlatformDomainObject $accountStripePlatform,
- Account $stripeAccount
- ): void
+ public function isStripeAccountComplete(Account $stripeAccount): bool
{
- $isAccountSetupCompleted = $this->isStripeAccountComplete($stripeAccount);
- $isCurrentlyComplete = $accountStripePlatform->getStripeSetupCompletedAt() !== null;
+ return $stripeAccount->charges_enabled && $stripeAccount->payouts_enabled;
+ }
- // Only update if status has actually changed
- if ($isCurrentlyComplete === $isAccountSetupCompleted) {
- // Still update account details even if status hasn't changed
- $this->updateAccountDetails($stripeAccount);
- return;
- }
+ public function createStripeAccountSetupUrl(Account $stripeAccount, StripeClient $stripeClient, int $organizerId): ?string
+ {
+ try {
+ $refreshUrl = sprintf(Url::getFrontEndUrlFromConfig(Url::STRIPE_CONNECT_REFRESH_URL), $organizerId);
+ $returnUrl = sprintf(Url::getFrontEndUrlFromConfig(Url::STRIPE_CONNECT_RETURN_URL), $organizerId);
+
+ $accountLink = $stripeClient->accountLinks->create([
+ 'account' => $stripeAccount->id,
+ 'refresh_url' => $this->appendQueryParam($refreshUrl, 'is_refresh=1'),
+ 'return_url' => $this->appendQueryParam($returnUrl, 'is_return=1'),
+ 'type' => 'account_onboarding',
+ ]);
- if ($isAccountSetupCompleted) {
- $this->markAccountAsComplete($accountStripePlatform, $stripeAccount);
- } else {
- $this->logger->info(sprintf(
- 'Stripe Connect account is no longer complete. Updating account stripe platform %s',
- $stripeAccount->id
- ));
- $this->updateAccountStatusAndDetails($stripeAccount, isAccountSetupCompleted: false);
- $this->updateAccountDetails($stripeAccount);
+ return $accountLink->url;
+ } catch (Throwable $e) {
+ $this->logger->error('Failed to create Stripe Connect Account Link', [
+ 'stripe_account_id' => $stripeAccount->id,
+ 'organizer_id' => $organizerId,
+ 'error' => $e->getMessage(),
+ ]);
+ return null;
}
}
/**
- * Force update account status when we know it should be complete
- * (e.g., from GetStripeConnectAccountsHandler when Stripe says complete but DB doesn't)
- * @throws NoStripeCountryCodeException
+ * Insert the query param BEFORE the URL's fragment so the resulting URL is well-formed.
+ * Naive concatenation breaks when the configured return URL ends with #fragment
+ * (e.g. /manage/organizer/%d/settings#payouts) — the param lands inside the hash.
*/
- public function markAccountAsComplete(
- AccountStripePlatformDomainObject $accountStripePlatform,
- Account $stripeAccount
- ): void
+ private function appendQueryParam(string $url, string $param): string
{
- $this->logger->info(sprintf(
- 'Marking Stripe Connect account as complete for account stripe platform %s with Stripe account ID %s',
- $accountStripePlatform->getId(),
- $stripeAccount->id
- ));
+ $hashPosition = strpos($url, '#');
+ $base = $hashPosition === false ? $url : substr($url, 0, $hashPosition);
+ $fragment = $hashPosition === false ? '' : substr($url, $hashPosition);
+ $separator = str_contains($base, '?') ? '&' : '?';
- $this->updateAccountStatusAndDetails($stripeAccount, isAccountSetupCompleted: true);
- $this->updateAccountCountryAndVerificationStatus($accountStripePlatform, $stripeAccount);
- $this->createVatSettingIfMissing($accountStripePlatform);
+ return $base . $separator . $param . $fragment;
}
- public function isStripeAccountComplete(Account $stripeAccount): bool
+ /**
+ * Webhook entrypoint — updates every organizer row sharing this Stripe account
+ * and seeds account-level country/VAT state for every organizer that owns one.
+ */
+ public function syncStripeAccountStatusByAccountId(Account $stripeAccount): void
{
- return $stripeAccount->charges_enabled && $stripeAccount->payouts_enabled;
+ $details = $this->buildAccountDetails($stripeAccount);
+ $isAccountSetupCompleted = $this->isStripeAccountComplete($stripeAccount);
+
+ $this->organizerStripePlatformRepository->updateWhere(
+ attributes: [
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_SETUP_COMPLETED_AT => $isAccountSetupCompleted ? now() : null,
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_DETAILS => $details,
+ ],
+ where: [
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id,
+ ]
+ );
+
+ if (!$isAccountSetupCompleted) {
+ return;
+ }
+
+ $organizerRows = $this->organizerStripePlatformRepository->findWhere([
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id,
+ ]);
+
+ foreach ($organizerRows as $organizerRow) {
+ $this->updateOrganizerCountryAndVerificationStatus($organizerRow, $stripeAccount);
+ $this->seedVatSettingForOrganizerIfMissing(
+ organizerId: $organizerRow->getOrganizerId(),
+ countryCode: $stripeAccount->country,
+ stripeAccountId: $stripeAccount->id,
+ organizerStripePlatformId: $organizerRow->getId(),
+ );
+ }
}
- private function updateAccountStatusAndDetails(
- Account $stripeAccount,
- bool $isAccountSetupCompleted
+ public function markAccountAsCompleteForOrganizer(
+ OrganizerStripePlatformDomainObject $organizerStripePlatform,
+ Account $stripeAccount,
): void
{
- $this->accountStripePlatformRepository->updateWhere(
+ $this->logger->info(sprintf(
+ 'Marking Stripe Connect account as complete for organizer stripe platform %s with Stripe account ID %s',
+ $organizerStripePlatform->getId(),
+ $stripeAccount->id,
+ ));
+
+ $this->organizerStripePlatformRepository->updateWhere(
attributes: [
- AccountStripePlatformDomainObjectAbstract::STRIPE_SETUP_COMPLETED_AT => $isAccountSetupCompleted ? now() : null,
- AccountStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_DETAILS => $this->buildAccountDetails($stripeAccount),
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_SETUP_COMPLETED_AT => now(),
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_DETAILS => $this->buildAccountDetails($stripeAccount),
],
where: [
- AccountStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id,
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id,
]
);
+
+ $this->updateOrganizerCountryAndVerificationStatus($organizerStripePlatform, $stripeAccount);
+ $this->seedVatSettingForOrganizerIfMissing(
+ organizerId: $organizerStripePlatform->getOrganizerId(),
+ countryCode: $stripeAccount->country,
+ stripeAccountId: $stripeAccount->id,
+ organizerStripePlatformId: $organizerStripePlatform->getId(),
+ );
}
- private function updateAccountDetails(Account $stripeAccount): void
+ /**
+ * Seeds an empty organizer VAT setting row when an organizer first connects
+ * (or copies a connection) to a Stripe account in an EU country.
+ * Public so the copy/reuse flow can call it with a country code parsed
+ * from cached stripe_account_details rather than a live Stripe Account.
+ */
+ public function seedVatSettingForOrganizerIfMissing(
+ int $organizerId,
+ ?string $countryCode,
+ ?string $stripeAccountId = null,
+ ?int $organizerStripePlatformId = null,
+ ): void
{
- $this->accountStripePlatformRepository->updateWhere(
+ if (!$this->config->get('app.saas_mode_enabled')) {
+ return;
+ }
+
+ if ($this->config->get('app.tax.eu_vat_handling_enabled') !== true) {
+ return;
+ }
+
+ if (!$countryCode) {
+ $this->logger->error('Stripe account country code is missing, cannot create VAT setting.', [
+ 'organizer_id' => $organizerId,
+ 'organizer_stripe_platform_id' => $organizerStripePlatformId,
+ 'stripe_account_id' => $stripeAccountId,
+ ]);
+ return;
+ }
+
+ $countryCode = strtoupper($countryCode);
+ if (!CountryCode::isEuCountry(CountryCode::from($countryCode))) {
+ return;
+ }
+
+ $existingVatSetting = $this->vatSettingRepository->findByOrganizerId($organizerId);
+
+ if ($existingVatSetting === null) {
+ $this->vatSettingRepository->create([
+ OrganizerVatSettingDomainObjectAbstract::ORGANIZER_ID => $organizerId,
+ OrganizerVatSettingDomainObjectAbstract::VAT_VALIDATED => false,
+ OrganizerVatSettingDomainObjectAbstract::VAT_COUNTRY_CODE => $countryCode,
+ ]);
+ }
+ }
+
+ public function syncStripeAccountDetailsForOrganizer(
+ OrganizerStripePlatformDomainObject $organizerStripePlatform,
+ Account $stripeAccount,
+ ): void
+ {
+ $this->organizerStripePlatformRepository->updateWhere(
attributes: [
- AccountStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_DETAILS => $this->buildAccountDetails($stripeAccount),
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_DETAILS => $this->buildAccountDetails($stripeAccount),
],
where: [
- AccountStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id,
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id,
]
);
}
@@ -134,36 +225,20 @@ private function buildAccountDetails(Account $stripeAccount): string
], JSON_THROW_ON_ERROR);
}
- public function createStripeAccountSetupUrl(Account $stripeAccount, StripeClient $stripeClient): ?string
+ private function updateOrganizerCountryAndVerificationStatus(
+ OrganizerStripePlatformDomainObject $organizerStripePlatform,
+ Account $stripeAccount,
+ ): void
{
- try {
- $accountLink = $stripeClient->accountLinks->create([
- 'account' => $stripeAccount->id,
- 'refresh_url' => Url::getFrontEndUrlFromConfig(Url::STRIPE_CONNECT_REFRESH_URL, [
- 'is_refresh' => true,
- ]),
- 'return_url' => Url::getFrontEndUrlFromConfig(Url::STRIPE_CONNECT_RETURN_URL, [
- 'is_return' => true,
- ]),
- 'type' => 'account_onboarding',
- ]);
-
- return $accountLink->url;
- } catch (Throwable $e) {
- $this->logger->error('Failed to create Stripe Connect Account Link', [
- 'stripe_account_id' => $stripeAccount->id,
- 'error' => $e->getMessage(),
- ]);
- return null;
+ $organizer = $this->organizerRepository->findById($organizerStripePlatform->getOrganizerId());
+ if ($organizer === null) {
+ return;
}
- }
- private function updateAccountCountryAndVerificationStatus(
- AccountStripePlatformDomainObject $accountStripePlatform,
- Account $stripeAccount,
- ): void
- {
- $account = $this->accountRepository->findById($accountStripePlatform->getAccountId());
+ $account = $this->accountRepository->findById($organizer->getAccountId());
+ if ($account === null) {
+ return;
+ }
$updates = [];
if (!$account->getCountry()) {
@@ -178,58 +253,10 @@ private function updateAccountCountryAndVerificationStatus(
$this->accountRepository->updateWhere(
attributes: $updates,
where: [
- 'id' => $accountStripePlatform->getAccountId(),
+ 'id' => $account->getId(),
]
);
}
}
- /**
- * @throws NoStripeCountryCodeException
- */
- private function createVatSettingIfMissing(AccountStripePlatformDomainObject $accountStripePlatform): void
- {
- if ($this->config->get('app.tax.eu_vat_handling_enabled') !== true) {
- $this->logger->info('EU VAT handling is disabled, skipping VAT setting creation.', [
- 'account_stripe_platform_id' => $accountStripePlatform->getId(),
- 'account_id' => $accountStripePlatform->getAccountId(),
- ]);
- return;
- }
-
- $countryCode = $accountStripePlatform->getStripeAccountDetails()['country'];
-
- if ($countryCode === null) {
- $this->logger->error('Stripe account country code is missing, cannot create VAT setting.', [
- 'account_stripe_platform_id' => $accountStripePlatform->getId(),
- 'account_id' => $accountStripePlatform->getAccountId(),
- ]);
-
- throw new NoStripeCountryCodeException('Stripe account country code is missing. cannot create VAT setting.',
- accountStripePlatformId: $accountStripePlatform->getId(),
- accountId: $accountStripePlatform->getAccountId()
- );
- }
-
- if (!CountryCode::isEuCountry(CountryCode::from($countryCode))) {
- $this->logger->info('Account is not in an EU country, skipping VAT setting creation.', [
- 'account_stripe_platform_id' => $accountStripePlatform->getId(),
- 'account_id' => $accountStripePlatform->getAccountId(),
- 'country_code' => $countryCode,
- ]);
- return;
- }
-
- $existingVatSetting = $this->vatSettingRepository->findFirstWhere([
- AccountVatSettingDomainObjectAbstract::ACCOUNT_ID => $accountStripePlatform->getAccountId(),
- ]);
-
- if ($existingVatSetting === null) {
- $this->vatSettingRepository->create([
- AccountVatSettingDomainObjectAbstract::ACCOUNT_ID => $accountStripePlatform->getAccountId(),
- AccountVatSettingDomainObjectAbstract::VAT_VALIDATED => false,
- AccountVatSettingDomainObjectAbstract::VAT_COUNTRY_CODE => $countryCode,
- ]);
- }
- }
}
diff --git a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php
index 74351891d0..9c043b4047 100644
--- a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php
+++ b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php
@@ -66,14 +66,16 @@ public function createPaymentIntentWithClient(
try {
$this->databaseManager->beginTransaction();
- $accountConfiguration = $paymentIntentDTO->account->getConfiguration();
- $bypassApplicationFees = $accountConfiguration?->getBypassApplicationFees() ?? false;
-
- $applicationFee = $this->orderApplicationFeeCalculationService->calculateApplicationFee(
- accountConfiguration: $accountConfiguration,
- order: $paymentIntentDTO->order,
- vatSettings: $paymentIntentDTO->vatSettings,
- );
+ $configuration = $paymentIntentDTO->configuration;
+ $bypassApplicationFees = $configuration?->getBypassApplicationFees() ?? false;
+
+ $applicationFee = $configuration
+ ? $this->orderApplicationFeeCalculationService->calculateApplicationFee(
+ configuration: $configuration,
+ order: $paymentIntentDTO->order,
+ vatSettings: $paymentIntentDTO->vatSettings,
+ )
+ : null;
$paymentIntent = $stripeClient->paymentIntents->create([
'amount' => $paymentIntentDTO->amount->toMinorUnit(),
diff --git a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php
index 23946a6764..609b13a9af 100644
--- a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php
+++ b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php
@@ -14,28 +14,41 @@
class StripePaymentIntentRefundService
{
public function __construct(
- private readonly Repository $config,
- )
- {
- }
+ private readonly Repository $config,
+ ) {}
/**
* @throws ApiErrorException
* @throws MathException
+ *
* @todo - catch and handle stripe errors
*/
public function refundPayment(
- MoneyValue $amount,
+ MoneyValue $amount,
StripePaymentDomainObject $payment,
- StripeClient $stripeClient,
- ): Refund
- {
+ StripeClient $stripeClient,
+ ): Refund {
+ // Stable idempotency key prevents a duplicate refund when Stripe processes
+ // the call but the response is lost (network timeout, worker crash). A
+ // retry with the same params hits Stripe's idempotency cache; a partial
+ // refund for a different amount differs in the key and is allowed.
+ $opts = array_merge(
+ $this->getStripeAccountData($payment),
+ [
+ 'idempotency_key' => sprintf(
+ 'refund_payment_%d_amount_%d',
+ $payment->getId(),
+ $amount->toMinorUnit(),
+ ),
+ ],
+ );
+
return $stripeClient->refunds->create(
params: [
'payment_intent' => $payment->getPaymentIntentId(),
- 'amount' => $amount->toMinorUnit()
+ 'amount' => $amount->toMinorUnit(),
],
- opts: $this->getStripeAccountData($payment),
+ opts: $opts,
);
}
diff --git a/backend/app/Services/Domain/Product/AvailableProductQuantitiesFetchService.php b/backend/app/Services/Domain/Product/AvailableProductQuantitiesFetchService.php
index d9c8869ea3..d5398200d7 100644
--- a/backend/app/Services/Domain/Product/AvailableProductQuantitiesFetchService.php
+++ b/backend/app/Services/Domain/Product/AvailableProductQuantitiesFetchService.php
@@ -5,10 +5,12 @@
use HiEvents\Constants;
use HiEvents\DomainObjects\CapacityAssignmentDomainObject;
use HiEvents\DomainObjects\Enums\CapacityAssignmentAppliesTo;
+use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\Status\CapacityAssignmentStatus;
use HiEvents\DomainObjects\Status\OrderStatus;
-use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\Repository\Interfaces\CapacityAssignmentRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesDTO;
use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesResponseDTO;
use Illuminate\Config\Repository as Config;
@@ -19,34 +21,53 @@
class AvailableProductQuantitiesFetchService
{
public function __construct(
- private readonly DatabaseManager $db,
- private readonly Config $config,
- private readonly Cache $cache,
+ private readonly DatabaseManager $db,
+ private readonly Config $config,
+ private readonly Cache $cache,
private readonly CapacityAssignmentRepositoryInterface $capacityAssignmentRepository,
- )
- {
- }
-
- public function getAvailableProductQuantities(int $eventId, bool $ignoreCache = false): AvailableProductQuantitiesResponseDTO
- {
- if (!$ignoreCache && $this->config->get('app.homepage_product_quantities_cache_ttl')) {
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly EventOccurrenceRepositoryInterface $occurrenceRepository,
+ ) {}
+
+ public function getAvailableProductQuantities(
+ int $eventId,
+ bool $ignoreCache = false,
+ ?int $eventOccurrenceId = null,
+ ): AvailableProductQuantitiesResponseDTO {
+ if (! $ignoreCache && $eventOccurrenceId === null && $this->config->get('app.homepage_product_quantities_cache_ttl')) {
$cachedData = $this->getDataFromCache($eventId);
if ($cachedData) {
return $cachedData;
}
}
- $capacities = $this->capacityAssignmentRepository
- ->loadRelation(ProductDomainObject::class)
- ->findWhere([
- 'event_id' => $eventId,
- 'applies_to' => CapacityAssignmentAppliesTo::PRODUCTS->name,
- 'status' => CapacityAssignmentStatus::ACTIVE->name,
- ]);
+ // Capacity assignments are deliberately ignored for recurring events
+ // to mirror OrderCreateRequestValidationService::validateOverallCapacity:
+ // a single assignment that spans every session in the series doesn't
+ // map to per-occurrence capacity, and applying it as a per-product min
+ // here while skipping the aggregate check at validation lets two
+ // products that share an assignment each pass independently and then
+ // overdraw the shared cap on completion. Until per-occurrence
+ // assignments exist, treat them as inapplicable for recurring events
+ // end-to-end.
+ $event = $this->eventRepository->findById($eventId);
+ $isRecurring = $event !== null && $event->isRecurring();
+
+ $capacities = collect();
+ if (! $isRecurring) {
+ $capacities = $this->capacityAssignmentRepository
+ ->loadRelation(ProductDomainObject::class)
+ ->findWhere([
+ 'event_id' => $eventId,
+ 'applies_to' => CapacityAssignmentAppliesTo::PRODUCTS->name,
+ 'status' => CapacityAssignmentStatus::ACTIVE->name,
+ ]);
+ }
- $reservedProductQuantities = $this->fetchReservedProductQuantities($eventId);
$productCapacities = $this->calculateProductCapacities($capacities);
+ $reservedProductQuantities = $this->fetchReservedProductQuantities($eventId);
+
$quantities = $reservedProductQuantities->map(function (AvailableProductQuantitiesDTO $dto) use ($productCapacities) {
$productId = $dto->product_id;
if (isset($productCapacities[$productId])) {
@@ -57,21 +78,77 @@ public function getAvailableProductQuantities(int $eventId, bool $ignoreCache =
return $dto;
});
+ if ($eventOccurrenceId !== null) {
+ $quantities = $this->applyOccurrenceCapacity($quantities, $eventOccurrenceId);
+ }
+
$finalData = new AvailableProductQuantitiesResponseDTO(
productQuantities: $quantities,
capacities: $capacities
);
- if (!$ignoreCache && $this->config->get('app.homepage_product_quantities_cache_ttl')) {
+ if (! $ignoreCache && $eventOccurrenceId === null && $this->config->get('app.homepage_product_quantities_cache_ttl')) {
$this->cache->put($this->getCacheKey($eventId), $finalData, $this->config->get('app.homepage_product_quantities_cache_ttl'));
}
return $finalData;
}
+ private function applyOccurrenceCapacity(Collection $quantities, int $occurrenceId): Collection
+ {
+ $occurrence = $this->occurrenceRepository->findById($occurrenceId);
+
+ // Cancelled, past, or missing occurrences should never report available
+ // capacity. Cancelled is the common case (capacity released when the
+ // cancel flow rolls back attendees and fires CapacityChangedEvent).
+ // Past covers waitlist offers / share links that resolve to a session
+ // whose endDate has already passed — without it, the waitlist listener
+ // would still offer entries against a dead date as long as raw
+ // capacity arithmetic checked out. Missing covers hard-deletion edge
+ // cases (FK normally cascades to NULL on delete, but a corrupted/
+ // manually-deleted row could leave an entry pointing at a non-existent
+ // occurrence).
+ if ($occurrence === null || $occurrence->isCancelled() || $occurrence->isPast()) {
+ return $quantities->map(function (AvailableProductQuantitiesDTO $dto) {
+ $dto->quantity_available = 0;
+
+ return $dto;
+ });
+ }
+
+ if ($occurrence->getCapacity() === null) {
+ return $quantities;
+ }
+
+ $reservedForOccurrence = (int) $this->db->selectOne(<<<'SQL'
+ SELECT COALESCE(SUM(oi.quantity), 0) as reserved
+ FROM order_items oi
+ JOIN orders o ON o.id = oi.order_id
+ WHERE oi.event_occurrence_id = :occurrenceId
+ AND o.status = :reserved
+ AND o.reserved_until > NOW()
+ AND o.deleted_at IS NULL
+ SQL, [
+ 'occurrenceId' => $occurrenceId,
+ 'reserved' => OrderStatus::RESERVED->name,
+ ])->reserved;
+
+ $occurrenceAvailable = max(0, $occurrence->getCapacity() - $occurrence->getUsedCapacity() - $reservedForOccurrence);
+
+ return $quantities->map(function (AvailableProductQuantitiesDTO $dto) use ($occurrenceAvailable) {
+ if ($dto->quantity_available !== Constants::INFINITE) {
+ $dto->quantity_available = min($dto->quantity_available, $occurrenceAvailable);
+ } else {
+ $dto->quantity_available = $occurrenceAvailable;
+ }
+
+ return $dto;
+ });
+ }
+
private function fetchReservedProductQuantities(int $eventId): Collection
{
- $result = $this->db->select(<<db->select(<<<'SQL'
WITH reserved_quantities AS (
SELECT
products.id AS product_id,
@@ -128,10 +205,10 @@ private function fetchReservedProductQuantities(int $eventId): Collection
GROUP BY products.id, product_prices.id, reserved_quantities.quantity_reserved;
SQL, [
'eventId' => $eventId,
- 'reserved' => OrderStatus::RESERVED->name
+ 'reserved' => OrderStatus::RESERVED->name,
]);
- return collect($result)->map(fn($row) => AvailableProductQuantitiesDTO::fromArray([
+ return collect($result)->map(fn ($row) => AvailableProductQuantitiesDTO::fromArray([
'product_id' => $row->product_id,
'price_id' => $row->product_price_id,
'product_title' => $row->product_title,
@@ -139,12 +216,12 @@ private function fetchReservedProductQuantities(int $eventId): Collection
'quantity_available' => $row->unlimited_quantity_available ? Constants::INFINITE : $row->quantity_available,
'initial_quantity_available' => $row->initial_quantity_available,
'quantity_reserved' => $row->quantity_reserved,
- 'capacities' => new Collection(),
+ 'capacities' => new Collection,
]));
}
/**
- * @param Collection $capacities
+ * @param Collection $capacities
*/
private function calculateProductCapacities(Collection $capacities): array
{
@@ -152,7 +229,7 @@ private function calculateProductCapacities(Collection $capacities): array
foreach ($capacities as $capacity) {
foreach ($capacity->getProducts() as $product) {
$productId = $product->getId();
- if (!isset($productCapacities[$productId])) {
+ if (! isset($productCapacities[$productId])) {
$productCapacities[$productId] = collect();
}
diff --git a/backend/app/Services/Domain/Product/ProductFilterService.php b/backend/app/Services/Domain/Product/ProductFilterService.php
index 36af1acc19..78e3671696 100644
--- a/backend/app/Services/Domain/Product/ProductFilterService.php
+++ b/backend/app/Services/Domain/Product/ProductFilterService.php
@@ -3,9 +3,10 @@
namespace HiEvents\Services\Domain\Product;
use HiEvents\Constants;
-use HiEvents\DomainObjects\AccountConfigurationDomainObject;
use HiEvents\DomainObjects\CapacityAssignmentDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
+use HiEvents\DomainObjects\OrganizerConfigurationDomainObject;
+use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\DomainObjects\ProductCategoryDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
@@ -13,26 +14,26 @@
use HiEvents\DomainObjects\TaxAndFeesDomainObject;
use HiEvents\Helper\Currency;
use HiEvents\Repository\Eloquent\Value\Relationship;
-use HiEvents\Repository\Interfaces\AccountRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Services\Domain\Order\OrderPlatformFeePassThroughService;
+use HiEvents\Repository\Interfaces\ProductOccurrenceVisibilityRepositoryInterface;
use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesDTO;
use HiEvents\Services\Domain\Tax\TaxAndFeeCalculationService;
use Illuminate\Support\Collection;
class ProductFilterService
{
- private ?AccountConfigurationDomainObject $accountConfiguration = null;
+ private ?OrganizerConfigurationDomainObject $organizerConfiguration = null;
private ?EventSettingDomainObject $eventSettings = null;
private ?string $eventCurrency = null;
public function __construct(
- private readonly TaxAndFeeCalculationService $taxCalculationService,
- private readonly ProductPriceService $productPriceService,
- private readonly AvailableProductQuantitiesFetchService $fetchAvailableProductQuantitiesService,
- private readonly OrderPlatformFeePassThroughService $platformFeeService,
- private readonly AccountRepositoryInterface $accountRepository,
- private readonly EventRepositoryInterface $eventRepository,
+ private readonly TaxAndFeeCalculationService $taxCalculationService,
+ private readonly ProductPriceService $productPriceService,
+ private readonly AvailableProductQuantitiesFetchService $fetchAvailableProductQuantitiesService,
+ private readonly OrderPlatformFeePassThroughService $platformFeeService,
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly ProductOccurrenceVisibilityRepositoryInterface $productOccurrenceVisibilityRepository,
)
{
}
@@ -48,6 +49,7 @@ public function filter(
Collection $productsCategories,
?PromoCodeDomainObject $promoCode = null,
bool $hideSoldOutProducts = true,
+ ?int $eventOccurrenceId = null,
bool $hideHiddenCategories = true,
): Collection
{
@@ -69,13 +71,17 @@ public function filter(
$productQuantities = $this
->fetchAvailableProductQuantitiesService
- ->getAvailableProductQuantities($eventId);
+ ->getAvailableProductQuantities($eventId, eventOccurrenceId: $eventOccurrenceId);
$filteredProducts = $products
- ->map(fn(ProductDomainObject $product) => $this->processProduct($product, $productQuantities->productQuantities, $promoCode))
+ ->map(fn(ProductDomainObject $product) => $this->processProduct($product, $productQuantities->productQuantities, $promoCode, $eventOccurrenceId))
->reject(fn(ProductDomainObject $product) => $this->filterProduct($product, $promoCode, $hideSoldOutProducts))
->each(fn(ProductDomainObject $product) => $this->processProductPrices($product, $hideSoldOutProducts));
+ if ($eventOccurrenceId !== null) {
+ $filteredProducts = $this->filterByOccurrenceVisibility($filteredProducts, $eventOccurrenceId);
+ }
+
$filteredCategories = $hideHiddenCategories
? $productsCategories->reject(fn(ProductCategoryDomainObject $category) => $category->getIsHidden())
: $productsCategories;
@@ -90,21 +96,23 @@ public function filter(
private function loadAccountConfiguration(int $eventId): void
{
- $account = $this->accountRepository
- ->loadRelation(new Relationship(
- domainObject: AccountConfigurationDomainObject::class,
- name: 'configuration',
- ))
- ->findByEventId($eventId);
-
- $this->accountConfiguration = $account->getConfiguration();
-
$event = $this->eventRepository
->loadRelation(EventSettingDomainObject::class)
+ ->loadRelation(new Relationship(
+ domainObject: OrganizerDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: OrganizerConfigurationDomainObject::class,
+ name: 'organizer_configuration',
+ ),
+ ],
+ name: 'organizer',
+ ))
->findById($eventId);
$this->eventSettings = $event->getEventSettings();
$this->eventCurrency = $event->getCurrency();
+ $this->organizerConfiguration = $event->getOrganizer()?->getOrganizerConfiguration();
}
private function isHiddenByPromoCode(ProductDomainObject $product, ?PromoCodeDomainObject $promoCode): bool
@@ -136,12 +144,22 @@ private function processProduct(
ProductDomainObject $product,
Collection $productQuantities,
?PromoCodeDomainObject $promoCode = null,
+ ?int $eventOccurrenceId = null,
): ProductDomainObject
{
if ($this->shouldProductBeDiscounted($promoCode, $product)) {
- $product->getProductPrices()?->each(function (ProductPriceDomainObject $price) use ($product, $promoCode) {
+ $product->getProductPrices()?->each(function (ProductPriceDomainObject $price) use ($product, $promoCode, $eventOccurrenceId) {
$price->setPriceBeforeDiscount($price->getPrice());
- $price->setPrice($this->productPriceService->getIndividualPrice($product, $price, $promoCode));
+ $price->setPrice($this->productPriceService->getIndividualPrice($product, $price, $promoCode, $eventOccurrenceId));
+ });
+ }
+
+ if ($eventOccurrenceId !== null && !$this->shouldProductBeDiscounted($promoCode, $product)) {
+ $product->getProductPrices()?->each(function (ProductPriceDomainObject $price) use ($product, $eventOccurrenceId) {
+ $overridePrice = $this->productPriceService->getIndividualPrice($product, $price, null, $eventOccurrenceId);
+ if ($overridePrice !== $price->getPrice()) {
+ $price->setPrice($overridePrice);
+ }
});
}
@@ -226,12 +244,12 @@ private function processProductPrice(ProductDomainObject $product, ProductPriceD
private function calculatePlatformFee(float $total): float
{
- if ($this->accountConfiguration === null || $this->eventSettings === null) {
+ if ($this->organizerConfiguration === null || $this->eventSettings === null) {
return 0.0;
}
return $this->platformFeeService->calculatePlatformFee(
- accountConfiguration: $this->accountConfiguration,
+ organizerConfiguration: $this->organizerConfiguration,
eventSettings: $this->eventSettings,
total: $total,
quantity: 1,
@@ -304,6 +322,23 @@ private function processProductPrices(ProductDomainObject $product, bool $hideSo
);
}
+ private function filterByOccurrenceVisibility(Collection $products, int $eventOccurrenceId): Collection
+ {
+ $visibilityRules = $this->productOccurrenceVisibilityRepository->findWhere([
+ 'event_occurrence_id' => $eventOccurrenceId,
+ ]);
+
+ if ($visibilityRules->isEmpty()) {
+ return $products;
+ }
+
+ $visibleProductIds = $visibilityRules->map(fn($rule) => $rule->getProductId());
+
+ return $products->filter(
+ fn(ProductDomainObject $product) => $visibleProductIds->contains($product->getId())
+ );
+ }
+
private function getPriceAvailability(ProductPriceDomainObject $price, ProductDomainObject $product): bool
{
if ($product->isTieredType()) {
diff --git a/backend/app/Services/Domain/Product/ProductPriceService.php b/backend/app/Services/Domain/Product/ProductPriceService.php
index aa29d65503..0512271796 100644
--- a/backend/app/Services/Domain/Product/ProductPriceService.php
+++ b/backend/app/Services/Domain/Product/ProductPriceService.php
@@ -8,30 +8,39 @@
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\Helper\Currency;
+use HiEvents\Repository\Interfaces\ProductPriceOccurrenceOverrideRepositoryInterface;
use HiEvents\Services\Domain\Product\DTO\OrderProductPriceDTO;
use HiEvents\Services\Domain\Product\DTO\PriceDTO;
class ProductPriceService
{
+ public function __construct(
+ private readonly ProductPriceOccurrenceOverrideRepositoryInterface $priceOverrideRepository,
+ )
+ {
+ }
+
public function getIndividualPrice(
ProductDomainObject $product,
ProductPriceDomainObject $price,
- ?PromoCodeDomainObject $promoCode
+ ?PromoCodeDomainObject $promoCode,
+ ?int $eventOccurrenceId = null,
): float
{
return $this->getPrice($product, new OrderProductPriceDTO(
quantity: 1,
price_id: $price->getId(),
- ), $promoCode)->price;
+ ), $promoCode, $eventOccurrenceId)->price;
}
public function getPrice(
ProductDomainObject $product,
OrderProductPriceDTO $productOrderDetail,
- ?PromoCodeDomainObject $promoCode
+ ?PromoCodeDomainObject $promoCode,
+ ?int $eventOccurrenceId = null,
): PriceDTO
{
- $price = $this->determineProductPrice($product, $productOrderDetail);
+ $price = $this->determineProductPrice($product, $productOrderDetail, $eventOccurrenceId);
if ($product->getType() === ProductPriceType::FREE->name) {
return new PriceDTO(0.00);
@@ -65,8 +74,19 @@ public function getPrice(
);
}
- private function determineProductPrice(ProductDomainObject $product, OrderProductPriceDTO $productOrderDetails): float
+ private function determineProductPrice(ProductDomainObject $product, OrderProductPriceDTO $productOrderDetails, ?int $eventOccurrenceId = null): float
{
+ if ($eventOccurrenceId !== null) {
+ $override = $this->priceOverrideRepository->findFirstWhere([
+ 'event_occurrence_id' => $eventOccurrenceId,
+ 'product_price_id' => $productOrderDetails->price_id,
+ ]);
+
+ if ($override !== null) {
+ return (float) $override->getPrice();
+ }
+ }
+
return match ($product->getType()) {
ProductPriceType::DONATION->name => max($product->getPrice(), $productOrderDetails->price),
ProductPriceType::PAID->name => $product->getPrice(),
diff --git a/backend/app/Services/Domain/Product/ProductQuantityUpdateService.php b/backend/app/Services/Domain/Product/ProductQuantityUpdateService.php
index 44fe678a22..cae1d79f03 100644
--- a/backend/app/Services/Domain/Product/ProductQuantityUpdateService.php
+++ b/backend/app/Services/Domain/Product/ProductQuantityUpdateService.php
@@ -6,13 +6,15 @@
use HiEvents\DomainObjects\Generated\CapacityAssignmentDomainObjectAbstract;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
+use HiEvents\DomainObjects\Status\EventOccurrenceStatus;
+use HiEvents\Exceptions\OrderHasNoItemsException;
use HiEvents\Repository\Interfaces\CapacityAssignmentRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
-use InvalidArgumentException;
class ProductQuantityUpdateService
{
@@ -21,13 +23,14 @@ public function __construct(
private readonly ProductRepositoryInterface $productRepository,
private readonly CapacityAssignmentRepositoryInterface $capacityAssignmentRepository,
private readonly DatabaseManager $databaseManager,
+ private readonly EventOccurrenceRepositoryInterface $occurrenceRepository,
)
{
}
- public function increaseQuantitySold(int $priceId, int $adjustment = 1): void
+ public function increaseQuantitySold(int $priceId, int $adjustment = 1, ?int $eventOccurrenceId = null): void
{
- $this->databaseManager->transaction(function () use ($priceId, $adjustment) {
+ $this->databaseManager->transaction(function () use ($priceId, $adjustment, $eventOccurrenceId) {
$capacityAssignments = $this->getCapacityAssignments($priceId);
$capacityAssignments->each(function (CapacityAssignmentDomainObjectAbstract $capacityAssignment) use ($adjustment) {
@@ -39,12 +42,16 @@ public function increaseQuantitySold(int $priceId, int $adjustment = 1): void
], [
'id' => $priceId,
]);
+
+ if ($eventOccurrenceId !== null) {
+ $this->increaseOccurrenceUsedCapacity($eventOccurrenceId, $adjustment);
+ }
});
}
- public function decreaseQuantitySold(int $priceId, int $adjustment = 1): void
+ public function decreaseQuantitySold(int $priceId, int $adjustment = 1, ?int $eventOccurrenceId = null): void
{
- $this->databaseManager->transaction(function () use ($priceId, $adjustment) {
+ $this->databaseManager->transaction(function () use ($priceId, $adjustment, $eventOccurrenceId) {
$capacityAssignments = $this->getCapacityAssignments($priceId);
$capacityAssignments->each(function (CapacityAssignmentDomainObjectAbstract $capacityAssignment) use ($adjustment) {
@@ -56,6 +63,10 @@ public function decreaseQuantitySold(int $priceId, int $adjustment = 1): void
], [
'id' => $priceId,
]);
+
+ if ($eventOccurrenceId !== null) {
+ $this->decreaseOccurrenceUsedCapacity($eventOccurrenceId, $adjustment);
+ }
});
}
@@ -66,7 +77,7 @@ public function updateQuantitiesFromOrder(OrderDomainObject $order): void
{
$this->databaseManager->transaction(function () use ($order) {
if ($order->getOrderItems() === null) {
- throw new InvalidArgumentException(__('Order has no order items'));
+ throw new OrderHasNoItemsException(__('Order has no order items'));
}
$this->updateProductQuantities($order);
@@ -81,7 +92,11 @@ private function updateProductQuantities(OrderDomainObject $order): void
{
/** @var OrderItemDomainObject $orderItem */
foreach ($order->getOrderItems() as $orderItem) {
- $this->increaseQuantitySold($orderItem->getProductPriceId(), $orderItem->getQuantity());
+ $this->increaseQuantitySold(
+ $orderItem->getProductPriceId(),
+ $orderItem->getQuantity(),
+ $orderItem->getEventOccurrenceId(),
+ );
}
}
@@ -103,6 +118,52 @@ private function decreaseCapacityAssignmentUsedCapacity(int $capacityAssignmentI
]);
}
+ private function increaseOccurrenceUsedCapacity(int $occurrenceId, int $adjustment): void
+ {
+ $this->occurrenceRepository->updateWhere([
+ 'used_capacity' => DB::raw('used_capacity + ' . $adjustment),
+ ], [
+ 'id' => $occurrenceId,
+ ]);
+
+ $occurrence = $this->occurrenceRepository->findById($occurrenceId);
+
+ if (
+ $occurrence->getStatus() === EventOccurrenceStatus::ACTIVE->name
+ && $occurrence->getCapacity() !== null
+ && $occurrence->getUsedCapacity() >= $occurrence->getCapacity()
+ ) {
+ $this->occurrenceRepository->updateWhere([
+ 'status' => EventOccurrenceStatus::SOLD_OUT->name,
+ ], [
+ 'id' => $occurrenceId,
+ ]);
+ }
+ }
+
+ private function decreaseOccurrenceUsedCapacity(int $occurrenceId, int $adjustment): void
+ {
+ $this->occurrenceRepository->updateWhere([
+ 'used_capacity' => DB::raw('GREATEST(0, used_capacity - ' . $adjustment . ')'),
+ ], [
+ 'id' => $occurrenceId,
+ ]);
+
+ $occurrence = $this->occurrenceRepository->findById($occurrenceId);
+
+ if (
+ $occurrence->getStatus() === EventOccurrenceStatus::SOLD_OUT->name
+ && $occurrence->getCapacity() !== null
+ && $occurrence->getUsedCapacity() < $occurrence->getCapacity()
+ ) {
+ $this->occurrenceRepository->updateWhere([
+ 'status' => EventOccurrenceStatus::ACTIVE->name,
+ ], [
+ 'id' => $occurrenceId,
+ ]);
+ }
+ }
+
/**
* @param int $priceId
* @return Collection
diff --git a/backend/app/Services/Domain/Report/AbstractReportService.php b/backend/app/Services/Domain/Report/AbstractReportService.php
index 5ff76bfdb9..5edbb0579f 100644
--- a/backend/app/Services/Domain/Report/AbstractReportService.php
+++ b/backend/app/Services/Domain/Report/AbstractReportService.php
@@ -18,7 +18,7 @@ public function __construct(
{
}
- public function generateReport(int $eventId, ?Carbon $startDate = null, ?Carbon $endDate = null): Collection
+ public function generateReport(int $eventId, ?Carbon $startDate = null, ?Carbon $endDate = null, ?int $occurrenceId = null): Collection
{
$event = $this->eventRepository->findById($eventId);
$timezone = $event->getTimezone();
@@ -31,24 +31,34 @@ public function generateReport(int $eventId, ?Carbon $startDate = null, ?Carbon
? $startDate->copy()->setTimezone($timezone)->startOfDay()
: $endDate->copy()->subDays(30)->startOfDay();
+ $bindings = ['event_id' => $eventId];
+ if ($occurrenceId !== null) {
+ $bindings['occurrence_id'] = $occurrenceId;
+ }
+
+ $bindings = array_merge($bindings, $this->getAdditionalBindings($startDate, $endDate));
+
$reportResults = $this->cache->remember(
- key: $this->getCacheKey($eventId, $startDate, $endDate),
+ key: $this->getCacheKey($eventId, $startDate, $endDate, $occurrenceId),
ttl: Carbon::now()->addSeconds(20),
callback: fn() => $this->queryBuilder->select(
- $this->getSqlQuery($startDate, $endDate),
- [
- 'event_id' => $eventId,
- ]
+ $this->getSqlQuery($startDate, $endDate, $occurrenceId),
+ $bindings,
)
);
return collect($reportResults);
}
- abstract protected function getSqlQuery(Carbon $startDate, Carbon $endDate): string;
+ abstract protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?int $occurrenceId = null): string;
+
+ protected function getAdditionalBindings(Carbon $startDate, Carbon $endDate): array
+ {
+ return [];
+ }
- protected function getCacheKey(int $eventId, ?Carbon $startDate, ?Carbon $endDate): string
+ protected function getCacheKey(int $eventId, ?Carbon $startDate, ?Carbon $endDate, ?int $occurrenceId = null): string
{
- return static::class . "$eventId.{$startDate?->toDateString()}.{$endDate?->toDateString()}";
+ return static::class . "$eventId.{$startDate?->toDateString()}.{$endDate?->toDateString()}.{$occurrenceId}";
}
}
diff --git a/backend/app/Services/Domain/Report/Factory/ReportServiceFactory.php b/backend/app/Services/Domain/Report/Factory/ReportServiceFactory.php
index a18f2dbc64..cffe095efc 100644
--- a/backend/app/Services/Domain/Report/Factory/ReportServiceFactory.php
+++ b/backend/app/Services/Domain/Report/Factory/ReportServiceFactory.php
@@ -5,6 +5,7 @@
use HiEvents\DomainObjects\Enums\ReportTypes;
use HiEvents\Services\Domain\Report\AbstractReportService;
use HiEvents\Services\Domain\Report\Reports\DailySalesReport;
+use HiEvents\Services\Domain\Report\Reports\OccurrenceSummaryReport;
use HiEvents\Services\Domain\Report\Reports\ProductSalesReport;
use HiEvents\Services\Domain\Report\Reports\PromoCodesReport;
use Illuminate\Support\Facades\App;
@@ -17,6 +18,7 @@ public function create(ReportTypes $reportType): AbstractReportService
ReportTypes::PRODUCT_SALES => App::make(ProductSalesReport::class),
ReportTypes::DAILY_SALES_REPORT => App::make(DailySalesReport::class),
ReportTypes::PROMO_CODES_REPORT => App::make(PromoCodesReport::class),
+ ReportTypes::OCCURRENCE_SUMMARY => App::make(OccurrenceSummaryReport::class),
};
}
}
diff --git a/backend/app/Services/Domain/Report/OrganizerReports/CheckInSummaryReport.php b/backend/app/Services/Domain/Report/OrganizerReports/CheckInSummaryReport.php
index 79331ebc3d..bf3ac38f2b 100644
--- a/backend/app/Services/Domain/Report/OrganizerReports/CheckInSummaryReport.php
+++ b/backend/app/Services/Domain/Report/OrganizerReports/CheckInSummaryReport.php
@@ -18,11 +18,20 @@ protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?string $curr
FROM events
WHERE organizer_id = :organizer_id
AND deleted_at IS NULL
+ ),
+ event_dates AS (
+ SELECT
+ eo.event_id,
+ MIN(eo.start_date) AS start_date
+ FROM event_occurrences eo
+ WHERE eo.event_id IN (SELECT id FROM organizer_events)
+ AND eo.deleted_at IS NULL
+ GROUP BY eo.event_id
)
SELECT
e.id AS event_id,
e.title AS event_name,
- e.start_date,
+ ed.start_date,
COALESCE(attendee_counts.total_attendees, 0) AS total_attendees,
COALESCE(checkin_counts.total_checked_in, 0) AS total_checked_in,
CASE
@@ -31,6 +40,7 @@ protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?string $curr
END AS check_in_rate,
COALESCE(list_counts.check_in_lists_count, 0) AS check_in_lists_count
FROM events e
+ LEFT JOIN event_dates ed ON e.id = ed.event_id
LEFT JOIN (
SELECT
event_id,
@@ -61,7 +71,7 @@ protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?string $curr
) list_counts ON e.id = list_counts.event_id
WHERE e.organizer_id = :organizer_id
AND e.deleted_at IS NULL
- ORDER BY e.start_date DESC NULLS LAST
+ ORDER BY ed.start_date DESC NULLS LAST
SQL;
}
}
diff --git a/backend/app/Services/Domain/Report/OrganizerReports/EventsPerformanceReport.php b/backend/app/Services/Domain/Report/OrganizerReports/EventsPerformanceReport.php
index 846bf07877..d801d1d385 100644
--- a/backend/app/Services/Domain/Report/OrganizerReports/EventsPerformanceReport.php
+++ b/backend/app/Services/Domain/Report/OrganizerReports/EventsPerformanceReport.php
@@ -21,6 +21,16 @@ protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?string $curr
WHERE organizer_id = :organizer_id
AND deleted_at IS NULL
),
+ event_dates AS (
+ SELECT
+ eo.event_id,
+ MIN(eo.start_date) AS start_date,
+ MAX(COALESCE(eo.end_date, eo.start_date)) AS end_date
+ FROM event_occurrences eo
+ WHERE eo.event_id IN (SELECT id FROM organizer_events)
+ AND eo.deleted_at IS NULL
+ GROUP BY eo.event_id
+ ),
order_stats AS (
SELECT
o.event_id,
@@ -53,12 +63,12 @@ protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?string $curr
e.id AS event_id,
e.title AS event_name,
e.currency AS event_currency,
- e.start_date,
- e.end_date,
+ ed.start_date,
+ ed.end_date,
e.status,
CASE
- WHEN e.end_date < NOW() THEN 'past'
- WHEN e.start_date <= NOW() AND (e.end_date >= NOW() OR e.end_date IS NULL) THEN 'ongoing'
+ WHEN ed.end_date < NOW() THEN 'past'
+ WHEN ed.start_date <= NOW() AND (ed.end_date >= NOW() OR ed.end_date IS NULL) THEN 'ongoing'
WHEN e.status = 'LIVE' THEN 'on_sale'
ELSE 'upcoming'
END AS event_state,
@@ -72,6 +82,7 @@ protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?string $curr
COALESCE(os.unique_customers, 0) AS unique_customers,
COALESCE(es.total_views, 0) AS page_views
FROM events e
+ LEFT JOIN event_dates ed ON e.id = ed.event_id
LEFT JOIN order_stats os ON e.id = os.event_id
LEFT JOIN product_stats ps ON e.id = ps.event_id
LEFT JOIN event_statistics es ON e.id = es.event_id
@@ -80,10 +91,10 @@ protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?string $curr
$eventCurrencyFilter
ORDER BY
CASE
- WHEN e.start_date IS NULL THEN 1
+ WHEN ed.start_date IS NULL THEN 1
ELSE 0
END,
- e.start_date DESC
+ ed.start_date DESC
SQL;
}
}
diff --git a/backend/app/Services/Domain/Report/Reports/DailySalesReport.php b/backend/app/Services/Domain/Report/Reports/DailySalesReport.php
index ba396f3718..725c0889bc 100644
--- a/backend/app/Services/Domain/Report/Reports/DailySalesReport.php
+++ b/backend/app/Services/Domain/Report/Reports/DailySalesReport.php
@@ -7,11 +7,36 @@
class DailySalesReport extends AbstractReportService
{
- public function getSqlQuery(Carbon $startDate, Carbon $endDate): string
+ public function getSqlQuery(Carbon $startDate, Carbon $endDate, ?int $occurrenceId = null): string
{
$startDateStr = $startDate->toDateString();
$endDateStr = $endDate->toDateString();
+ if ($occurrenceId !== null) {
+ return << $startDate->toDateTimeString(),
+ 'end_date' => $endDate->toDateTimeString(),
+ ];
+ }
+
+ protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?int $occurrenceId = null): string
+ {
+ return <<= :start_date
+ AND eo.start_date <= :end_date
+ ORDER BY eo.start_date
+SQL;
+ }
+}
diff --git a/backend/app/Services/Domain/Report/Reports/ProductSalesReport.php b/backend/app/Services/Domain/Report/Reports/ProductSalesReport.php
index c29feee0cb..a6bd36effc 100644
--- a/backend/app/Services/Domain/Report/Reports/ProductSalesReport.php
+++ b/backend/app/Services/Domain/Report/Reports/ProductSalesReport.php
@@ -8,11 +8,14 @@
class ProductSalesReport extends AbstractReportService
{
- protected function getSqlQuery(Carbon $startDate, Carbon $endDate): string
+ protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?int $occurrenceId = null): string
{
$startDateString = $startDate->format('Y-m-d H:i:s');
$endDateString = $endDate->format('Y-m-d H:i:s');
$completedStatus = OrderStatus::COMPLETED->name;
+ $occurrenceFilter = $occurrenceId !== null
+ ? 'AND oi.event_occurrence_id = :occurrence_id'
+ : '';
return <<format('Y-m-d H:i:s');
$endDateString = $endDate->format('Y-m-d H:i:s');
$reservedString = OrderStatus::RESERVED->name;
$completedStatus = OrderStatus::COMPLETED->name;
+ $occurrenceFilter = $occurrenceId !== null
+ ? 'AND oi.event_occurrence_id = :occurrence_id'
+ : '';
$translatedStringMap = [
'Expired' => __('Expired'),
@@ -41,6 +44,7 @@ protected function getSqlQuery(Carbon $startDate, Carbon $endDate): string
AND o.event_id = :event_id
AND o.created_at >= '$startDateString'
AND o.created_at <= '$endDateString'
+ $occurrenceFilter
GROUP BY
o.id,
diff --git a/backend/app/Services/Domain/SelfService/OrderAuditLogService.php b/backend/app/Services/Domain/SelfService/OrderAuditLogService.php
index 5a53c67384..4ac5693754 100644
--- a/backend/app/Services/Domain/SelfService/OrderAuditLogService.php
+++ b/backend/app/Services/Domain/SelfService/OrderAuditLogService.php
@@ -77,4 +77,32 @@ public function logEmailResent(
'user_agent' => $userAgent,
]);
}
+
+ /**
+ * Records that an organiser overrode an occurrence's capacity ceiling when
+ * manually creating an attendee. The override flag intentionally bypasses a
+ * normally-blocking check, so the audit trail records who/when/which
+ * occurrence — important for later support and diagnosis when stats or
+ * waitlists look impossible.
+ */
+ public function logManualAttendeeCapacityOverride(
+ int $eventId,
+ int $orderId,
+ int $attendeeId,
+ int $occurrenceId,
+ string $ipAddress,
+ ?string $userAgent,
+ ): void {
+ $this->orderAuditLogRepository->create([
+ 'event_id' => $eventId,
+ 'order_id' => $orderId,
+ 'attendee_id' => $attendeeId,
+ 'action' => OrderAuditAction::MANUAL_ATTENDEE_CAPACITY_OVERRIDE->value,
+ 'old_values' => null,
+ 'new_values' => ['event_occurrence_id' => $occurrenceId],
+ 'changed_fields' => 'event_occurrence_id',
+ 'ip_address' => $ipAddress,
+ 'user_agent' => $userAgent,
+ ]);
+ }
}
diff --git a/backend/app/Services/Domain/SelfService/SelfServiceEditAttendeeService.php b/backend/app/Services/Domain/SelfService/SelfServiceEditAttendeeService.php
index 49bdf9a721..66190e635e 100644
--- a/backend/app/Services/Domain/SelfService/SelfServiceEditAttendeeService.php
+++ b/backend/app/Services/Domain/SelfService/SelfServiceEditAttendeeService.php
@@ -4,6 +4,7 @@
use HiEvents\DomainObjects\AttendeeDomainObject;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
@@ -124,6 +125,14 @@ private function sendTicketToNewEmail(int $attendeeId, EventDomainObject $event)
->loadRelation(new Relationship(OrderDomainObject::class, nested: [
new Relationship(OrderItemDomainObject::class),
], name: 'order'))
+ ->loadRelation(new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ name: 'event_occurrence',
+ ))
+ ->loadRelation(new Relationship(
+ domainObject: ProductDomainObject::class,
+ name: 'product',
+ ))
->findById($attendeeId);
$this->sendAttendeeTicketService->send(
diff --git a/backend/app/Services/Domain/SelfService/SelfServiceEditOrderService.php b/backend/app/Services/Domain/SelfService/SelfServiceEditOrderService.php
index 0fa7727414..4a4634c86c 100644
--- a/backend/app/Services/Domain/SelfService/SelfServiceEditOrderService.php
+++ b/backend/app/Services/Domain/SelfService/SelfServiceEditOrderService.php
@@ -4,6 +4,7 @@
use HiEvents\DomainObjects\AttendeeDomainObject;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\InvoiceDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
@@ -62,7 +63,7 @@ public function editOrder(
$emailChanged = true;
}
- if (!empty($updateData)) {
+ if (! empty($updateData)) {
$oldEmail = $order->getEmail();
if ($emailChanged) {
@@ -114,13 +115,22 @@ private function loadEventWithRelations(int $eventId): EventDomainObject
return $this->eventRepository
->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer'))
->loadRelation(new Relationship(EventSettingDomainObject::class))
+ ->loadRelation(new Relationship(EventOccurrenceDomainObject::class))
->findById($eventId);
}
private function sendConfirmationToNewEmail(int $orderId, EventDomainObject $event): void
{
$order = $this->orderRepository
- ->loadRelation(OrderItemDomainObject::class)
+ ->loadRelation(new Relationship(
+ domainObject: OrderItemDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ name: 'event_occurrence',
+ ),
+ ],
+ ))
->loadRelation(AttendeeDomainObject::class)
->loadRelation(InvoiceDomainObject::class)
->findById($orderId);
diff --git a/backend/app/Services/Domain/SelfService/SelfServiceResendEmailService.php b/backend/app/Services/Domain/SelfService/SelfServiceResendEmailService.php
index bf0d08142b..f4673c14df 100644
--- a/backend/app/Services/Domain/SelfService/SelfServiceResendEmailService.php
+++ b/backend/app/Services/Domain/SelfService/SelfServiceResendEmailService.php
@@ -4,12 +4,13 @@
use HiEvents\DomainObjects\AttendeeDomainObject;
use HiEvents\DomainObjects\Enums\OrderAuditAction;
-use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\InvoiceDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
+use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
@@ -39,6 +40,14 @@ public function resendAttendeeTicket(
->loadRelation(new Relationship(OrderDomainObject::class, nested: [
new Relationship(OrderItemDomainObject::class),
], name: 'order'))
+ ->loadRelation(new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ name: 'event_occurrence',
+ ))
+ ->loadRelation(new Relationship(
+ domainObject: ProductDomainObject::class,
+ name: 'product',
+ ))
->findFirstWhere([
'id' => $attendeeId,
'order_id' => $orderId,
@@ -75,7 +84,15 @@ public function resendOrderConfirmation(
?string $userAgent
): void {
$order = $this->orderRepository
- ->loadRelation(OrderItemDomainObject::class)
+ ->loadRelation(new Relationship(
+ domainObject: OrderItemDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ name: 'event_occurrence',
+ ),
+ ],
+ ))
->loadRelation(AttendeeDomainObject::class)
->loadRelation(InvoiceDomainObject::class)
->findFirstWhere([
@@ -86,6 +103,7 @@ public function resendOrderConfirmation(
$event = $this->eventRepository
->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer'))
->loadRelation(new Relationship(EventSettingDomainObject::class))
+ ->loadRelation(new Relationship(EventOccurrenceDomainObject::class))
->findById($eventId);
$this->sendOrderDetailsService->sendCustomerOrderSummary(
diff --git a/backend/app/Services/Domain/Waitlist/CancelWaitlistEntryService.php b/backend/app/Services/Domain/Waitlist/CancelWaitlistEntryService.php
index fa8aa6d541..f3fe853b6c 100644
--- a/backend/app/Services/Domain/Waitlist/CancelWaitlistEntryService.php
+++ b/backend/app/Services/Domain/Waitlist/CancelWaitlistEntryService.php
@@ -103,6 +103,7 @@ private function cancelEntry(WaitlistEntryDomainObject $entry): WaitlistEntryDom
direction: CapacityChangeDirection::INCREASED,
productId: $productPrice->getProductId(),
productPriceId: $entry->getProductPriceId(),
+ eventOccurrenceId: $entry->getEventOccurrenceId(),
));
}
diff --git a/backend/app/Services/Domain/Waitlist/CreateWaitlistEntryService.php b/backend/app/Services/Domain/Waitlist/CreateWaitlistEntryService.php
index c934377cdb..73c4671123 100644
--- a/backend/app/Services/Domain/Waitlist/CreateWaitlistEntryService.php
+++ b/backend/app/Services/Domain/Waitlist/CreateWaitlistEntryService.php
@@ -36,13 +36,14 @@ public function createEntry(
/** @var WaitlistEntryDomainObject $entry */
$entry = $this->databaseManager->transaction(function () use ($dto) {
- $this->waitlistEntryRepository->lockForProductPrice($dto->product_price_id);
+ $this->waitlistEntryRepository->lockForProductPrice($dto->product_price_id, $dto->event_occurrence_id);
$this->validateNoDuplicate($dto);
$position = $this->calculatePosition($dto);
return $this->waitlistEntryRepository->create([
'event_id' => $dto->event_id,
'product_price_id' => $dto->product_price_id,
+ 'event_occurrence_id' => $dto->event_occurrence_id,
'email' => EmailHelper::normalize($dto->email),
'first_name' => trim($dto->first_name),
'last_name' => $dto->last_name ? trim($dto->last_name) : null,
@@ -78,6 +79,7 @@ private function validateNoDuplicate(CreateWaitlistEntryDTO $dto): void
'event_id' => $dto->event_id,
['status', 'in', [WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFERED->name]],
'product_price_id' => $dto->product_price_id,
+ 'event_occurrence_id' => $dto->event_occurrence_id,
];
$existing = $this->waitlistEntryRepository->findFirstWhere($conditions);
@@ -91,6 +93,6 @@ private function validateNoDuplicate(CreateWaitlistEntryDTO $dto): void
private function calculatePosition(CreateWaitlistEntryDTO $dto): int
{
- return $this->waitlistEntryRepository->getMaxPosition($dto->product_price_id) + 1;
+ return $this->waitlistEntryRepository->getMaxPosition($dto->product_price_id, $dto->event_occurrence_id) + 1;
}
}
diff --git a/backend/app/Services/Domain/Waitlist/ProcessWaitlistService.php b/backend/app/Services/Domain/Waitlist/ProcessWaitlistService.php
index eedc88ffa6..9be1c88509 100644
--- a/backend/app/Services/Domain/Waitlist/ProcessWaitlistService.php
+++ b/backend/app/Services/Domain/Waitlist/ProcessWaitlistService.php
@@ -5,6 +5,7 @@
use HiEvents\Constants;
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
+use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\DomainObjects\Status\WaitlistEntryStatus;
@@ -14,10 +15,13 @@
use HiEvents\Exceptions\ResourceConflictException;
use HiEvents\Exceptions\ResourceNotFoundException;
use HiEvents\Jobs\Waitlist\SendWaitlistOfferEmailJob;
+use HiEvents\Repository\Eloquent\Value\OrderAndDirection;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
use HiEvents\Repository\Interfaces\WaitlistEntryRepositoryInterface;
use HiEvents\Services\Application\Handlers\Order\DTO\ProductOrderDetailsDTO;
+use HiEvents\Services\Domain\EventOccurrence\OccurrencePurchaseEligibilityService;
use HiEvents\Services\Domain\Order\OrderItemProcessingService;
use HiEvents\Services\Domain\Order\OrderManagementService;
use HiEvents\Services\Domain\Product\AvailableProductQuantitiesFetchService;
@@ -25,54 +29,47 @@
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
+use Illuminate\Validation\ValidationException;
class ProcessWaitlistService
{
private const DEFAULT_OFFER_TIMEOUT_MINUTES = 60 * 12; // 12 hours
public function __construct(
- private readonly WaitlistEntryRepositoryInterface $waitlistEntryRepository,
- private readonly DatabaseManager $databaseManager,
- private readonly OrderManagementService $orderManagementService,
- private readonly OrderItemProcessingService $orderItemProcessingService,
- private readonly ProductRepositoryInterface $productRepository,
+ private readonly WaitlistEntryRepositoryInterface $waitlistEntryRepository,
+ private readonly DatabaseManager $databaseManager,
+ private readonly OrderManagementService $orderManagementService,
+ private readonly OrderItemProcessingService $orderItemProcessingService,
+ private readonly ProductRepositoryInterface $productRepository,
private readonly AvailableProductQuantitiesFetchService $availableQuantitiesService,
- private readonly ProductPriceRepositoryInterface $productPriceRepository,
- )
- {
- }
+ private readonly ProductPriceRepositoryInterface $productPriceRepository,
+ private readonly EventOccurrenceRepositoryInterface $eventOccurrenceRepository,
+ private readonly OccurrencePurchaseEligibilityService $eligibilityService,
+ ) {}
/**
* @return Collection
*/
public function offerToNext(
- int $productPriceId,
- int $quantity,
- EventDomainObject $event,
+ int $productPriceId,
+ int $quantity,
+ EventDomainObject $event,
EventSettingDomainObject $eventSettings,
- ): Collection
- {
- return $this->databaseManager->transaction(function () use ($productPriceId, $quantity, $event, $eventSettings) {
- $this->databaseManager->statement('SELECT pg_advisory_xact_lock(?)', [$event->getId()]);
- $this->waitlistEntryRepository->lockForProductPrice($productPriceId);
-
- $quantities = $this->availableQuantitiesService->getAvailableProductQuantities(
- $event->getId(),
- ignoreCache: true,
+ ?int $eventOccurrenceId = null,
+ ): Collection {
+ if ($quantity <= 0) {
+ throw new NoCapacityAvailableException(
+ __('No capacity available for the selected waitlist entries.')
);
+ }
- $availableCount = $this->getAvailableCountForPrice($quantities, $productPriceId);
-
- if ($availableCount <= 0) {
- throw new NoCapacityAvailableException(
- __('No capacity available. Available: :available', [
- 'available' => $availableCount,
- ])
- );
- }
+ return $this->databaseManager->transaction(function () use ($productPriceId, $quantity, $event, $eventSettings, $eventOccurrenceId) {
+ $this->databaseManager->statement('SELECT pg_advisory_xact_lock(?)', [$event->getId()]);
+ $this->waitlistEntryRepository->lockForProductPrice($productPriceId, $eventOccurrenceId);
- $toOffer = min($quantity, $availableCount);
- $entries = $this->waitlistEntryRepository->getNextWaitingEntries($productPriceId, $toOffer);
+ $entries = $eventOccurrenceId !== null
+ ? $this->waitlistEntryRepository->getNextWaitingEntries($productPriceId, eventOccurrenceId: $eventOccurrenceId)
+ : $this->waitlistEntryRepository->getNextWaitingEntries($productPriceId);
if ($entries->isEmpty()) {
throw new NoCapacityAvailableException(
@@ -81,10 +78,61 @@ public function offerToNext(
}
$offeredEntries = collect();
+ // Capacity is fetched once per (occurrence, price) and decremented
+ // in-flight as offers are issued, so the loop neither re-runs the
+ // multi-CTE availability query per entry nor over-offers when
+ // multiple waiting entries share an occurrence.
+ $remainingByOccurrenceAndPrice = [];
foreach ($entries as $entry) {
+ try {
+ $occurrenceId = $this->resolveEventOccurrenceId($event, $entry);
+ } catch (ResourceConflictException|ResourceNotFoundException) {
+ continue;
+ }
+
+ // Re-validate the occurrence + product visibility right before
+ // offering. The entry was validated when it was created, but
+ // the date can be cancelled, age past, or have its product
+ // visibility changed before this listener fires. Skip those
+ // entries silently — they should be cancelled out-of-band by
+ // the cancel/regen flows, but defending here means a stale
+ // entry never produces an offer email pointing at a dead date.
+ if (! $this->isOccurrenceStillEligibleForEntry($event, $occurrenceId, $entry)) {
+ continue;
+ }
+
+ $priceId = $entry->getProductPriceId();
+
+ if (! isset($remainingByOccurrenceAndPrice[$occurrenceId][$priceId])) {
+ $quantities = $this->availableQuantitiesService->getAvailableProductQuantities(
+ $event->getId(),
+ ignoreCache: true,
+ eventOccurrenceId: $occurrenceId,
+ );
+ $remainingByOccurrenceAndPrice[$occurrenceId][$priceId] = $this->getAvailableCountForPrice($quantities, $priceId);
+ }
+
+ if ($remainingByOccurrenceAndPrice[$occurrenceId][$priceId] <= 0) {
+ continue;
+ }
+
$updatedEntry = $this->offerEntry($entry, $event, $eventSettings);
$offeredEntries->push($updatedEntry);
+
+ if ($remainingByOccurrenceAndPrice[$occurrenceId][$priceId] !== Constants::INFINITE) {
+ $remainingByOccurrenceAndPrice[$occurrenceId][$priceId]--;
+ }
+
+ if ($offeredEntries->count() >= $quantity) {
+ break;
+ }
+ }
+
+ if ($offeredEntries->isEmpty()) {
+ throw new NoCapacityAvailableException(
+ __('No capacity available for the selected waitlist entries.')
+ );
}
return $offeredEntries;
@@ -95,12 +143,11 @@ public function offerToNext(
* @return Collection
*/
public function offerSpecificEntry(
- int $entryId,
- int $eventId,
- EventDomainObject $event,
+ int $entryId,
+ int $eventId,
+ EventDomainObject $event,
EventSettingDomainObject $eventSettings,
- ): Collection
- {
+ ): Collection {
return $this->databaseManager->transaction(function () use ($entryId, $eventId, $event, $eventSettings) {
$this->databaseManager->statement('SELECT pg_advisory_xact_lock(?)', [$event->getId()]);
@@ -115,26 +162,35 @@ public function offerSpecificEntry(
}
$validStatuses = [WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFER_EXPIRED->name];
- if (!in_array($entry->getStatus(), $validStatuses, true)) {
+ if (! in_array($entry->getStatus(), $validStatuses, true)) {
throw new ResourceConflictException(
__('This waitlist entry cannot be offered in its current status')
);
}
- $this->waitlistEntryRepository->lockForProductPrice($entry->getProductPriceId());
-
- $quantities = $this->availableQuantitiesService->getAvailableProductQuantities(
- $event->getId(),
- ignoreCache: true,
+ $this->waitlistEntryRepository->lockForProductPrice(
+ $entry->getProductPriceId(),
+ $entry->getEventOccurrenceId(),
);
- $availableCount = $this->getAvailableCountForPrice($quantities, $entry->getProductPriceId());
+ try {
+ $occurrenceId = $this->resolveEventOccurrenceId($event, $entry);
+ } catch (ResourceConflictException|ResourceNotFoundException $e) {
+ throw new ResourceConflictException(
+ __('This waitlist entry is no longer linked to a valid event date.'),
+ previous: $e,
+ );
+ }
- if ($availableCount <= 0) {
+ if (! $this->isOccurrenceStillEligibleForEntry($event, $occurrenceId, $entry)) {
+ throw new ResourceConflictException(
+ __('This event date is no longer available for this product.')
+ );
+ }
+
+ if (! $this->hasCapacityForEntry($entry, $event)) {
throw new NoCapacityAvailableException(
- __('No capacity available to offer this waitlist entry. You will need to increase the available quantity for the product. Available: :available', [
- 'available' => $availableCount,
- ])
+ __('No capacity available to offer this waitlist entry. You will need to increase the available quantity for the product or date.')
);
}
@@ -144,14 +200,48 @@ public function offerSpecificEntry(
});
}
+ /**
+ * Checks the occurrence is still cancellable / not past, and that the
+ * waitlisted product remains visible on it. Wraps the eligibility service
+ * with `overrideCapacity: true` because the caller has already done its
+ * own capacity arithmetic and we only want the lifecycle gates here.
+ */
+ private function isOccurrenceStillEligibleForEntry(
+ EventDomainObject $event,
+ int $occurrenceId,
+ WaitlistEntryDomainObject $entry,
+ ): bool {
+ try {
+ $this->eligibilityService->assertOccurrencePurchasable(
+ eventId: $event->getId(),
+ occurrenceId: $occurrenceId,
+ additionalQuantity: 1,
+ overrideCapacity: true,
+ );
+
+ $productPrice = $this->productPriceRepository->findById($entry->getProductPriceId());
+ if ($productPrice === null) {
+ return false;
+ }
+
+ $this->eligibilityService->assertProductsVisibleOnOccurrence(
+ $occurrenceId,
+ [$productPrice->getProductId()],
+ );
+ } catch (ValidationException) {
+ return false;
+ }
+
+ return true;
+ }
+
private function offerEntry(
WaitlistEntryDomainObject $entry,
- EventDomainObject $event,
- EventSettingDomainObject $eventSettings,
- ): WaitlistEntryDomainObject
- {
+ EventDomainObject $event,
+ EventSettingDomainObject $eventSettings,
+ ): WaitlistEntryDomainObject {
$offerExpiresAt = $this->calculateOfferExpiry($eventSettings);
- $sessionIdentifier = sha1(Str::uuid() . Str::random(40));
+ $sessionIdentifier = sha1(Str::uuid().Str::random(40));
$order = $this->createReservedOrder($entry, $event, $eventSettings, $sessionIdentifier);
$this->waitlistEntryRepository->updateWhere(
@@ -175,11 +265,10 @@ private function offerEntry(
private function createReservedOrder(
WaitlistEntryDomainObject $entry,
- EventDomainObject $event,
- EventSettingDomainObject $eventSettings,
- string $sessionIdentifier,
- ): OrderDomainObject
- {
+ EventDomainObject $event,
+ EventSettingDomainObject $eventSettings,
+ string $sessionIdentifier,
+ ): OrderDomainObject {
$timeoutMinutes = $eventSettings->getWaitlistOfferTimeoutMinutes() ?? self::DEFAULT_OFFER_TIMEOUT_MINUTES;
$order = $this->orderManagementService->createNewOrder(
@@ -198,6 +287,8 @@ private function createReservedOrder(
->loadRelation(ProductPriceDomainObject::class)
->findById($productPrice->getProductId());
+ $eventOccurrenceId = $this->resolveEventOccurrenceId($event, $entry);
+
$orderDetails = collect([
new ProductOrderDetailsDTO(
product_id: $product->getId(),
@@ -207,6 +298,7 @@ private function createReservedOrder(
price_id: $productPrice->getId(),
),
]),
+ event_occurrence_id: $eventOccurrenceId,
),
]);
@@ -220,6 +312,34 @@ private function createReservedOrder(
return $this->orderManagementService->updateOrderTotals($order, $orderItems);
}
+ private function resolveEventOccurrenceId(EventDomainObject $event, WaitlistEntryDomainObject $entry): ?int
+ {
+ if ($entry->getEventOccurrenceId() !== null) {
+ return $entry->getEventOccurrenceId();
+ }
+
+ if ($event->isRecurring()) {
+ throw new ResourceConflictException(__('Waitlist entry is missing an event date.'));
+ }
+
+ $occurrence = $this->eventOccurrenceRepository
+ ->findWhere(
+ where: [
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $event->getId(),
+ ],
+ orderAndDirections: [
+ new OrderAndDirection(EventOccurrenceDomainObjectAbstract::START_DATE, 'asc'),
+ ],
+ )
+ ->first();
+
+ if ($occurrence === null) {
+ throw new ResourceNotFoundException(__('No occurrence found for this event.'));
+ }
+
+ return $occurrence->getId();
+ }
+
private function getAvailableCountForPrice(object $quantities, int $priceId): int
{
foreach ($quantities->productQuantities as $productQuantity) {
@@ -228,6 +348,7 @@ private function getAvailableCountForPrice(object $quantities, int $priceId): in
if ($available === Constants::INFINITE) {
return Constants::INFINITE;
}
+
return $available;
}
}
@@ -235,6 +356,31 @@ private function getAvailableCountForPrice(object $quantities, int $priceId): in
return 0;
}
+ private function hasCapacityForEntry(WaitlistEntryDomainObject $entry, EventDomainObject $event): bool
+ {
+ // resolveEventOccurrenceId can throw two ways for an entry we cannot
+ // confidently route:
+ // - ResourceConflictException: orphan entry on a recurring event
+ // (FK cascaded null after delete)
+ // - ResourceNotFoundException: single event has no occurrences at
+ // all (degenerate state, but possible during creation flows)
+ // Either case → treat as "no capacity" so the offer batch skips it
+ // instead of crashing the entire waitlist run for that price.
+ try {
+ $eventOccurrenceId = $this->resolveEventOccurrenceId($event, $entry);
+ } catch (ResourceConflictException|ResourceNotFoundException) {
+ return false;
+ }
+
+ $quantities = $this->availableQuantitiesService->getAvailableProductQuantities(
+ $event->getId(),
+ ignoreCache: true,
+ eventOccurrenceId: $eventOccurrenceId,
+ );
+
+ return $this->getAvailableCountForPrice($quantities, $entry->getProductPriceId()) > 0;
+ }
+
private function calculateOfferExpiry(EventSettingDomainObject $eventSettings): string
{
$timeoutMinutes = $eventSettings->getWaitlistOfferTimeoutMinutes() ?? self::DEFAULT_OFFER_TIMEOUT_MINUTES;
diff --git a/backend/app/Services/Infrastructure/DomainEvents/Enums/DomainEventType.php b/backend/app/Services/Infrastructure/DomainEvents/Enums/DomainEventType.php
index 690dadd394..5cde8cad7c 100644
--- a/backend/app/Services/Infrastructure/DomainEvents/Enums/DomainEventType.php
+++ b/backend/app/Services/Infrastructure/DomainEvents/Enums/DomainEventType.php
@@ -28,4 +28,6 @@ enum DomainEventType: string
case CHECKIN_CREATED = 'checkin.created';
case CHECKIN_DELETED = 'checkin.deleted';
+
+ case OCCURRENCE_CANCELLED = 'occurrence.cancelled';
}
diff --git a/backend/app/Services/Infrastructure/DomainEvents/Events/OccurrenceEvent.php b/backend/app/Services/Infrastructure/DomainEvents/Events/OccurrenceEvent.php
new file mode 100644
index 0000000000..48b2cadd3f
--- /dev/null
+++ b/backend/app/Services/Infrastructure/DomainEvents/Events/OccurrenceEvent.php
@@ -0,0 +1,15 @@
+ __('Message shown after checkout'),
'example' => 'Thank you for your purchase!',
],
+ [
+ 'token' => '{{ occurrence.start_date }}',
+ 'description' => __('The occurrence start date'),
+ 'example' => 'January 15, 2024',
+ ],
+ [
+ 'token' => '{{ occurrence.start_time }}',
+ 'description' => __('The occurrence start time'),
+ 'example' => '7:00 PM',
+ ],
+ [
+ 'token' => '{{ occurrence.end_date }}',
+ 'description' => __('The occurrence end date'),
+ 'example' => 'January 16, 2024',
+ ],
+ [
+ 'token' => '{{ occurrence.end_time }}',
+ 'description' => __('The occurrence end time'),
+ 'example' => '11:00 PM',
+ ],
+ [
+ 'token' => '{{ occurrence.label }}',
+ 'description' => __('The occurrence title suffix'),
+ 'example' => 'Session A',
+ ],
];
$orderTokens = [
@@ -214,9 +239,23 @@ public function getAvailableTokens(EmailTemplateType $type): array
],
];
+ $cancellationTokens = [
+ [
+ 'token' => '{{ cancellation.refund_issued }}',
+ 'description' => __('Whether refunds are being processed for this cancellation'),
+ 'example' => 'true',
+ ],
+ [
+ 'token' => '{{ event.url }}',
+ 'description' => __('Link to the event homepage'),
+ 'example' => 'https://example.com/event/123/summer-fest',
+ ],
+ ];
+
return match ($type) {
EmailTemplateType::ORDER_CONFIRMATION => array_merge($commonTokens, $orderTokens),
EmailTemplateType::ATTENDEE_TICKET => array_merge($commonTokens, $orderTokens, $attendeeTokens),
+ EmailTemplateType::OCCURRENCE_CANCELLATION => array_merge($commonTokens, $cancellationTokens),
};
}
}
diff --git a/backend/app/Services/Application/Handlers/Account/Vat/DTO/ViesValidationResponseDTO.php b/backend/app/Services/Infrastructure/Vat/DTO/ViesValidationResponseDTO.php
similarity index 56%
rename from backend/app/Services/Application/Handlers/Account/Vat/DTO/ViesValidationResponseDTO.php
rename to backend/app/Services/Infrastructure/Vat/DTO/ViesValidationResponseDTO.php
index aedbb1a0d9..048c51449f 100644
--- a/backend/app/Services/Application/Handlers/Account/Vat/DTO/ViesValidationResponseDTO.php
+++ b/backend/app/Services/Infrastructure/Vat/DTO/ViesValidationResponseDTO.php
@@ -2,20 +2,21 @@
declare(strict_types=1);
-namespace HiEvents\Services\Application\Handlers\Account\Vat\DTO;
+namespace HiEvents\Services\Infrastructure\Vat\DTO;
use HiEvents\DataTransferObjects\BaseDataObject;
class ViesValidationResponseDTO extends BaseDataObject
{
public function __construct(
- public readonly bool $valid,
+ public readonly bool $valid,
public readonly ?string $businessName = null,
public readonly ?string $businessAddress = null,
- public readonly string $countryCode = '',
- public readonly string $vatNumber = '',
- public readonly bool $isTransientError = false,
+ public readonly string $countryCode = '',
+ public readonly string $vatNumber = '',
+ public readonly bool $isTransientError = false,
public readonly ?string $errorMessage = null,
- ) {
+ )
+ {
}
}
diff --git a/backend/app/Services/Infrastructure/Vat/ViesValidationService.php b/backend/app/Services/Infrastructure/Vat/ViesValidationService.php
index cf4653538e..ec6341f6a7 100644
--- a/backend/app/Services/Infrastructure/Vat/ViesValidationService.php
+++ b/backend/app/Services/Infrastructure/Vat/ViesValidationService.php
@@ -5,7 +5,7 @@
namespace HiEvents\Services\Infrastructure\Vat;
use Exception;
-use HiEvents\Services\Application\Handlers\Account\Vat\DTO\ViesValidationResponseDTO;
+use HiEvents\Services\Infrastructure\Vat\DTO\ViesValidationResponseDTO;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Factory as HttpClient;
use Psr\Log\LoggerInterface;
diff --git a/backend/app/Services/Infrastructure/Webhook/WebhookDispatchService.php b/backend/app/Services/Infrastructure/Webhook/WebhookDispatchService.php
index 25fe3369ed..6085a85bf8 100644
--- a/backend/app/Services/Infrastructure/Webhook/WebhookDispatchService.php
+++ b/backend/app/Services/Infrastructure/Webhook/WebhookDispatchService.php
@@ -3,6 +3,7 @@
namespace HiEvents\Services\Infrastructure\Webhook;
use HiEvents\DomainObjects\AttendeeDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\DomainObjects\QuestionAndAnswerViewDomainObject;
@@ -11,6 +12,7 @@
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\AttendeeCheckInRepositoryInterface;
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
use HiEvents\Repository\Interfaces\WebhookRepositoryInterface;
@@ -18,6 +20,7 @@
use HiEvents\Resources\Attendee\AttendeeResource;
use HiEvents\Resources\Event\EventResource;
use HiEvents\Resources\CheckInList\AttendeeCheckInResource;
+use HiEvents\Resources\EventOccurrence\EventOccurrenceResource;
use HiEvents\Resources\Order\OrderResource;
use HiEvents\Resources\Product\ProductResource;
use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType;
@@ -28,13 +31,14 @@
class WebhookDispatchService
{
public function __construct(
- private readonly LoggerInterface $logger,
- private readonly WebhookRepositoryInterface $webhookRepository,
- private readonly OrderRepositoryInterface $orderRepository,
- private readonly ProductRepositoryInterface $productRepository,
- private readonly AttendeeRepositoryInterface $attendeeRepository,
- private readonly AttendeeCheckInRepositoryInterface $attendeeCheckInRepository,
- private readonly EventRepositoryInterface $eventRepository,
+ private readonly LoggerInterface $logger,
+ private readonly WebhookRepositoryInterface $webhookRepository,
+ private readonly OrderRepositoryInterface $orderRepository,
+ private readonly ProductRepositoryInterface $productRepository,
+ private readonly AttendeeRepositoryInterface $attendeeRepository,
+ private readonly AttendeeCheckInRepositoryInterface $attendeeCheckInRepository,
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly EventOccurrenceRepositoryInterface $eventOccurrenceRepository,
)
{
}
@@ -57,6 +61,10 @@ public function dispatchAttendeeWebhook(DomainEventType $eventType, int $attende
domainObject: QuestionAndAnswerViewDomainObject::class,
name: 'question_and_answer_views',
))
+ ->loadRelation(new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ name: 'event_occurrence',
+ ))
->findById($attendeeId);
$this->dispatchWebhook(
@@ -83,6 +91,21 @@ public function dispatchCheckInWebhook(DomainEventType $eventType, int $attendee
);
}
+ public function dispatchOccurrenceWebhook(DomainEventType $eventType, int $occurrenceId): void
+ {
+ $occurrence = $this->eventOccurrenceRepository->findById($occurrenceId);
+
+ if ($occurrence === null) {
+ return;
+ }
+
+ $this->dispatchWebhook(
+ eventType: $eventType,
+ payload: new EventOccurrenceResource($occurrence),
+ eventId: $occurrence->getEventId(),
+ );
+ }
+
public function dispatchProductWebhook(DomainEventType $eventType, int $productId): void
{
$product = $this->productRepository
@@ -101,7 +124,15 @@ public function dispatchProductWebhook(DomainEventType $eventType, int $productI
public function dispatchOrderWebhook(DomainEventType $eventType, int $orderId): void
{
$order = $this->orderRepository
- ->loadRelation(OrderItemDomainObject::class)
+ ->loadRelation(new Relationship(
+ domainObject: OrderItemDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ name: 'event_occurrence',
+ ),
+ ],
+ ))
->loadRelation(new Relationship(
domainObject: AttendeeDomainObject::class,
nested: [
@@ -109,6 +140,10 @@ public function dispatchOrderWebhook(DomainEventType $eventType, int $orderId):
domainObject: QuestionAndAnswerViewDomainObject::class,
name: 'question_and_answer_views',
),
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ name: 'event_occurrence',
+ ),
],
name: 'attendees')
)
diff --git a/backend/app/Validators/EventRules.php b/backend/app/Validators/EventRules.php
index 55bf6eed36..ce4adfa0b0 100644
--- a/backend/app/Validators/EventRules.php
+++ b/backend/app/Validators/EventRules.php
@@ -3,6 +3,7 @@
namespace HiEvents\Validators;
use HiEvents\DomainObjects\Enums\EventCategory;
+use HiEvents\DomainObjects\Enums\EventType;
use Illuminate\Validation\Rule;
trait EventRules
@@ -12,6 +13,7 @@ public function eventRules(): array
$currencies = include __DIR__ . '/../../data/currencies.php';
return array_merge($this->minimalRules(), [
+ 'type' => ['nullable', Rule::in(EventType::valuesArray())],
'timezone' => ['timezone:all'],
'organizer_id' => ['required', 'integer'],
'currency' => [Rule::in(array_values($currencies))],
@@ -33,12 +35,14 @@ public function eventRules(): array
public function minimalRules(): array
{
+ $isRecurring = $this->input('type') === EventType::RECURRING->name;
+
return [
'title' => ['string', 'required', 'max:150', 'min:1'],
'description' => ['string', 'min:1', 'max:50000', 'nullable'],
'start_date' => [
'date',
- 'required',
+ $isRecurring ? 'nullable' : 'required',
Rule::when($this->input('end_date') !== null, ['before_or_equal:end_date'])
],
'end_date' => ['date', 'nullable'],
diff --git a/backend/config/app.php b/backend/config/app.php
index 50bcabc3e9..a7492bc502 100644
--- a/backend/config/app.php
+++ b/backend/config/app.php
@@ -47,8 +47,8 @@
'reset_password' => '/auth/reset-password/%s',
'confirm_email_change' => '/manage/profile/confirm-email-change/%s',
'accept_invitation' => '/auth/accept-invitation/%s',
- 'stripe_connect_return_url' => '/account/payment',
- 'stripe_connect_refresh_url' => '/account/payment',
+ 'stripe_connect_return_url' => '/manage/organizer/%d/settings#payouts',
+ 'stripe_connect_refresh_url' => '/manage/organizer/%d/settings#payouts',
'event_homepage' => '/event/%d/%s',
'attendee_product' => '/product/%d/%s',
'order_summary' => '/checkout/%d/%s/summary',
diff --git a/backend/database/factories/AccountFactory.php b/backend/database/factories/AccountFactory.php
index 2968b43255..8fd859b9a7 100644
--- a/backend/database/factories/AccountFactory.php
+++ b/backend/database/factories/AccountFactory.php
@@ -12,11 +12,6 @@
*/
class AccountFactory extends Factory
{
- /**
- * Define the model's default state.
- *
- * @return array
- */
public function definition(): array
{
$currencies = include base_path('data/currencies.php');
@@ -27,33 +22,10 @@ public function definition(): array
'timezone' => fake()->timezone(),
'currency_code' => fake()->randomElement(array_values($currencies)),
'short_id' => IdHelper::shortId(IdHelper::ACCOUNT_PREFIX),
- 'account_configuration_id' => 1, // Default account configuration is first entry
+ 'account_configuration_id' => 1,
];
}
- /**
- * Indicate that the model's stripe account id is set.
- */
- public function stripeAccount(): self
- {
- return $this->state(fn(array $attributes) => [
- 'stripe_account_id' => fake()->stripeConnectAccountId(),
- ]);
- }
-
- /**
- * Indicate that the model's stripe account connection setup is complete.
- */
- public function stripeConnectSetupComplete(bool $isComplete = true): self
- {
- return $this->state(fn(array $attributes) => [
- 'stripe_connect_setup_complete' => $isComplete,
- ]);
- }
-
- /**
- * Indicate that the model is verified.
- */
public function verified(): self
{
return $this->state(fn(array $attributes) => [
@@ -61,9 +33,6 @@ public function verified(): self
]);
}
- /**
- * Indicate that the model has been manually verified.
- */
public function manuallyVerified(): self
{
return $this->state(fn(array $attributes) => [
diff --git a/backend/database/migrations/2026_02_22_000001_add_type_and_recurrence_rule_to_events.php b/backend/database/migrations/2026_02_22_000001_add_type_and_recurrence_rule_to_events.php
new file mode 100644
index 0000000000..ba3407f6d4
--- /dev/null
+++ b/backend/database/migrations/2026_02_22_000001_add_type_and_recurrence_rule_to_events.php
@@ -0,0 +1,25 @@
+string('type', 20)->default('SINGLE');
+ $table->jsonb('recurrence_rule')->nullable();
+ });
+
+ DB::table('events')->update(['type' => 'SINGLE']);
+ }
+
+ public function down(): void
+ {
+ Schema::table('events', function (Blueprint $table) {
+ $table->dropColumn(['type', 'recurrence_rule']);
+ });
+ }
+};
diff --git a/backend/database/migrations/2026_02_22_000002_create_event_occurrences_table.php b/backend/database/migrations/2026_02_22_000002_create_event_occurrences_table.php
new file mode 100644
index 0000000000..e6d01c804d
--- /dev/null
+++ b/backend/database/migrations/2026_02_22_000002_create_event_occurrences_table.php
@@ -0,0 +1,35 @@
+id();
+ $table->string('short_id')->index();
+ $table->foreignId('event_id')->constrained('events')->onDelete('cascade');
+ $table->timestamp('start_date');
+ $table->timestamp('end_date')->nullable();
+ $table->string('status', 20)->default('ACTIVE');
+ $table->integer('capacity')->nullable();
+ $table->integer('used_capacity')->default(0);
+ $table->string('label', 255)->nullable();
+ $table->boolean('is_overridden')->default(false);
+ $table->timestamps();
+ $table->softDeletes();
+
+ $table->index('start_date');
+ $table->index('status');
+ $table->index(['event_id', 'start_date']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('event_occurrences');
+ }
+};
diff --git a/backend/database/migrations/2026_02_22_000003_create_product_price_occurrence_overrides_table.php b/backend/database/migrations/2026_02_22_000003_create_product_price_occurrence_overrides_table.php
new file mode 100644
index 0000000000..8ae4f882a0
--- /dev/null
+++ b/backend/database/migrations/2026_02_22_000003_create_product_price_occurrence_overrides_table.php
@@ -0,0 +1,29 @@
+id();
+ $table->foreignId('event_occurrence_id')->constrained('event_occurrences')->onDelete('cascade');
+ $table->foreignId('product_price_id')->constrained('product_prices')->onDelete('cascade');
+ $table->decimal('price', 14, 2);
+ $table->timestamps();
+
+ $table->unique(
+ ['event_occurrence_id', 'product_price_id'],
+ 'ppoo_occurrence_price_unique'
+ );
+ $table->index('event_occurrence_id');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('product_price_occurrence_overrides');
+ }
+};
diff --git a/backend/database/migrations/2026_02_22_000004_create_product_occurrence_visibility_table.php b/backend/database/migrations/2026_02_22_000004_create_product_occurrence_visibility_table.php
new file mode 100644
index 0000000000..6ffd4f6a71
--- /dev/null
+++ b/backend/database/migrations/2026_02_22_000004_create_product_occurrence_visibility_table.php
@@ -0,0 +1,28 @@
+id();
+ $table->foreignId('event_occurrence_id')->constrained('event_occurrences')->onDelete('cascade');
+ $table->foreignId('product_id')->constrained('products')->onDelete('cascade');
+ $table->timestamp('created_at')->useCurrent();
+
+ $table->unique(
+ ['event_occurrence_id', 'product_id'],
+ 'pov_occurrence_product_unique'
+ );
+ $table->index('event_occurrence_id');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('product_occurrence_visibility');
+ }
+};
diff --git a/backend/database/migrations/2026_02_22_000005_add_occurrence_id_to_order_items_attendees_checkin_lists.php b/backend/database/migrations/2026_02_22_000005_add_occurrence_id_to_order_items_attendees_checkin_lists.php
new file mode 100644
index 0000000000..2ac9ca4c02
--- /dev/null
+++ b/backend/database/migrations/2026_02_22_000005_add_occurrence_id_to_order_items_attendees_checkin_lists.php
@@ -0,0 +1,47 @@
+foreignId('event_occurrence_id')
+ ->nullable()
+ ->constrained('event_occurrences');
+ $table->index('event_occurrence_id');
+ });
+
+ Schema::table('attendees', function (Blueprint $table) {
+ $table->foreignId('event_occurrence_id')
+ ->nullable()
+ ->constrained('event_occurrences');
+ $table->index('event_occurrence_id');
+ });
+
+ Schema::table('check_in_lists', function (Blueprint $table) {
+ $table->foreignId('event_occurrence_id')
+ ->nullable()
+ ->constrained('event_occurrences')
+ ->nullOnDelete();
+ $table->index('event_occurrence_id');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('order_items', function (Blueprint $table) {
+ $table->dropConstrainedForeignId('event_occurrence_id');
+ });
+
+ Schema::table('attendees', function (Blueprint $table) {
+ $table->dropConstrainedForeignId('event_occurrence_id');
+ });
+
+ Schema::table('check_in_lists', function (Blueprint $table) {
+ $table->dropConstrainedForeignId('event_occurrence_id');
+ });
+ }
+};
diff --git a/backend/database/migrations/2026_02_22_000006_backfill_occurrences_and_drop_event_dates.php b/backend/database/migrations/2026_02_22_000006_backfill_occurrences_and_drop_event_dates.php
new file mode 100644
index 0000000000..145b881195
--- /dev/null
+++ b/backend/database/migrations/2026_02_22_000006_backfill_occurrences_and_drop_event_dates.php
@@ -0,0 +1,109 @@
+select('id', 'start_date', 'end_date', 'created_at')->orderBy('id')->chunk(500, function ($events) {
+ $eventIds = $events->pluck('id')->all();
+ $alreadySeeded = DB::table('event_occurrences')
+ ->whereIn('event_id', $eventIds)
+ ->pluck('event_id')
+ ->all();
+ $seededLookup = array_flip($alreadySeeded);
+
+ foreach ($events as $event) {
+ if (isset($seededLookup[$event->id])) {
+ continue;
+ }
+
+ DB::table('event_occurrences')->insert([
+ 'event_id' => $event->id,
+ 'short_id' => \HiEvents\Helper\IdHelper::shortId(\HiEvents\Helper\IdHelper::OCCURRENCE_PREFIX),
+ 'start_date' => $event->start_date ?? $event->created_at ?? now(),
+ 'end_date' => $event->end_date,
+ 'status' => 'ACTIVE',
+ 'used_capacity' => 0,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+ });
+ }
+
+ // Step 2: Backfill order_items.event_occurrence_id (idempotent — WHERE IS NULL)
+ DB::statement('
+ UPDATE order_items oi
+ SET event_occurrence_id = (
+ SELECT eo.id FROM event_occurrences eo
+ JOIN products p ON p.event_id = eo.event_id
+ WHERE p.id = oi.product_id
+ LIMIT 1
+ )
+ WHERE oi.event_occurrence_id IS NULL
+ ');
+
+ // Step 3: Backfill attendees.event_occurrence_id (idempotent — WHERE IS NULL)
+ DB::statement('
+ UPDATE attendees a
+ SET event_occurrence_id = (
+ SELECT eo.id FROM event_occurrences eo
+ WHERE eo.event_id = a.event_id
+ LIMIT 1
+ )
+ WHERE a.event_occurrence_id IS NULL
+ ');
+
+ // Step 4: Make attendees NOT NULL (no-op if already NOT NULL).
+ // order_items stays nullable to support future series passes.
+ Schema::table('attendees', function (Blueprint $table) {
+ $table->foreignId('event_occurrence_id')->nullable(false)->change();
+ });
+
+ // Step 5: Drop start_date and end_date from events (no-op if already dropped).
+ if (Schema::hasColumn('events', 'start_date')) {
+ Schema::table('events', function (Blueprint $table) {
+ $table->dropColumn(['start_date', 'end_date']);
+ });
+ }
+ });
+ }
+
+ public function down(): void
+ {
+ // Re-add date columns to events
+ Schema::table('events', function (Blueprint $table) {
+ $table->timestamp('start_date')->nullable();
+ $table->timestamp('end_date')->nullable();
+ });
+
+ // Restore dates from occurrences
+ DB::statement('
+ UPDATE events e
+ SET start_date = (
+ SELECT MIN(eo.start_date) FROM event_occurrences eo WHERE eo.event_id = e.id
+ ),
+ end_date = (
+ SELECT MAX(eo.end_date) FROM event_occurrences eo WHERE eo.event_id = e.id
+ )
+ ');
+
+ // Null out occurrence FKs and make nullable again
+ DB::statement('UPDATE attendees SET event_occurrence_id = NULL');
+
+ Schema::table('attendees', function (Blueprint $table) {
+ $table->foreignId('event_occurrence_id')->nullable()->change();
+ });
+ }
+};
diff --git a/backend/database/migrations/2026_03_22_000001_make_check_in_lists_occurrence_id_nullable.php b/backend/database/migrations/2026_03_22_000001_make_check_in_lists_occurrence_id_nullable.php
new file mode 100644
index 0000000000..e0b26984e4
--- /dev/null
+++ b/backend/database/migrations/2026_03_22_000001_make_check_in_lists_occurrence_id_nullable.php
@@ -0,0 +1,34 @@
+foreignId('event_occurrence_id')->nullable()->change();
+ });
+
+ DB::statement("UPDATE check_in_lists SET event_occurrence_id = NULL");
+ }
+
+ public function down(): void
+ {
+ DB::statement("
+ UPDATE check_in_lists cl
+ SET event_occurrence_id = (
+ SELECT eo.id FROM event_occurrences eo
+ WHERE eo.event_id = cl.event_id
+ LIMIT 1
+ )
+ WHERE cl.event_occurrence_id IS NULL
+ ");
+
+ Schema::table('check_in_lists', function (Blueprint $table) {
+ $table->foreignId('event_occurrence_id')->nullable(false)->change();
+ });
+ }
+};
diff --git a/backend/database/migrations/2026_03_24_000001_add_quantity_to_price_overrides_and_drop_soft_deletes.php b/backend/database/migrations/2026_03_24_000001_add_quantity_to_price_overrides_and_drop_soft_deletes.php
new file mode 100644
index 0000000000..86fc043ae0
--- /dev/null
+++ b/backend/database/migrations/2026_03_24_000001_add_quantity_to_price_overrides_and_drop_soft_deletes.php
@@ -0,0 +1,34 @@
+whereNotNull('deleted_at')
+ ->delete();
+ }
+
+ Schema::table('product_price_occurrence_overrides', function (Blueprint $table) {
+ if (!Schema::hasColumn('product_price_occurrence_overrides', 'quantity_available')) {
+ $table->integer('quantity_available')->nullable()->after('price');
+ }
+ if (Schema::hasColumn('product_price_occurrence_overrides', 'deleted_at')) {
+ $table->dropColumn('deleted_at');
+ }
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('product_price_occurrence_overrides', function (Blueprint $table) {
+ $table->dropColumn('quantity_available');
+ $table->softDeletes();
+ });
+ }
+};
diff --git a/backend/database/migrations/2026_03_26_000001_create_event_occurrence_statistics_table.php b/backend/database/migrations/2026_03_26_000001_create_event_occurrence_statistics_table.php
new file mode 100644
index 0000000000..bcfbb3a045
--- /dev/null
+++ b/backend/database/migrations/2026_03_26_000001_create_event_occurrence_statistics_table.php
@@ -0,0 +1,84 @@
+id();
+ $table->foreignId('event_id')->constrained('events');
+ $table->foreignId('event_occurrence_id')->constrained('event_occurrences');
+ $table->integer('products_sold')->default(0);
+ $table->unsignedInteger('attendees_registered')->default(0);
+ $table->decimal('sales_total_gross', 14, 2)->default(0);
+ $table->decimal('sales_total_before_additions', 14, 2)->default(0);
+ $table->decimal('total_tax', 14, 2)->default(0);
+ $table->decimal('total_fee', 14, 2)->default(0);
+ $table->integer('orders_created')->default(0);
+ $table->unsignedInteger('orders_cancelled')->default(0);
+ $table->decimal('total_refunded', 14, 2)->default(0);
+ $table->integer('version')->default(0);
+ $table->timestamps();
+ $table->softDeletes();
+
+ $table->index('event_id');
+ $table->unique('event_occurrence_id');
+ });
+
+ // Backfill for single-occurrence events (1:1 mapping from event_statistics)
+ DB::statement(<<<'SQL'
+ INSERT INTO event_occurrence_statistics (
+ event_id,
+ event_occurrence_id,
+ products_sold,
+ attendees_registered,
+ sales_total_gross,
+ sales_total_before_additions,
+ total_tax,
+ total_fee,
+ orders_created,
+ orders_cancelled,
+ total_refunded,
+ version,
+ created_at,
+ updated_at
+ )
+ SELECT
+ es.event_id,
+ eo.id AS event_occurrence_id,
+ es.products_sold,
+ es.attendees_registered,
+ es.sales_total_gross,
+ es.sales_total_before_additions,
+ es.total_tax,
+ es.total_fee,
+ es.orders_created,
+ es.orders_cancelled,
+ es.total_refunded,
+ 0 AS version,
+ NOW(),
+ NOW()
+ FROM event_statistics es
+ INNER JOIN event_occurrences eo ON eo.event_id = es.event_id AND eo.deleted_at IS NULL
+ WHERE es.deleted_at IS NULL
+ AND NOT EXISTS (
+ SELECT 1 FROM event_occurrence_statistics eos
+ WHERE eos.event_occurrence_id = eo.id
+ AND eos.deleted_at IS NULL
+ )
+ AND (
+ SELECT COUNT(*) FROM event_occurrences eo2
+ WHERE eo2.event_id = es.event_id AND eo2.deleted_at IS NULL
+ ) = 1
+ SQL);
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('event_occurrence_statistics');
+ }
+};
diff --git a/backend/database/migrations/2026_03_26_000002_backfill_event_occurrence_statistics.php b/backend/database/migrations/2026_03_26_000002_backfill_event_occurrence_statistics.php
new file mode 100644
index 0000000000..907f03689f
--- /dev/null
+++ b/backend/database/migrations/2026_03_26_000002_backfill_event_occurrence_statistics.php
@@ -0,0 +1,60 @@
+id();
+ $table->foreignId('event_id')->constrained('events');
+ $table->foreignId('event_occurrence_id')->constrained('event_occurrences');
+ $table->date('date');
+ $table->integer('products_sold')->default(0);
+ $table->unsignedInteger('attendees_registered')->default(0);
+ $table->decimal('sales_total_gross', 14, 2)->default(0);
+ $table->decimal('sales_total_before_additions', 14, 2)->default(0);
+ $table->decimal('total_tax', 14, 2)->default(0);
+ $table->decimal('total_fee', 14, 2)->default(0);
+ $table->integer('orders_created')->default(0);
+ $table->unsignedInteger('orders_cancelled')->default(0);
+ $table->decimal('total_refunded', 14, 2)->default(0);
+ $table->integer('version')->default(0);
+ $table->timestamps();
+ $table->softDeletes();
+
+ $table->index(['event_id', 'date']);
+ $table->unique(['event_occurrence_id', 'date']);
+ });
+
+ // Backfill for single-occurrence events (1:1 mapping from event_daily_statistics)
+ DB::statement(<<<'SQL'
+ INSERT INTO event_occurrence_daily_statistics (
+ event_id, event_occurrence_id, date,
+ products_sold, attendees_registered,
+ sales_total_gross, sales_total_before_additions,
+ total_tax, total_fee,
+ orders_created, orders_cancelled, total_refunded,
+ version, created_at, updated_at
+ )
+ SELECT
+ eds.event_id, eo.id, eds.date,
+ eds.products_sold, eds.attendees_registered,
+ eds.sales_total_gross, eds.sales_total_before_additions,
+ eds.total_tax, eds.total_fee,
+ eds.orders_created, eds.orders_cancelled, eds.total_refunded,
+ 0, NOW(), NOW()
+ FROM event_daily_statistics eds
+ INNER JOIN event_occurrences eo ON eo.event_id = eds.event_id AND eo.deleted_at IS NULL
+ WHERE eds.deleted_at IS NULL
+ AND (
+ SELECT COUNT(*) FROM event_occurrences eo2
+ WHERE eo2.event_id = eds.event_id AND eo2.deleted_at IS NULL
+ ) = 1
+ SQL);
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('event_occurrence_daily_statistics');
+ }
+};
diff --git a/backend/database/migrations/2026_03_29_000001_add_event_occurrence_id_to_messages_table.php b/backend/database/migrations/2026_03_29_000001_add_event_occurrence_id_to_messages_table.php
new file mode 100644
index 0000000000..7f0ea7b783
--- /dev/null
+++ b/backend/database/migrations/2026_03_29_000001_add_event_occurrence_id_to_messages_table.php
@@ -0,0 +1,36 @@
+foreignId('event_occurrence_id')
+ ->nullable()
+ ->after('order_id')
+ ->constrained('event_occurrences')
+ ->nullOnDelete();
+
+ $table->index('event_occurrence_id');
+ });
+
+ DB::table('messages')
+ ->whereNotNull('send_data')
+ ->whereRaw("(send_data->>'event_occurrence_id') IS NOT NULL")
+ ->update([
+ 'event_occurrence_id' => DB::raw("(send_data->>'event_occurrence_id')::integer"),
+ ]);
+ }
+
+ public function down(): void
+ {
+ Schema::table('messages', function (Blueprint $table) {
+ $table->dropConstrainedForeignId('event_occurrence_id');
+ });
+ }
+};
diff --git a/backend/database/migrations/2026_03_31_000001_add_event_occurrence_id_to_attendee_check_ins.php b/backend/database/migrations/2026_03_31_000001_add_event_occurrence_id_to_attendee_check_ins.php
new file mode 100644
index 0000000000..f6b768a43c
--- /dev/null
+++ b/backend/database/migrations/2026_03_31_000001_add_event_occurrence_id_to_attendee_check_ins.php
@@ -0,0 +1,27 @@
+unsignedBigInteger('event_occurrence_id')->nullable()->after('event_id');
+ $table->foreign('event_occurrence_id')
+ ->references('id')
+ ->on('event_occurrences')
+ ->nullOnDelete();
+ $table->index('event_occurrence_id');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('attendee_check_ins', function (Blueprint $table) {
+ $table->dropForeign(['event_occurrence_id']);
+ $table->dropColumn('event_occurrence_id');
+ });
+ }
+};
diff --git a/backend/database/migrations/2026_04_04_000001_fix_occurrence_fk_on_delete_behavior.php b/backend/database/migrations/2026_04_04_000001_fix_occurrence_fk_on_delete_behavior.php
new file mode 100644
index 0000000000..9379e59a64
--- /dev/null
+++ b/backend/database/migrations/2026_04_04_000001_fix_occurrence_fk_on_delete_behavior.php
@@ -0,0 +1,49 @@
+dropForeign(['event_occurrence_id']);
+ $table->foreignId('event_occurrence_id')
+ ->nullable()
+ ->change();
+ $table->foreign('event_occurrence_id')
+ ->references('id')
+ ->on('event_occurrences')
+ ->nullOnDelete();
+ });
+
+ Schema::table('attendees', function (Blueprint $table) {
+ $table->dropForeign(['event_occurrence_id']);
+ $table->foreignId('event_occurrence_id')
+ ->nullable()
+ ->change();
+ $table->foreign('event_occurrence_id')
+ ->references('id')
+ ->on('event_occurrences')
+ ->nullOnDelete();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('order_items', function (Blueprint $table) {
+ $table->dropForeign(['event_occurrence_id']);
+ $table->foreign('event_occurrence_id')
+ ->references('id')
+ ->on('event_occurrences');
+ });
+
+ Schema::table('attendees', function (Blueprint $table) {
+ $table->dropForeign(['event_occurrence_id']);
+ $table->foreign('event_occurrence_id')
+ ->references('id')
+ ->on('event_occurrences');
+ });
+ }
+};
diff --git a/backend/database/migrations/2026_04_20_120000_add_public_visibility_to_check_in_lists.php b/backend/database/migrations/2026_04_20_120000_add_public_visibility_to_check_in_lists.php
new file mode 100644
index 0000000000..4862967304
--- /dev/null
+++ b/backend/database/migrations/2026_04_20_120000_add_public_visibility_to_check_in_lists.php
@@ -0,0 +1,25 @@
+boolean('public_show_attendee_notes')->default(true);
+ $table->boolean('public_show_question_answers')->default(true);
+ $table->boolean('public_show_order_details')->default(true);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('check_in_lists', function (Blueprint $table) {
+ $table->dropColumn('public_show_attendee_notes');
+ $table->dropColumn('public_show_question_answers');
+ $table->dropColumn('public_show_order_details');
+ });
+ }
+};
diff --git a/backend/database/migrations/2026_04_23_000001_add_is_system_default_to_check_in_lists.php b/backend/database/migrations/2026_04_23_000001_add_is_system_default_to_check_in_lists.php
new file mode 100644
index 0000000000..bbd28f2984
--- /dev/null
+++ b/backend/database/migrations/2026_04_23_000001_add_is_system_default_to_check_in_lists.php
@@ -0,0 +1,66 @@
+boolean('is_system_default')->default(false);
+ });
+
+ // One default list per event.
+ DB::statement("
+ CREATE UNIQUE INDEX check_in_lists_one_default_per_event
+ ON check_in_lists (event_id)
+ WHERE is_system_default = true AND deleted_at IS NULL
+ ");
+
+ // Backfill one default list per existing event. Chunked for large DBs.
+ DB::table('events')
+ ->select('id')
+ ->whereNull('deleted_at')
+ ->orderBy('id')
+ ->chunk(500, function ($events) {
+ foreach ($events as $event) {
+ $alreadyHasDefault = DB::table('check_in_lists')
+ ->where('event_id', $event->id)
+ ->where('is_system_default', true)
+ ->whereNull('deleted_at')
+ ->exists();
+
+ if ($alreadyHasDefault) {
+ continue;
+ }
+
+ DB::table('check_in_lists')->insert([
+ 'event_id' => $event->id,
+ 'short_id' => IdHelper::shortId(IdHelper::CHECK_IN_LIST_PREFIX),
+ 'name' => 'Default check-in',
+ 'description' => null,
+ 'is_system_default' => true,
+ 'public_show_attendee_notes' => true,
+ 'public_show_question_answers' => true,
+ 'public_show_order_details' => true,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+ });
+ }
+
+ public function down(): void
+ {
+ DB::statement('DROP INDEX IF EXISTS check_in_lists_one_default_per_event');
+
+ Schema::table('check_in_lists', function (Blueprint $table) {
+ $table->dropColumn('is_system_default');
+ });
+ }
+};
diff --git a/backend/database/migrations/2026_04_23_000002_expand_event_category_check_constraint.php b/backend/database/migrations/2026_04_23_000002_expand_event_category_check_constraint.php
new file mode 100644
index 0000000000..6accfe6f86
--- /dev/null
+++ b/backend/database/migrations/2026_04_23_000002_expand_event_category_check_constraint.php
@@ -0,0 +1,32 @@
+ "'".$v."'", $values));
+
+ DB::statement('ALTER TABLE events DROP CONSTRAINT IF EXISTS events_category_check');
+ DB::statement("ALTER TABLE events ADD CONSTRAINT events_category_check CHECK (category IN ($quoted))");
+ }
+
+ public function down(): void
+ {
+ $originalValues = [
+ 'SOCIAL', 'FOOD_DRINK', 'CHARITY',
+ 'MUSIC', 'ART', 'COMEDY', 'THEATER',
+ 'BUSINESS', 'TECH', 'EDUCATION', 'WORKSHOP',
+ 'SPORTS', 'FESTIVAL', 'NIGHTLIFE',
+ 'OTHER',
+ ];
+ $quoted = implode(', ', array_map(fn ($v) => "'".$v."'", $originalValues));
+
+ DB::statement('ALTER TABLE events DROP CONSTRAINT IF EXISTS events_category_check');
+ DB::statement("ALTER TABLE events ADD CONSTRAINT events_category_check CHECK (category IN ($quoted))");
+ }
+};
diff --git a/backend/database/migrations/2026_04_26_000001_drop_quantity_from_price_occurrence_overrides.php b/backend/database/migrations/2026_04_26_000001_drop_quantity_from_price_occurrence_overrides.php
new file mode 100644
index 0000000000..80269cc563
--- /dev/null
+++ b/backend/database/migrations/2026_04_26_000001_drop_quantity_from_price_occurrence_overrides.php
@@ -0,0 +1,30 @@
+dropColumn('quantity_available');
+ });
+ }
+
+ public function down(): void
+ {
+ if (Schema::hasColumn('product_price_occurrence_overrides', 'quantity_available')) {
+ return;
+ }
+
+ Schema::table('product_price_occurrence_overrides', function (Blueprint $table) {
+ $table->integer('quantity_available')->nullable()->after('price');
+ });
+ }
+};
diff --git a/backend/database/migrations/2026_04_28_000001_add_occurrence_id_to_waitlist_entries.php b/backend/database/migrations/2026_04_28_000001_add_occurrence_id_to_waitlist_entries.php
new file mode 100644
index 0000000000..68d5d455ef
--- /dev/null
+++ b/backend/database/migrations/2026_04_28_000001_add_occurrence_id_to_waitlist_entries.php
@@ -0,0 +1,45 @@
+foreignId('event_occurrence_id')
+ ->nullable()
+ ->after('product_price_id')
+ ->constrained('event_occurrences')
+ ->nullOnDelete();
+ $table->index('event_occurrence_id');
+ $table->index(['product_price_id', 'event_occurrence_id', 'status'], 'idx_waitlist_price_occ_status');
+ });
+
+ DB::statement('DROP INDEX IF EXISTS idx_unique_email_product_price_status');
+ DB::statement("
+ CREATE UNIQUE INDEX idx_unique_email_product_price_occ_status
+ ON waitlist_entries (email, product_price_id, COALESCE(event_occurrence_id, 0), status)
+ WHERE status IN ('WAITING', 'OFFERED')
+ ");
+ }
+
+ public function down(): void
+ {
+ DB::statement('DROP INDEX IF EXISTS idx_unique_email_product_price_occ_status');
+ DB::statement("
+ CREATE UNIQUE INDEX idx_unique_email_product_price_status
+ ON waitlist_entries (email, product_price_id, status)
+ WHERE status IN ('WAITING', 'OFFERED')
+ ");
+
+ Schema::table('waitlist_entries', function (Blueprint $table) {
+ $table->dropIndex('idx_waitlist_price_occ_status');
+ $table->dropIndex(['event_occurrence_id']);
+ $table->dropConstrainedForeignId('event_occurrence_id');
+ });
+ }
+};
diff --git a/backend/database/migrations/2026_05_11_000000_create_organizer_stripe_platforms_table.php b/backend/database/migrations/2026_05_11_000000_create_organizer_stripe_platforms_table.php
new file mode 100644
index 0000000000..9593b8f63a
--- /dev/null
+++ b/backend/database/migrations/2026_05_11_000000_create_organizer_stripe_platforms_table.php
@@ -0,0 +1,64 @@
+id();
+ $table->unsignedBigInteger('organizer_id');
+ $table->string('stripe_connect_account_type')->nullable();
+ $table->string('stripe_connect_platform', 2)->nullable();
+ $table->string('stripe_account_id')->nullable();
+ $table->timestamp('stripe_setup_completed_at')->nullable();
+ $table->jsonb('stripe_account_details')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+
+ $table->foreign('organizer_id')->references('id')->on('organizers')->onDelete('cascade');
+ $table->index(['organizer_id', 'stripe_connect_platform']);
+ $table->index('stripe_account_id');
+ $table->index('stripe_connect_platform');
+ });
+
+ // Preserve original created_at so getPrimaryStripePlatform()'s sortByDesc(created_at)
+ // is deterministic when an account had multiple platform rows (e.g. IE + US).
+ // Without this, every backfilled row shares NOW() and ties pick non-deterministically.
+ DB::statement("
+ INSERT INTO organizer_stripe_platforms (
+ organizer_id,
+ stripe_connect_account_type,
+ stripe_connect_platform,
+ stripe_account_id,
+ stripe_setup_completed_at,
+ stripe_account_details,
+ created_at,
+ updated_at
+ )
+ SELECT
+ o.id,
+ asp.stripe_connect_account_type,
+ asp.stripe_connect_platform,
+ asp.stripe_account_id,
+ asp.stripe_setup_completed_at,
+ asp.stripe_account_details,
+ asp.created_at,
+ NOW()
+ FROM organizers o
+ JOIN account_stripe_platforms asp ON asp.account_id = o.account_id
+ WHERE asp.deleted_at IS NULL
+ AND o.deleted_at IS NULL
+ AND asp.stripe_account_id IS NOT NULL
+ ");
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('organizer_stripe_platforms');
+ }
+};
diff --git a/backend/database/migrations/2026_05_12_000000_create_organizer_vat_settings_table.php b/backend/database/migrations/2026_05_12_000000_create_organizer_vat_settings_table.php
new file mode 100644
index 0000000000..22570d68bb
--- /dev/null
+++ b/backend/database/migrations/2026_05_12_000000_create_organizer_vat_settings_table.php
@@ -0,0 +1,70 @@
+id();
+ $table->unsignedBigInteger('organizer_id');
+ $table->boolean('vat_registered')->default(false);
+ $table->string('vat_number', 20)->nullable();
+ $table->boolean('vat_validated')->default(false);
+ $table->string('vat_validation_status', 20)->default('PENDING');
+ $table->text('vat_validation_error')->nullable();
+ $table->unsignedInteger('vat_validation_attempts')->default(0);
+ $table->timestamp('vat_validation_date')->nullable();
+ $table->string('business_name')->nullable();
+ $table->string('business_address')->nullable();
+ $table->string('vat_country_code', 2)->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+
+ $table->foreign('organizer_id')
+ ->references('id')
+ ->on('organizers')
+ ->onDelete('cascade');
+
+ $table->unique('organizer_id');
+ $table->index('vat_number');
+ $table->index('vat_validated');
+ $table->index('vat_validation_status');
+ });
+
+ DB::statement("
+ INSERT INTO organizer_vat_settings (
+ organizer_id, vat_registered, vat_number, vat_validated, vat_validation_status,
+ vat_validation_error, vat_validation_attempts, vat_validation_date,
+ business_name, business_address, vat_country_code, created_at, updated_at
+ )
+ SELECT
+ o.id,
+ avs.vat_registered,
+ avs.vat_number,
+ avs.vat_validated,
+ avs.vat_validation_status,
+ avs.vat_validation_error,
+ avs.vat_validation_attempts,
+ avs.vat_validation_date,
+ avs.business_name,
+ avs.business_address,
+ avs.vat_country_code,
+ NOW(),
+ NOW()
+ FROM organizers o
+ JOIN account_vat_settings avs ON avs.account_id = o.account_id
+ WHERE avs.deleted_at IS NULL
+ AND o.deleted_at IS NULL
+ ");
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('organizer_vat_settings');
+ }
+};
diff --git a/backend/database/migrations/2026_05_12_000001_create_organizer_configurations_table.php b/backend/database/migrations/2026_05_12_000001_create_organizer_configurations_table.php
new file mode 100644
index 0000000000..73a610011d
--- /dev/null
+++ b/backend/database/migrations/2026_05_12_000001_create_organizer_configurations_table.php
@@ -0,0 +1,93 @@
+id();
+ $table->string('name');
+ $table->boolean('is_system_default')->default(false);
+ $table->json('application_fees')->nullable();
+ $table->boolean('bypass_application_fees')->default(false);
+ // Tracks the source row in the legacy account_configuration table so
+ // backfill + new-organizer assignment can match by id, not by name.
+ // Dropped in the follow-up that retires the legacy table.
+ $table->unsignedBigInteger('legacy_account_configuration_id')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+
+ $table->index('legacy_account_configuration_id');
+ });
+
+ // Copy every non-deleted account_configuration row 1:1 into organizer_configurations,
+ // preserving the legacy id pointer so we can map organizers deterministically.
+ DB::statement("
+ INSERT INTO organizer_configurations
+ (name, is_system_default, application_fees, bypass_application_fees,
+ legacy_account_configuration_id, created_at, updated_at)
+ SELECT name, is_system_default, application_fees, bypass_application_fees,
+ id, NOW(), NOW()
+ FROM account_configuration
+ WHERE deleted_at IS NULL
+ ");
+
+ // Ensure there's always a system default. If the legacy table didn't have one
+ // (fresh open-source install), seed from app config.
+ $hasDefault = DB::table('organizer_configurations')->where('is_system_default', true)->exists();
+ if (!$hasDefault) {
+ DB::table('organizer_configurations')->insert([
+ 'name' => 'Default',
+ 'is_system_default' => true,
+ 'application_fees' => json_encode([
+ 'percentage' => config('app.saas_stripe_application_fee_percent'),
+ 'fixed' => config('app.saas_stripe_application_fee_fixed') ?? 0,
+ ], JSON_THROW_ON_ERROR),
+ 'bypass_application_fees' => false,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ $defaultConfigId = DB::table('organizer_configurations')
+ ->where('is_system_default', true)
+ ->orderBy('id', 'asc')
+ ->value('id');
+
+ Schema::table('organizers', function (Blueprint $table) {
+ $table->foreignId('organizer_configuration_id')
+ ->nullable()
+ ->constrained('organizer_configurations')
+ ->onDelete('set null');
+ });
+
+ // Map each organizer to the new row that mirrors its parent account's plan.
+ // Falls back to the system default if the account had no plan assigned.
+ DB::statement("
+ UPDATE organizers o
+ SET organizer_configuration_id = COALESCE((
+ SELECT oc.id
+ FROM organizer_configurations oc
+ JOIN accounts a ON a.account_configuration_id = oc.legacy_account_configuration_id
+ WHERE a.id = o.account_id
+ LIMIT 1
+ ), ?)
+ WHERE o.deleted_at IS NULL
+ ", [$defaultConfigId]);
+ }
+
+ public function down(): void
+ {
+ Schema::table('organizers', function (Blueprint $table) {
+ $table->dropForeign(['organizer_configuration_id']);
+ $table->dropColumn('organizer_configuration_id');
+ });
+
+ Schema::dropIfExists('organizer_configurations');
+ }
+};
diff --git a/backend/phpunit.xml b/backend/phpunit.xml
index 8fec6e6b02..0d665f89e7 100644
--- a/backend/phpunit.xml
+++ b/backend/phpunit.xml
@@ -22,7 +22,7 @@
-
+
diff --git a/backend/resources/views/emails/occurrence/cancellation.blade.php b/backend/resources/views/emails/occurrence/cancellation.blade.php
new file mode 100644
index 0000000000..c6149200c1
--- /dev/null
+++ b/backend/resources/views/emails/occurrence/cancellation.blade.php
@@ -0,0 +1,32 @@
+@php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp
+@php /** @var \HiEvents\DomainObjects\EventOccurrenceDomainObject $occurrence */ @endphp
+@php /** @var \HiEvents\DomainObjects\OrganizerDomainObject $organizer */ @endphp
+@php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp
+@php /** @var string $formattedDate */ @endphp
+@php /** @var string $eventUrl */ @endphp
+@php /** @var bool $refundOrders */ @endphp
+
+@php /** @see \HiEvents\Mail\Occurrence\OccurrenceCancellationMail */ @endphp
+
+
+# {{ $event->getTitle() }}
+
+{{ __('Hello') }},
+
+{{ __('We\'re sorry to let you know that **:event** scheduled for **:date** has been cancelled.', ['event' => $event->getTitle(), 'date' => $formattedDate]) }}
+
+@if($refundOrders)
+{{ __('A refund for your order will be processed automatically. Please allow a few business days for the refund to appear on your statement.') }}
+@else
+{{ __('If you have any questions about your order, please respond to this email.') }}
+@endif
+
+
+{{ __('View Event') }}
+
+
+{{ __('Thank you') }},
+{{ $organizer->getName() ?: config('app.name') }}
+
+{!! $eventSettings->getGetEmailFooterHtml() !!}
+
diff --git a/backend/resources/views/emails/orders/attendee-ticket.blade.php b/backend/resources/views/emails/orders/attendee-ticket.blade.php
index 296ac24b2d..f0d469f43c 100644
--- a/backend/resources/views/emails/orders/attendee-ticket.blade.php
+++ b/backend/resources/views/emails/orders/attendee-ticket.blade.php
@@ -1,14 +1,38 @@
-@php use HiEvents\Helper\DateHelper; @endphp
-@php /** @uses \HiEvents\Mail\Order\OrderSummary */ @endphp
+@php use Carbon\Carbon; use HiEvents\Helper\DateHelper; @endphp
@php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp
@php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp
@php /** @var \HiEvents\DomainObjects\OrganizerDomainObject $organizer */ @endphp
@php /** @var \HiEvents\DomainObjects\AttendeeDomainObject $attendee */ @endphp
@php /** @var \HiEvents\DomainObjects\OrderDomainObject $order */ @endphp
-
+@php /** @var \HiEvents\DomainObjects\EventOccurrenceDomainObject|null $occurrence */ @endphp
@php /** @var string $ticketUrl */ @endphp
@php /** @see \HiEvents\Mail\Attendee\AttendeeTicketMail */ @endphp
+@php
+ $tz = $event->getTimezone();
+ $displayStart = $occurrence?->getStartDate() ?? $event->getStartDate();
+ $displayEnd = $occurrence?->getEndDate() ?? $event->getEndDate();
+
+ $formatDateTime = static fn(?string $utc) => $utc
+ ? (new Carbon(DateHelper::convertFromUTC($utc, $tz)))->format('D, M j, Y · g:i A')
+ : null;
+ $formatTime = static fn(?string $utc) => $utc
+ ? (new Carbon(DateHelper::convertFromUTC($utc, $tz)))->format('g:i A')
+ : null;
+
+ $startFormatted = $formatDateTime($displayStart);
+ $endFormatted = null;
+ if ($displayStart && $displayEnd) {
+ // Same day → show just the end time; cross-day → show the full end timestamp.
+ $sameDay = substr($displayStart, 0, 10) === substr($displayEnd, 0, 10);
+ $endFormatted = $sameDay ? $formatTime($displayEnd) : $formatDateTime($displayEnd);
+ }
+
+ $venueName = $eventSettings->getLocationDetails()['venue_name'] ?? null;
+ $addressString = $eventSettings->getIsOnlineEvent() ? null : $eventSettings->getAddressString();
+ $productTitle = $attendee->getProduct()?->getTitle();
+@endphp
+
# {{ __('You\'re going to') }} {{ $event->getTitle() }}! 🎉
@@ -23,6 +47,24 @@
{{ __('Please find your ticket details below.') }}
+@if($startFormatted || $venueName || $addressString || $productTitle)
+
+@if($startFormatted)
+{{ __('Date & Time:') }} {{ $startFormatted }}@if($endFormatted) – {{ $endFormatted }}@endif
+@if($occurrence?->getLabel())
+{{ __('Session:') }} {{ $occurrence->getLabel() }}
+@endif
+@endif
+@if($venueName || $addressString)
+{{ __('Location:') }} {{ trim(($venueName ? $venueName . ($addressString ? ', ' : '') : '') . ($addressString ?? '')) }}
+@endif
+@if($productTitle)
+{{ __('Ticket:') }} {{ $productTitle }}
+@endif
+{{ __('Attendee:') }} {{ trim($attendee->getFirstName() . ' ' . $attendee->getLastName()) }}
+
+@endif
+
{{ __('View Ticket') }}
diff --git a/backend/resources/views/emails/orders/summary.blade.php b/backend/resources/views/emails/orders/summary.blade.php
index ab596db483..bdd61636ce 100644
--- a/backend/resources/views/emails/orders/summary.blade.php
+++ b/backend/resources/views/emails/orders/summary.blade.php
@@ -3,17 +3,24 @@
@php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp
@php /** @var \HiEvents\DomainObjects\OrganizerDomainObject $organizer */ @endphp
@php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp
+@php /** @var \HiEvents\DomainObjects\EventOccurrenceDomainObject|null $occurrence */ @endphp
@php /** @var string $orderUrl */ @endphp
@php /** @see \HiEvents\Mail\Order\OrderSummary */ @endphp
+@php
+ $displayStart = $occurrence?->getStartDate() ?? $event->getStartDate();
+ $displayDate = (new Carbon(DateHelper::convertFromUTC($displayStart, $event->getTimezone())))->format('F j, Y');
+ $displayTime = (new Carbon(DateHelper::convertFromUTC($displayStart, $event->getTimezone())))->format('g:i A');
+@endphp
+
# {{ __('Your Order is Confirmed! ') }} 🎉
@if($order->isOrderAwaitingOfflinePayment() === false)
-{{ __('Congratulations! Your order for :eventTitle on :eventDate at :eventTime was successful. Please find your order details below.', ['eventTitle' => $event->getTitle(), 'eventDate' => (new Carbon(DateHelper::convertFromUTC($event->getStartDate(), $event->getTimezone())))->format('F j, Y'), 'eventTime' => (new Carbon(DateHelper::convertFromUTC($event->getStartDate(), $event->getTimezone())))->format('g:i A')]) }}
+{{ __('Congratulations! Your order for :eventTitle on :eventDate at :eventTime was successful. Please find your order details below.', ['eventTitle' => $event->getTitle(), 'eventDate' => $displayDate, 'eventTime' => $displayTime]) }}
@else
@@ -37,7 +44,11 @@
# {{ __('Event Details') }}
**{{ __('Event Name:') }}** {{ $event->getTitle() }}
-**{{ __('Date & Time:') }}** {{ __(':date at :time', ['date' => (new Carbon(DateHelper::convertFromUTC($event->getStartDate(), $event->getTimezone())))->format('F j, Y'), 'time' => (new Carbon(DateHelper::convertFromUTC($event->getStartDate(), $event->getTimezone())))->format('g:i A')]) }}
+**{{ __('Date & Time:') }}** {{ __(':date at :time', ['date' => $displayDate, 'time' => $displayTime]) }}
+@if($occurrence?->getLabel())
+
+**{{ __('Session:') }}** {{ $occurrence->getLabel() }}
+@endif
diff --git a/backend/resources/views/emails/waitlist/confirmation.blade.php b/backend/resources/views/emails/waitlist/confirmation.blade.php
index 5881800296..80f9067886 100644
--- a/backend/resources/views/emails/waitlist/confirmation.blade.php
+++ b/backend/resources/views/emails/waitlist/confirmation.blade.php
@@ -1,6 +1,7 @@
@php /** @var \HiEvents\DomainObjects\WaitlistEntryDomainObject $entry */ @endphp
@php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp
@php /** @var ?string $productName */ @endphp
+@php /** @var ?string $occurrenceDateFormatted */ @endphp
@php /** @var \HiEvents\DomainObjects\OrganizerDomainObject $organizer */ @endphp
@php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp
@php /** @var string $eventUrl */ @endphp
@@ -12,7 +13,11 @@
{{ __('Hello') }},
-@if($productName)
+@if($occurrenceDateFormatted && $productName)
+{{ __("You have been added to the waitlist for **:product** on **:date** for the event **:event**.", ['product' => $productName, 'date' => $occurrenceDateFormatted, 'event' => $event->getTitle()]) }}
+@elseif($occurrenceDateFormatted)
+{{ __("You have been added to the waitlist on **:date** for the event **:event**.", ['date' => $occurrenceDateFormatted, 'event' => $event->getTitle()]) }}
+@elseif($productName)
{{ __("You have been added to the waitlist for **:product** for the event **:event**.", ['product' => $productName, 'event' => $event->getTitle()]) }}
@else
{{ __("You have been added to the waitlist for the event **:event**.", ['event' => $event->getTitle()]) }}
diff --git a/backend/resources/views/emails/waitlist/offer-expired.blade.php b/backend/resources/views/emails/waitlist/offer-expired.blade.php
index 2e530b286c..70df3c09ff 100644
--- a/backend/resources/views/emails/waitlist/offer-expired.blade.php
+++ b/backend/resources/views/emails/waitlist/offer-expired.blade.php
@@ -1,6 +1,7 @@
@php /** @var \HiEvents\DomainObjects\WaitlistEntryDomainObject $entry */ @endphp
@php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp
@php /** @var ?string $productName */ @endphp
+@php /** @var ?string $occurrenceDateFormatted */ @endphp
@php /** @var \HiEvents\DomainObjects\OrganizerDomainObject $organizer */ @endphp
@php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp
@php /** @var string $eventUrl */ @endphp
@@ -12,7 +13,11 @@
{{ __('Hello') }},
-@if($productName)
+@if($occurrenceDateFormatted && $productName)
+{{ __('Unfortunately, your waitlist offer for **:product** on **:date** for the event **:event** has expired.', ['product' => $productName, 'date' => $occurrenceDateFormatted, 'event' => $event->getTitle()]) }}
+@elseif($occurrenceDateFormatted)
+{{ __('Unfortunately, your waitlist offer on **:date** for the event **:event** has expired.', ['date' => $occurrenceDateFormatted, 'event' => $event->getTitle()]) }}
+@elseif($productName)
{{ __('Unfortunately, your waitlist offer for **:product** for the event **:event** has expired.', ['product' => $productName, 'event' => $event->getTitle()]) }}
@else
{{ __('Unfortunately, your waitlist offer for the event **:event** has expired.', ['event' => $event->getTitle()]) }}
diff --git a/backend/resources/views/emails/waitlist/offer.blade.php b/backend/resources/views/emails/waitlist/offer.blade.php
index 2174f77f3a..88315ad723 100644
--- a/backend/resources/views/emails/waitlist/offer.blade.php
+++ b/backend/resources/views/emails/waitlist/offer.blade.php
@@ -1,6 +1,7 @@
@php /** @var \HiEvents\DomainObjects\WaitlistEntryDomainObject $entry */ @endphp
@php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp
@php /** @var ?string $productName */ @endphp
+@php /** @var ?string $occurrenceDateFormatted */ @endphp
@php /** @var \HiEvents\DomainObjects\OrganizerDomainObject $organizer */ @endphp
@php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp
@php /** @var string $checkoutUrl */ @endphp
@@ -12,7 +13,11 @@
{{ __('Hello') }},
-@if($productName)
+@if($occurrenceDateFormatted && $productName)
+{{ __('Great news! A spot has become available for **:product** on **:date** for the event **:event**.', ['product' => $productName, 'date' => $occurrenceDateFormatted, 'event' => $event->getTitle()]) }}
+@elseif($occurrenceDateFormatted)
+{{ __('Great news! A spot has become available on **:date** for the event **:event**.', ['date' => $occurrenceDateFormatted, 'event' => $event->getTitle()]) }}
+@elseif($productName)
{{ __('Great news! A spot has become available for **:product** for the event **:event**.', ['product' => $productName, 'event' => $event->getTitle()]) }}
@else
{{ __('Great news! A spot has become available for the event **:event**.', ['event' => $event->getTitle()]) }}
diff --git a/backend/routes/api.php b/backend/routes/api.php
index e3947d0804..4059bc9812 100644
--- a/backend/routes/api.php
+++ b/backend/routes/api.php
@@ -2,11 +2,40 @@
use HiEvents\Http\Actions\Accounts\CreateAccountAction;
use HiEvents\Http\Actions\Accounts\GetAccountAction;
-use HiEvents\Http\Actions\Accounts\Stripe\CreateStripeConnectAccountAction;
-use HiEvents\Http\Actions\Accounts\Stripe\GetStripeConnectAccountsAction;
+use HiEvents\Http\Actions\Organizers\Stripe\CopyStripeConnectAccountAction;
+use HiEvents\Http\Actions\Organizers\Stripe\CreateStripeConnectAccountAction;
+use HiEvents\Http\Actions\Organizers\Stripe\GetStripeConnectAccountsAction;
use HiEvents\Http\Actions\Accounts\UpdateAccountAction;
-use HiEvents\Http\Actions\Accounts\Vat\GetAccountVatSettingAction;
-use HiEvents\Http\Actions\Accounts\Vat\UpsertAccountVatSettingAction;
+use HiEvents\Http\Actions\Organizers\Vat\GetOrganizerVatSettingAction;
+use HiEvents\Http\Actions\Organizers\Vat\UpsertOrganizerVatSettingAction;
+use HiEvents\Http\Actions\Admin\Organizers\AssignOrganizerConfigurationAction;
+use HiEvents\Http\Actions\Admin\Organizers\UpdateOrganizerConfigurationAction;
+use HiEvents\Http\Actions\Admin\Organizers\UpdateOrganizerVatSettingAction;
+use HiEvents\Http\Actions\Admin\Accounts\GetAccountAction as GetAdminAccountAction;
+use HiEvents\Http\Actions\Admin\Accounts\GetAllAccountsAction as GetAllAdminAccountsAction;
+use HiEvents\Http\Actions\Admin\Accounts\UpdateAccountMessagingTierAction;
+use HiEvents\Http\Actions\Admin\Attribution\GetUtmAttributionStatsAction;
+use HiEvents\Http\Actions\Admin\Configurations\CreateConfigurationAction;
+use HiEvents\Http\Actions\Admin\Configurations\DeleteConfigurationAction;
+use HiEvents\Http\Actions\Admin\Configurations\GetAllConfigurationsAction;
+use HiEvents\Http\Actions\Admin\Configurations\UpdateConfigurationAction;
+use HiEvents\Http\Actions\Admin\Events\GetAllEventsAction as GetAllAdminEventsAction;
+use HiEvents\Http\Actions\Admin\Events\GetUpcomingEventsAction;
+use HiEvents\Http\Actions\Admin\FailedJobs\DeleteAllFailedJobsAction;
+use HiEvents\Http\Actions\Admin\FailedJobs\DeleteFailedJobAction;
+use HiEvents\Http\Actions\Admin\FailedJobs\GetAllFailedJobsAction;
+use HiEvents\Http\Actions\Admin\FailedJobs\RetryAllFailedJobsAction;
+use HiEvents\Http\Actions\Admin\FailedJobs\RetryFailedJobAction;
+use HiEvents\Http\Actions\Admin\GetMessagingTiersAction;
+use HiEvents\Http\Actions\Admin\GetSystemInfoAction;
+use HiEvents\Http\Actions\Admin\Messages\ApproveMessageAction;
+use HiEvents\Http\Actions\Admin\Messages\GetAllMessagesAction as GetAllAdminMessagesAction;
+use HiEvents\Http\Actions\Admin\Orders\GetAllOrdersAction;
+use HiEvents\Http\Actions\Admin\Stats\GetAdminDashboardDataAction;
+use HiEvents\Http\Actions\Admin\Stats\GetAdminStatsAction;
+use HiEvents\Http\Actions\Admin\Users\GetAllUsersAction;
+use HiEvents\Http\Actions\Admin\Users\StartImpersonationAction;
+use HiEvents\Http\Actions\Admin\Users\StopImpersonationAction;
use HiEvents\Http\Actions\Affiliates\CreateAffiliateAction;
use HiEvents\Http\Actions\Affiliates\DeleteAffiliateAction;
use HiEvents\Http\Actions\Affiliates\ExportAffiliatesAction;
@@ -41,15 +70,45 @@
use HiEvents\Http\Actions\CheckInLists\GetCheckInListsAction;
use HiEvents\Http\Actions\CheckInLists\Public\CreateAttendeeCheckInPublicAction;
use HiEvents\Http\Actions\CheckInLists\Public\DeleteAttendeeCheckInPublicAction;
+use HiEvents\Http\Actions\CheckInLists\Public\GetCheckInListAttendeeDetailPublicAction;
use HiEvents\Http\Actions\CheckInLists\Public\GetCheckInListAttendeePublicAction;
use HiEvents\Http\Actions\CheckInLists\Public\GetCheckInListAttendeesPublicAction;
use HiEvents\Http\Actions\CheckInLists\Public\GetCheckInListPublicAction;
+use HiEvents\Http\Actions\CheckInLists\Public\GetCheckInListStatsPublicAction;
use HiEvents\Http\Actions\CheckInLists\UpdateCheckInListAction;
use HiEvents\Http\Actions\Common\GetColorThemesAction;
use HiEvents\Http\Actions\Common\Webhooks\StripeIncomingWebhookAction;
+use HiEvents\Http\Actions\EmailTemplates\CreateEventEmailTemplateAction;
+use HiEvents\Http\Actions\EmailTemplates\CreateOrganizerEmailTemplateAction;
+use HiEvents\Http\Actions\EmailTemplates\DeleteEventEmailTemplateAction;
+use HiEvents\Http\Actions\EmailTemplates\DeleteOrganizerEmailTemplateAction;
+use HiEvents\Http\Actions\EmailTemplates\GetAvailableTokensAction;
+use HiEvents\Http\Actions\EmailTemplates\GetDefaultEmailTemplateAction;
+use HiEvents\Http\Actions\EmailTemplates\GetEventEmailTemplatesAction;
+use HiEvents\Http\Actions\EmailTemplates\GetOrganizerEmailTemplatesAction;
+use HiEvents\Http\Actions\EmailTemplates\PreviewEventEmailTemplateAction;
+use HiEvents\Http\Actions\EmailTemplates\PreviewOrganizerEmailTemplateAction;
+use HiEvents\Http\Actions\EmailTemplates\UpdateEventEmailTemplateAction;
+use HiEvents\Http\Actions\EmailTemplates\UpdateOrganizerEmailTemplateAction;
+use HiEvents\Http\Actions\EventOccurrences\BulkUpdateOccurrencesAction;
+use HiEvents\Http\Actions\EventOccurrences\CancelOccurrenceAction;
+use HiEvents\Http\Actions\EventOccurrences\CreateEventOccurrenceAction;
+use HiEvents\Http\Actions\EventOccurrences\DeleteEventOccurrenceAction;
+use HiEvents\Http\Actions\EventOccurrences\DeletePriceOverrideAction;
+use HiEvents\Http\Actions\EventOccurrences\GenerateOccurrencesAction;
+use HiEvents\Http\Actions\EventOccurrences\GetEventOccurrenceAction;
+use HiEvents\Http\Actions\EventOccurrences\GetEventOccurrencesAction;
+use HiEvents\Http\Actions\EventOccurrences\GetPriceOverridesAction;
+use HiEvents\Http\Actions\EventOccurrences\GetProductVisibilityAction;
+use HiEvents\Http\Actions\EventOccurrences\ReactivateOccurrenceAction;
+use HiEvents\Http\Actions\EventOccurrences\UpdateEventOccurrenceAction;
+use HiEvents\Http\Actions\EventOccurrences\UpdateProductVisibilityAction;
+use HiEvents\Http\Actions\EventOccurrences\UpsertPriceOverrideAction;
use HiEvents\Http\Actions\Events\CreateEventAction;
+use HiEvents\Http\Actions\Events\DeleteEventAction;
use HiEvents\Http\Actions\Events\DuplicateEventAction;
use HiEvents\Http\Actions\Events\GetEventAction;
+use HiEvents\Http\Actions\Events\GetEventDeletionStatusAction;
use HiEvents\Http\Actions\Events\GetEventPublicAction;
use HiEvents\Http\Actions\Events\GetEventsAction;
use HiEvents\Http\Actions\Events\GetOrganizerEventsPublicAction;
@@ -58,24 +117,10 @@
use HiEvents\Http\Actions\Events\Images\GetEventImagesAction;
use HiEvents\Http\Actions\Events\Stats\GetEventStatsAction;
use HiEvents\Http\Actions\Events\UpdateEventAction;
-use HiEvents\Http\Actions\Events\DeleteEventAction;
-use HiEvents\Http\Actions\Events\GetEventDeletionStatusAction;
use HiEvents\Http\Actions\Events\UpdateEventStatusAction;
use HiEvents\Http\Actions\EventSettings\EditEventSettingsAction;
use HiEvents\Http\Actions\EventSettings\GetEventSettingsAction;
use HiEvents\Http\Actions\EventSettings\GetPlatformFeePreviewAction;
-use HiEvents\Http\Actions\EmailTemplates\CreateOrganizerEmailTemplateAction;
-use HiEvents\Http\Actions\EmailTemplates\CreateEventEmailTemplateAction;
-use HiEvents\Http\Actions\EmailTemplates\UpdateOrganizerEmailTemplateAction;
-use HiEvents\Http\Actions\EmailTemplates\UpdateEventEmailTemplateAction;
-use HiEvents\Http\Actions\EmailTemplates\GetOrganizerEmailTemplatesAction;
-use HiEvents\Http\Actions\EmailTemplates\GetEventEmailTemplatesAction;
-use HiEvents\Http\Actions\EmailTemplates\DeleteOrganizerEmailTemplateAction;
-use HiEvents\Http\Actions\EmailTemplates\DeleteEventEmailTemplateAction;
-use HiEvents\Http\Actions\EmailTemplates\PreviewOrganizerEmailTemplateAction;
-use HiEvents\Http\Actions\EmailTemplates\PreviewEventEmailTemplateAction;
-use HiEvents\Http\Actions\EmailTemplates\GetAvailableTokensAction;
-use HiEvents\Http\Actions\EmailTemplates\GetDefaultEmailTemplateAction;
use HiEvents\Http\Actions\EventSettings\PartialEditEventSettingsAction;
use HiEvents\Http\Actions\Images\CreateImageAction;
use HiEvents\Http\Actions\Images\DeleteImageAction;
@@ -102,12 +147,10 @@
use HiEvents\Http\Actions\Orders\Public\TransitionOrderToOfflinePaymentPublicAction;
use HiEvents\Http\Actions\Orders\ResendOrderConfirmationAction;
use HiEvents\Http\Actions\Organizers\CreateOrganizerAction;
-use HiEvents\Http\Actions\SelfService\EditAttendeePublicAction;
-use HiEvents\Http\Actions\SelfService\EditOrderPublicAction;
-use HiEvents\Http\Actions\SelfService\ResendAttendeeTicketPublicAction;
-use HiEvents\Http\Actions\SelfService\ResendOrderConfirmationPublicAction;
+use HiEvents\Http\Actions\Organizers\DeleteOrganizerAction;
use HiEvents\Http\Actions\Organizers\EditOrganizerAction;
use HiEvents\Http\Actions\Organizers\GetOrganizerAction;
+use HiEvents\Http\Actions\Organizers\GetOrganizerDeletionStatusAction;
use HiEvents\Http\Actions\Organizers\GetOrganizerEventsAction;
use HiEvents\Http\Actions\Organizers\GetOrganizersAction;
use HiEvents\Http\Actions\Organizers\GetPublicOrganizerAction;
@@ -116,8 +159,6 @@
use HiEvents\Http\Actions\Organizers\Settings\GetOrganizerSettingsAction;
use HiEvents\Http\Actions\Organizers\Settings\PartialUpdateOrganizerSettingsAction;
use HiEvents\Http\Actions\Organizers\Stats\GetOrganizerStatsAction;
-use HiEvents\Http\Actions\Organizers\DeleteOrganizerAction;
-use HiEvents\Http\Actions\Organizers\GetOrganizerDeletionStatusAction;
use HiEvents\Http\Actions\Organizers\UpdateOrganizerStatusAction;
use HiEvents\Http\Actions\Organizers\Webhooks\CreateOrganizerWebhookAction;
use HiEvents\Http\Actions\Organizers\Webhooks\DeleteOrganizerWebhookAction;
@@ -154,6 +195,10 @@
use HiEvents\Http\Actions\Reports\ExportOrganizerReportAction;
use HiEvents\Http\Actions\Reports\GetOrganizerReportAction;
use HiEvents\Http\Actions\Reports\GetReportAction;
+use HiEvents\Http\Actions\SelfService\EditAttendeePublicAction;
+use HiEvents\Http\Actions\SelfService\EditOrderPublicAction;
+use HiEvents\Http\Actions\SelfService\ResendAttendeeTicketPublicAction;
+use HiEvents\Http\Actions\SelfService\ResendOrderConfirmationPublicAction;
use HiEvents\Http\Actions\Sitemap\GetSitemapEventsAction;
use HiEvents\Http\Actions\Sitemap\GetSitemapIndexAction;
use HiEvents\Http\Actions\Sitemap\GetSitemapOrganizersAction;
@@ -161,6 +206,8 @@
use HiEvents\Http\Actions\TaxesAndFees\DeleteTaxOrFeeAction;
use HiEvents\Http\Actions\TaxesAndFees\EditTaxOrFeeAction;
use HiEvents\Http\Actions\TaxesAndFees\GetTaxOrFeeAction;
+use HiEvents\Http\Actions\TicketLookup\GetOrdersByLookupTokenAction;
+use HiEvents\Http\Actions\TicketLookup\SendTicketLookupEmailAction;
use HiEvents\Http\Actions\Users\CancelEmailChangeAction;
use HiEvents\Http\Actions\Users\ConfirmEmailAddressAction;
use HiEvents\Http\Actions\Users\ConfirmEmailChangeAction;
@@ -174,35 +221,6 @@
use HiEvents\Http\Actions\Users\ResendInvitationAction;
use HiEvents\Http\Actions\Users\UpdateMeAction;
use HiEvents\Http\Actions\Users\UpdateUserAction;
-use HiEvents\Http\Actions\Admin\Accounts\AssignConfigurationAction;
-use HiEvents\Http\Actions\Admin\Accounts\GetAccountAction as GetAdminAccountAction;
-use HiEvents\Http\Actions\Admin\Accounts\GetAllAccountsAction as GetAllAdminAccountsAction;
-use HiEvents\Http\Actions\Admin\Accounts\UpdateAccountVatSettingAction as UpdateAdminAccountVatSettingAction;
-use HiEvents\Http\Actions\Admin\Configurations\CreateConfigurationAction;
-use HiEvents\Http\Actions\Admin\Configurations\DeleteConfigurationAction;
-use HiEvents\Http\Actions\Admin\Configurations\GetAllConfigurationsAction;
-use HiEvents\Http\Actions\Admin\Configurations\UpdateConfigurationAction;
-use HiEvents\Http\Actions\Admin\Events\GetAllEventsAction as GetAllAdminEventsAction;
-use HiEvents\Http\Actions\Admin\Events\GetUpcomingEventsAction;
-use HiEvents\Http\Actions\Admin\FailedJobs\DeleteAllFailedJobsAction;
-use HiEvents\Http\Actions\Admin\FailedJobs\DeleteFailedJobAction;
-use HiEvents\Http\Actions\Admin\FailedJobs\GetAllFailedJobsAction;
-use HiEvents\Http\Actions\Admin\FailedJobs\RetryAllFailedJobsAction;
-use HiEvents\Http\Actions\Admin\FailedJobs\RetryFailedJobAction;
-use HiEvents\Http\Actions\Admin\Messages\ApproveMessageAction;
-use HiEvents\Http\Actions\Admin\Messages\GetAllMessagesAction as GetAllAdminMessagesAction;
-use HiEvents\Http\Actions\Admin\GetMessagingTiersAction;
-use HiEvents\Http\Actions\Admin\Accounts\UpdateAccountMessagingTierAction;
-use HiEvents\Http\Actions\Admin\Orders\GetAllOrdersAction;
-use HiEvents\Http\Actions\Admin\Attribution\GetUtmAttributionStatsAction;
-use HiEvents\Http\Actions\Admin\GetSystemInfoAction;
-use HiEvents\Http\Actions\Admin\Stats\GetAdminDashboardDataAction;
-use HiEvents\Http\Actions\Admin\Stats\GetAdminStatsAction;
-use HiEvents\Http\Actions\Admin\Users\GetAllUsersAction;
-use HiEvents\Http\Actions\Admin\Users\StartImpersonationAction;
-use HiEvents\Http\Actions\Admin\Users\StopImpersonationAction;
-use HiEvents\Http\Actions\TicketLookup\GetOrdersByLookupTokenAction;
-use HiEvents\Http\Actions\TicketLookup\SendTicketLookupEmailAction;
use HiEvents\Http\Actions\Waitlist\Organizer\CancelWaitlistEntryAction;
use HiEvents\Http\Actions\Waitlist\Organizer\GetWaitlistEntriesAction;
use HiEvents\Http\Actions\Waitlist\Organizer\GetWaitlistStatsAction;
@@ -265,12 +283,6 @@ function (Router $router): void {
// Accounts
$router->get('/accounts/{account_id?}', GetAccountAction::class);
$router->put('/accounts/{account_id?}', UpdateAccountAction::class);
- $router->get('/accounts/{account_id}/stripe/connect_accounts', GetStripeConnectAccountsAction::class);
- $router->post('/accounts/{account_id}/stripe/connect', CreateStripeConnectAccountAction::class);
-
- // VAT Settings
- $router->get('/accounts/{account_id}/vat-settings', GetAccountVatSettingAction::class);
- $router->post('/accounts/{account_id}/vat-settings', UpsertAccountVatSettingAction::class);
// Organizers
$router->post('/organizers', CreateOrganizerAction::class);
@@ -295,6 +307,15 @@ function (Router $router): void {
$router->delete('/organizers/{organizer_id}/webhooks/{webhook_id}', DeleteOrganizerWebhookAction::class);
$router->get('/organizers/{organizer_id}/webhooks/{webhook_id}/logs', GetOrganizerWebhookLogsAction::class);
+ // Stripe Connect - Organizer level
+ $router->get('/organizers/{organizerId}/stripe/connect_accounts', GetStripeConnectAccountsAction::class);
+ $router->post('/organizers/{organizerId}/stripe/connect', CreateStripeConnectAccountAction::class);
+ $router->post('/organizers/{organizerId}/stripe/copy_from/{sourceOrganizerId}', CopyStripeConnectAccountAction::class);
+
+ // VAT Settings - Organizer level
+ $router->get('/organizers/{organizerId}/vat-settings', GetOrganizerVatSettingAction::class);
+ $router->post('/organizers/{organizerId}/vat-settings', UpsertOrganizerVatSettingAction::class);
+
// Email Templates - Organizer level
$router->get('/organizers/{organizerId}/email-templates', GetOrganizerEmailTemplatesAction::class);
$router->get('/email-templates/defaults', GetDefaultEmailTemplateAction::class);
@@ -441,6 +462,22 @@ function (Router $router): void {
$router->post('/events/{event_id}/waitlist/offer-next', OfferWaitlistEntryAction::class);
$router->delete('/events/{event_id}/waitlist/{entry_id}', CancelWaitlistEntryAction::class);
+ // Event Occurrences
+ $router->post('/events/{event_id}/occurrences/generate', GenerateOccurrencesAction::class);
+ $router->post('/events/{event_id}/occurrences/bulk-update', BulkUpdateOccurrencesAction::class);
+ $router->post('/events/{event_id}/occurrences', CreateEventOccurrenceAction::class);
+ $router->get('/events/{event_id}/occurrences', GetEventOccurrencesAction::class);
+ $router->get('/events/{event_id}/occurrences/{occurrence_id}', GetEventOccurrenceAction::class);
+ $router->put('/events/{event_id}/occurrences/{occurrence_id}', UpdateEventOccurrenceAction::class);
+ $router->delete('/events/{event_id}/occurrences/{occurrence_id}', DeleteEventOccurrenceAction::class);
+ $router->post('/events/{event_id}/occurrences/{occurrence_id}/cancel', CancelOccurrenceAction::class);
+ $router->post('/events/{event_id}/occurrences/{occurrence_id}/reactivate', ReactivateOccurrenceAction::class);
+ $router->put('/events/{event_id}/occurrences/{occurrence_id}/price-overrides', UpsertPriceOverrideAction::class);
+ $router->get('/events/{event_id}/occurrences/{occurrence_id}/price-overrides', GetPriceOverridesAction::class);
+ $router->delete('/events/{event_id}/occurrences/{occurrence_id}/price-overrides/{override_id}', DeletePriceOverrideAction::class);
+ $router->get('/events/{event_id}/occurrences/{occurrence_id}/product-visibility', GetProductVisibilityAction::class);
+ $router->put('/events/{event_id}/occurrences/{occurrence_id}/product-visibility', UpdateProductVisibilityAction::class);
+
// Images
$router->post('/images', CreateImageAction::class);
$router->delete('/images/{image_id}', DeleteImageAction::class);
@@ -454,8 +491,9 @@ function (Router $router): void {
$router->get('/attribution/stats', GetUtmAttributionStatsAction::class);
$router->get('/accounts', GetAllAdminAccountsAction::class);
$router->get('/accounts/{account_id}', GetAdminAccountAction::class);
- $router->put('/accounts/{account_id}/vat-settings', UpdateAdminAccountVatSettingAction::class);
- $router->put('/accounts/{account_id}/configuration', AssignConfigurationAction::class);
+ $router->put('/organizers/{organizerId}/vat-settings', UpdateOrganizerVatSettingAction::class);
+ $router->patch('/organizers/{organizerId}/configuration', UpdateOrganizerConfigurationAction::class);
+ $router->put('/organizers/{organizerId}/configuration', AssignOrganizerConfigurationAction::class);
$router->get('/configurations', GetAllConfigurationsAction::class);
$router->post('/configurations', CreateConfigurationAction::class);
$router->put('/configurations/{configuration_id}', UpdateConfigurationAction::class);
@@ -535,8 +573,10 @@ function (Router $router): void {
// Check-In
$router->get('/check-in-lists/{check_in_list_short_id}', GetCheckInListPublicAction::class);
+ $router->get('/check-in-lists/{check_in_list_short_id}/stats', GetCheckInListStatsPublicAction::class);
$router->get('/check-in-lists/{check_in_list_short_id}/attendees', GetCheckInListAttendeesPublicAction::class);
$router->get('/check-in-lists/{check_in_list_short_id}/attendees/{attendee_public_id}', GetCheckInListAttendeePublicAction::class);
+ $router->get('/check-in-lists/{check_in_list_short_id}/attendees/{attendee_public_id}/detail', GetCheckInListAttendeeDetailPublicAction::class);
$router->post('/check-in-lists/{check_in_list_short_id}/check-ins', CreateAttendeeCheckInPublicAction::class);
$router->delete('/check-in-lists/{check_in_list_short_id}/check-ins/{check_in_short_id}', DeleteAttendeeCheckInPublicAction::class);
@@ -563,4 +603,4 @@ function (Router $router): void {
}
);
-include_once __DIR__ . '/mail.php';
+include_once __DIR__.'/mail.php';
diff --git a/backend/scripts/createDomainFolderStructure.sh b/backend/scripts/createDomainFolderStructure.sh
deleted file mode 100755
index fbd04f9c25..0000000000
--- a/backend/scripts/createDomainFolderStructure.sh
+++ /dev/null
@@ -1,33 +0,0 @@
-#!/bin/bash
-
-SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
-
-if [ $# -eq 0 ]; then
- echo "No domain name provided. Usage: $0 "
- exit 1
-fi
-
-DOMAIN_NAME=$1
-
-BASE_PATH="$SCRIPT_DIR/../app/Domains/$DOMAIN_NAME"
-
-DIRECTORIES=(
- "Services/Handlers"
- "Http/Requests"
- "Http/DataTransferObjects"
- "Http/Middleware"
- "Http/Actions"
- "Repositories/Contracts"
- "Repositories/Eloquent"
- "Models/Eloquent"
- "Mail"
- "Resources"
- "DomainObjects"
- "Exceptions"
-)
-
-for dir in "${DIRECTORIES[@]}"; do
- mkdir -p "$BASE_PATH/$dir"
-done
-
-echo "Folder structure for '$DOMAIN_NAME' created at $BASE_PATH"
diff --git a/backend/tests/CreatesApplication.php b/backend/tests/CreatesApplication.php
index cc68301129..43b976ec92 100644
--- a/backend/tests/CreatesApplication.php
+++ b/backend/tests/CreatesApplication.php
@@ -4,9 +4,21 @@
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Foundation\Application;
+use Illuminate\Foundation\Testing\DatabaseMigrations;
+use Illuminate\Foundation\Testing\DatabaseTransactions;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use RuntimeException;
trait CreatesApplication
{
+ /**
+ * Tracks whether application migrations have been applied to the test
+ * database during this PHPUnit process. Migrations are expensive (a few
+ * seconds) so we run them at most once per process; subsequent tests
+ * inherit the migrated schema.
+ */
+ private static bool $migrationsApplied = false;
+
/**
* Creates the application.
*/
@@ -16,6 +28,83 @@ public function createApplication(): Application
$app->make(Kernel::class)->bootstrap();
+ // The _test database guard always runs — even for pure unit tests that
+ // never open a connection — so a misconfigured environment can never
+ // touch a non-test database via an accidental query.
+ $this->guardAgainstNonTestDatabase($app);
+
+ // Migrations only run when the current test class actually uses one of
+ // the database testing traits. Pure unit tests skip the (multi-second)
+ // migrate:fresh entirely, so the Unit suite stays fast and runs without
+ // a live Postgres connection.
+ if ($this->currentTestNeedsDatabase()) {
+ $this->ensureTestDatabaseIsMigrated($app);
+ }
+
return $app;
}
+
+ /**
+ * Hard safety net: any test that boots Laravel could (intentionally or not)
+ * issue queries against the configured database. Refuse to run unless the
+ * default connection's database name ends in "_test" so a misconfigured
+ * environment can never touch a dev/staging/prod database.
+ *
+ * Runs as part of createApplication so the check fires before any trait
+ * (DatabaseTransactions, RefreshDatabase, etc.) can open a connection.
+ */
+ private function guardAgainstNonTestDatabase(Application $app): void
+ {
+ $config = $app->make('config');
+ $defaultConnection = $config->get('database.default');
+ $database = $config->get("database.connections.{$defaultConnection}.database");
+
+ if (! is_string($database) || ! str_ends_with($database, '_test')) {
+ throw new RuntimeException(sprintf(
+ 'Refusing to run %s: default database connection "%s" points at "%s", '
+ .'which does not end in "_test". Set DB_DATABASE to a *_test database '
+ .'(CI uses hievents_test; locally configured via backend/.env.testing).',
+ static::class,
+ (string) $defaultConnection,
+ (string) $database,
+ ));
+ }
+ }
+
+ /**
+ * Apply application migrations to the test database exactly once per
+ * PHPUnit process. Runs inside createApplication so it executes BEFORE
+ * any DatabaseTransactions trait opens a wrapping transaction — some
+ * migrations (e.g. CREATE INDEX CONCURRENTLY) refuse to run inside a
+ * transaction block.
+ *
+ * Uses migrate:fresh so a leftover schema from a previous (possibly
+ * crashed) run is wiped clean. Per-test data isolation remains the
+ * responsibility of DatabaseTransactions / RefreshDatabase.
+ */
+ private function ensureTestDatabaseIsMigrated(Application $app): void
+ {
+ if (self::$migrationsApplied) {
+ return;
+ }
+
+ $app->make(Kernel::class)->call('migrate:fresh', ['--force' => true]);
+
+ self::$migrationsApplied = true;
+ }
+
+ /**
+ * Returns true when the currently running test class uses one of Laravel's
+ * database testing traits — the only signal we have at bootstrap time that
+ * the test will actually touch the database. Pure unit tests opt out by
+ * not using any of these traits and so skip migration entirely.
+ */
+ private function currentTestNeedsDatabase(): bool
+ {
+ $traits = class_uses_recursive(static::class);
+
+ return isset($traits[DatabaseTransactions::class])
+ || isset($traits[RefreshDatabase::class])
+ || isset($traits[DatabaseMigrations::class]);
+ }
}
diff --git a/backend/tests/Feature/Http/Actions/EmailTemplates/EmailTemplateTokenTest.php b/backend/tests/Feature/Http/Actions/EmailTemplates/EmailTemplateTokenTest.php
index 030a2f06f5..4689b16594 100644
--- a/backend/tests/Feature/Http/Actions/EmailTemplates/EmailTemplateTokenTest.php
+++ b/backend/tests/Feature/Http/Actions/EmailTemplates/EmailTemplateTokenTest.php
@@ -68,11 +68,11 @@ public function test_can_get_order_confirmation_tokens(): void
$tokens = $response->json('tokens');
$this->assertNotEmpty($tokens);
- $firstNameToken = collect($tokens)->firstWhere('token', '{{ order_first_name }}');
+ $firstNameToken = collect($tokens)->firstWhere('token', '{{ order.first_name }}');
$this->assertNotNull($firstNameToken);
$this->assertEquals('The first name of the person who placed the order', $firstNameToken['description']);
- $lastNameToken = collect($tokens)->firstWhere('token', '{{ order_last_name }}');
+ $lastNameToken = collect($tokens)->firstWhere('token', '{{ order.last_name }}');
$this->assertNotNull($lastNameToken);
$this->assertEquals('The last name of the person who placed the order', $lastNameToken['description']);
}
@@ -97,7 +97,7 @@ public function test_can_get_attendee_ticket_tokens(): void
$tokens = $response->json('tokens');
$this->assertNotEmpty($tokens);
- $attendeeNameToken = collect($tokens)->firstWhere('token', '{{ attendee_name }}');
+ $attendeeNameToken = collect($tokens)->firstWhere('token', '{{ attendee.name }}');
$this->assertNotNull($attendeeNameToken);
}
@@ -128,10 +128,10 @@ public function test_tokens_include_order_specific_tokens(): void
$tokens = $response->json('tokens');
$tokenNames = collect($tokens)->pluck('token')->toArray();
- $this->assertContains('{{ event_title }}', $tokenNames);
- $this->assertContains('{{ order_number }}', $tokenNames);
- $this->assertContains('{{ order_total }}', $tokenNames);
- $this->assertContains('{{ organizer_name }}', $tokenNames);
+ $this->assertContains('{{ event.title }}', $tokenNames);
+ $this->assertContains('{{ order.number }}', $tokenNames);
+ $this->assertContains('{{ order.total }}', $tokenNames);
+ $this->assertContains('{{ organizer.name }}', $tokenNames);
}
public function test_tokens_have_proper_structure(): void
diff --git a/backend/tests/Feature/Repository/BaseRepositoryTest.php b/backend/tests/Feature/Repository/BaseRepositoryTest.php
new file mode 100644
index 0000000000..f49717d291
--- /dev/null
+++ b/backend/tests/Feature/Repository/BaseRepositoryTest.php
@@ -0,0 +1,725 @@
+id();
+ $table->string('name');
+ $table->timestamps();
+ });
+
+ Schema::create('br_test_widgets', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('category_id')->nullable();
+ $table->string('name');
+ $table->string('sku')->nullable();
+ $table->integer('quantity')->default(0);
+ $table->decimal('price', 10, 2)->default(0);
+ $table->boolean('is_active')->default(true);
+ $table->text('description')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ $this->repository = $this->app->make(WidgetRepository::class);
+ $this->categoryRepository = $this->app->make(WidgetCategoryRepository::class);
+ }
+
+ protected function tearDown(): void
+ {
+ Schema::dropIfExists('br_test_widgets');
+ Schema::dropIfExists('br_test_widget_categories');
+
+ parent::tearDown();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Helpers
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private function makeCategory(string $name = 'Default'): WidgetCategoryModel
+ {
+ $category = new WidgetCategoryModel;
+ $category->name = $name;
+ $category->save();
+
+ return $category;
+ }
+
+ private function makeWidget(array $overrides = []): WidgetModel
+ {
+ $widget = new WidgetModel;
+ $widget->fill(array_merge([
+ 'name' => 'Widget '.uniqid('', true),
+ 'sku' => 'SKU-'.uniqid('', true),
+ 'quantity' => 10,
+ 'price' => 9.99,
+ 'is_active' => true,
+ 'category_id' => null,
+ ], $overrides));
+ $widget->save();
+
+ return $widget;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // create / insert
+ // ─────────────────────────────────────────────────────────────────────────
+
+ public function test_create_inserts_a_row_and_hydrates_a_domain_object(): void
+ {
+ $widget = $this->repository->create([
+ 'name' => 'Sprocket',
+ 'sku' => 'SP-001',
+ 'quantity' => 5,
+ 'price' => 12.50,
+ 'is_active' => true,
+ ]);
+
+ $this->assertInstanceOf(WidgetDomainObject::class, $widget);
+ $this->assertNotNull($widget->getId());
+ $this->assertSame('Sprocket', $widget->getName());
+ $this->assertSame(5, $widget->getQuantity());
+ $this->assertSame(12.50, $widget->getPrice());
+ $this->assertTrue($widget->getIsActive());
+
+ $this->assertDatabaseHas('br_test_widgets', ['sku' => 'SP-001']);
+ }
+
+ public function test_insert_bulk_inserts_rows_and_autofills_timestamps(): void
+ {
+ $result = $this->repository->insert([
+ ['name' => 'A', 'sku' => 'A-1', 'quantity' => 1, 'price' => 1, 'is_active' => true],
+ ['name' => 'B', 'sku' => 'B-1', 'quantity' => 2, 'price' => 2, 'is_active' => true],
+ ]);
+
+ $this->assertTrue($result);
+ $this->assertSame(2, WidgetModel::query()->count());
+ // both rows should have timestamps populated by the base repository
+ $this->assertSame(0, WidgetModel::query()->whereNull('created_at')->count());
+ $this->assertSame(0, WidgetModel::query()->whereNull('updated_at')->count());
+ }
+
+ public function test_insert_preserves_caller_supplied_timestamps(): void
+ {
+ $supplied = '2020-01-01 00:00:00';
+
+ $this->repository->insert([
+ [
+ 'name' => 'A',
+ 'sku' => 'A-1',
+ 'quantity' => 1,
+ 'price' => 1,
+ 'is_active' => true,
+ 'created_at' => $supplied,
+ 'updated_at' => $supplied,
+ ],
+ ]);
+
+ $this->assertSame(1, WidgetModel::query()->where('created_at', $supplied)->count());
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // findById / findFirst / findFirstByField / findFirstWhere
+ // ─────────────────────────────────────────────────────────────────────────
+
+ public function test_find_by_id_returns_hydrated_domain_object(): void
+ {
+ $widget = $this->makeWidget(['name' => 'Cog']);
+
+ $found = $this->repository->findById($widget->id);
+
+ $this->assertInstanceOf(WidgetDomainObject::class, $found);
+ $this->assertSame($widget->id, $found->getId());
+ $this->assertSame('Cog', $found->getName());
+ }
+
+ public function test_find_by_id_throws_when_missing(): void
+ {
+ $this->expectException(ModelNotFoundException::class);
+ $this->repository->findById(999_999);
+ }
+
+ public function test_find_first_returns_domain_object_when_present(): void
+ {
+ $widget = $this->makeWidget(['name' => 'Hinge']);
+
+ $found = $this->repository->findFirst($widget->id);
+
+ $this->assertNotNull($found);
+ $this->assertSame('Hinge', $found->getName());
+ }
+
+ public function test_find_first_by_field_returns_match(): void
+ {
+ $this->makeWidget(['sku' => 'UNIQ-1']);
+
+ $found = $this->repository->findFirstByField('sku', 'UNIQ-1');
+
+ $this->assertNotNull($found);
+ $this->assertSame('UNIQ-1', $found->getSku());
+ }
+
+ public function test_find_first_by_field_returns_null_when_no_match(): void
+ {
+ $found = $this->repository->findFirstByField('sku', 'does-not-exist');
+
+ $this->assertNull($found);
+ }
+
+ public function test_find_first_where_returns_first_matching_row(): void
+ {
+ $this->makeWidget(['name' => 'A', 'is_active' => false]);
+ $this->makeWidget(['name' => 'B', 'is_active' => true]);
+
+ $found = $this->repository->findFirstWhere(['is_active' => true]);
+
+ $this->assertNotNull($found);
+ $this->assertSame('B', $found->getName());
+ }
+
+ public function test_find_first_where_returns_null_when_no_match(): void
+ {
+ $this->makeWidget(['is_active' => true]);
+
+ $this->assertNull($this->repository->findFirstWhere(['is_active' => false]));
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // findWhere / findWhereIn / all / countWhere
+ // ─────────────────────────────────────────────────────────────────────────
+
+ public function test_find_where_returns_collection_of_domain_objects(): void
+ {
+ $this->makeWidget(['name' => 'A', 'is_active' => true]);
+ $this->makeWidget(['name' => 'B', 'is_active' => true]);
+ $this->makeWidget(['name' => 'C', 'is_active' => false]);
+
+ $results = $this->repository->findWhere(['is_active' => true]);
+
+ $this->assertInstanceOf(Collection::class, $results);
+ $this->assertCount(2, $results);
+ $this->assertContainsOnlyInstancesOf(WidgetDomainObject::class, $results);
+ }
+
+ public function test_find_where_orders_results_using_order_and_directions(): void
+ {
+ $this->makeWidget(['name' => 'B']);
+ $this->makeWidget(['name' => 'A']);
+ $this->makeWidget(['name' => 'C']);
+
+ $results = $this->repository->findWhere(
+ where: [],
+ orderAndDirections: [new OrderAndDirection('name', 'asc')],
+ );
+
+ $names = $results->map(fn (WidgetDomainObject $w) => $w->getName())->all();
+ $this->assertSame(['A', 'B', 'C'], $names);
+ }
+
+ public function test_find_where_in_filters_by_inclusion_with_additional_where(): void
+ {
+ $w1 = $this->makeWidget(['name' => 'X', 'is_active' => true]);
+ $w2 = $this->makeWidget(['name' => 'Y', 'is_active' => false]);
+ $this->makeWidget(['name' => 'Z', 'is_active' => true]);
+
+ $results = $this->repository->findWhereIn(
+ field: 'id',
+ values: [$w1->id, $w2->id],
+ additionalWhere: ['is_active' => true],
+ );
+
+ $this->assertCount(1, $results);
+ $this->assertSame('X', $results->first()->getName());
+ }
+
+ public function test_all_returns_every_row(): void
+ {
+ $this->makeWidget();
+ $this->makeWidget();
+ $this->makeWidget();
+
+ $this->assertCount(3, $this->repository->all());
+ }
+
+ public function test_count_where_counts_matching_rows(): void
+ {
+ $this->makeWidget(['is_active' => true]);
+ $this->makeWidget(['is_active' => true]);
+ $this->makeWidget(['is_active' => false]);
+
+ $this->assertSame(2, $this->repository->countWhere(['is_active' => true]));
+ $this->assertSame(3, $this->repository->countWhere([]));
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // applyConditions DSL
+ // ─────────────────────────────────────────────────────────────────────────
+
+ public function test_apply_conditions_supports_in_operator(): void
+ {
+ $a = $this->makeWidget();
+ $b = $this->makeWidget();
+ $this->makeWidget();
+
+ $results = $this->repository->findWhere([
+ ['id', 'in', [$a->id, $b->id]],
+ ]);
+
+ $this->assertCount(2, $results);
+ }
+
+ public function test_apply_conditions_supports_not_in_operator(): void
+ {
+ $a = $this->makeWidget();
+ $this->makeWidget();
+ $this->makeWidget();
+
+ $results = $this->repository->findWhere([
+ ['id', 'not in', [$a->id]],
+ ]);
+
+ $this->assertCount(2, $results);
+ }
+
+ public function test_apply_conditions_supports_null_operator(): void
+ {
+ $this->makeWidget(['description' => null]);
+ $this->makeWidget(['description' => 'has text']);
+
+ $results = $this->repository->findWhere([
+ ['description', 'null', null],
+ ]);
+
+ $this->assertCount(1, $results);
+ }
+
+ public function test_apply_conditions_supports_not_null_operator(): void
+ {
+ $this->makeWidget(['description' => null]);
+ $this->makeWidget(['description' => 'has text']);
+
+ $results = $this->repository->findWhere([
+ ['description', 'not null', null],
+ ]);
+
+ $this->assertCount(1, $results);
+ }
+
+ public function test_apply_conditions_supports_comparison_operators(): void
+ {
+ $this->makeWidget(['quantity' => 5]);
+ $this->makeWidget(['quantity' => 10]);
+ $this->makeWidget(['quantity' => 15]);
+
+ $this->assertCount(2, $this->repository->findWhere([['quantity', '>=', 10]]));
+ $this->assertCount(1, $this->repository->findWhere([['quantity', '<', 10]]));
+ $this->assertCount(1, $this->repository->findWhere([['quantity', '=', 15]]));
+ }
+
+ public function test_apply_conditions_treats_simple_pairs_as_equality(): void
+ {
+ $this->makeWidget(['name' => 'foo']);
+ $this->makeWidget(['name' => 'bar']);
+
+ $results = $this->repository->findWhere(['name' => 'foo']);
+
+ $this->assertCount(1, $results);
+ }
+
+ public function test_apply_conditions_supports_callable_value(): void
+ {
+ $this->makeWidget(['name' => 'foo', 'is_active' => true]);
+ $this->makeWidget(['name' => 'bar', 'is_active' => true]);
+ $this->makeWidget(['name' => 'foo', 'is_active' => false]);
+
+ $results = $this->repository->findWhere([
+ 'name' => 'foo',
+ // closure-as-value path through applyConditions
+ fn ($q) => $q->where('is_active', true),
+ ]);
+
+ $this->assertCount(1, $results);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // update / delete
+ // ─────────────────────────────────────────────────────────────────────────
+
+ public function test_update_from_array_persists_changes_and_returns_fresh_object(): void
+ {
+ $widget = $this->makeWidget(['name' => 'old', 'quantity' => 1]);
+
+ $updated = $this->repository->updateFromArray($widget->id, [
+ 'name' => 'new',
+ 'quantity' => 99,
+ ]);
+
+ $this->assertSame('new', $updated->getName());
+ $this->assertSame(99, $updated->getQuantity());
+ $this->assertDatabaseHas('br_test_widgets', ['id' => $widget->id, 'name' => 'new']);
+ }
+
+ public function test_update_where_returns_affected_count(): void
+ {
+ $this->makeWidget(['is_active' => true]);
+ $this->makeWidget(['is_active' => true]);
+ $this->makeWidget(['is_active' => false]);
+
+ $affected = $this->repository->updateWhere(
+ attributes: ['name' => 'renamed'],
+ where: ['is_active' => true],
+ );
+
+ $this->assertSame(2, $affected);
+ $this->assertSame(2, WidgetModel::query()->where('name', 'renamed')->count());
+ }
+
+ public function test_update_by_id_where_updates_when_predicate_matches(): void
+ {
+ $widget = $this->makeWidget(['is_active' => true, 'name' => 'old']);
+
+ $updated = $this->repository->updateByIdWhere(
+ id: $widget->id,
+ attributes: ['name' => 'new'],
+ where: ['is_active' => true],
+ );
+
+ $this->assertSame('new', $updated->getName());
+ }
+
+ public function test_update_by_id_where_throws_when_predicate_does_not_match(): void
+ {
+ $widget = $this->makeWidget(['is_active' => true]);
+
+ $this->expectException(ModelNotFoundException::class);
+ $this->repository->updateByIdWhere(
+ id: $widget->id,
+ attributes: ['name' => 'new'],
+ where: ['is_active' => false],
+ );
+ }
+
+ public function test_delete_by_id_soft_deletes_the_row(): void
+ {
+ $widget = $this->makeWidget();
+
+ $this->assertTrue($this->repository->deleteById($widget->id));
+ $this->assertSoftDeleted('br_test_widgets', ['id' => $widget->id]);
+ }
+
+ public function test_delete_where_returns_affected_count(): void
+ {
+ $this->makeWidget(['is_active' => true]);
+ $this->makeWidget(['is_active' => true]);
+ $this->makeWidget(['is_active' => false]);
+
+ $deleted = $this->repository->deleteWhere(['is_active' => true]);
+
+ $this->assertSame(2, $deleted);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // increment / decrement
+ // ─────────────────────────────────────────────────────────────────────────
+
+ public function test_increment_bumps_an_integer_column(): void
+ {
+ $widget = $this->makeWidget(['quantity' => 10]);
+
+ $this->repository->increment($widget->id, 'quantity', 3);
+
+ $this->assertSame(13, (int) WidgetModel::query()->find($widget->id)->quantity);
+ }
+
+ public function test_increment_supports_float_amount(): void
+ {
+ $widget = $this->makeWidget(['price' => 10.00]);
+
+ $this->repository->increment($widget->id, 'price', 2.50);
+
+ $this->assertSame(12.50, (float) WidgetModel::query()->find($widget->id)->price);
+ }
+
+ public function test_decrement_lowers_an_integer_column(): void
+ {
+ $widget = $this->makeWidget(['quantity' => 10]);
+
+ $this->repository->decrement($widget->id, 'quantity', 4);
+
+ $this->assertSame(6, (int) WidgetModel::query()->find($widget->id)->quantity);
+ }
+
+ public function test_increment_where_bumps_matching_rows(): void
+ {
+ $a = $this->makeWidget(['quantity' => 1, 'is_active' => true]);
+ $b = $this->makeWidget(['quantity' => 1, 'is_active' => true]);
+ $c = $this->makeWidget(['quantity' => 1, 'is_active' => false]);
+
+ $this->repository->incrementWhere(['is_active' => true], 'quantity', 5);
+
+ $this->assertSame(6, (int) WidgetModel::query()->find($a->id)->quantity);
+ $this->assertSame(6, (int) WidgetModel::query()->find($b->id)->quantity);
+ $this->assertSame(1, (int) WidgetModel::query()->find($c->id)->quantity);
+ }
+
+ public function test_increment_each_updates_multiple_columns(): void
+ {
+ $widget = $this->makeWidget(['quantity' => 1, 'price' => 1.00]);
+
+ $this->repository->incrementEach(
+ columns: ['quantity' => 2, 'price' => 3.00],
+ where: ['id' => $widget->id],
+ );
+
+ $fresh = WidgetModel::query()->find($widget->id);
+ $this->assertSame(3, (int) $fresh->quantity);
+ $this->assertSame(4.00, (float) $fresh->price);
+ }
+
+ public function test_decrement_each_updates_multiple_columns(): void
+ {
+ $widget = $this->makeWidget(['quantity' => 10, 'price' => 10.00]);
+
+ $this->repository->decrementEach(
+ where: ['id' => $widget->id],
+ columns: ['quantity' => 2, 'price' => 1.00],
+ );
+
+ $fresh = WidgetModel::query()->find($widget->id);
+ $this->assertSame(8, (int) $fresh->quantity);
+ $this->assertSame(9.00, (float) $fresh->price);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Pagination
+ // ─────────────────────────────────────────────────────────────────────────
+
+ public function test_paginate_returns_a_length_aware_paginator(): void
+ {
+ for ($i = 0; $i < 5; $i++) {
+ $this->makeWidget();
+ }
+
+ $page = $this->repository->paginate(limit: 2);
+
+ $this->assertInstanceOf(LengthAwarePaginator::class, $page);
+ $this->assertSame(5, $page->total());
+ $this->assertCount(2, $page->items());
+ $this->assertContainsOnlyInstancesOf(WidgetDomainObject::class, $page->items());
+ }
+
+ public function test_paginate_where_filters_then_paginates(): void
+ {
+ for ($i = 0; $i < 3; $i++) {
+ $this->makeWidget(['is_active' => true]);
+ }
+ $this->makeWidget(['is_active' => false]);
+
+ $page = $this->repository->paginateWhere(['is_active' => true], limit: 2);
+
+ $this->assertSame(3, $page->total());
+ $this->assertCount(2, $page->items());
+ }
+
+ public function test_simple_paginate_where_returns_a_simple_paginator(): void
+ {
+ for ($i = 0; $i < 4; $i++) {
+ $this->makeWidget(['is_active' => true]);
+ }
+
+ $page = $this->repository->simplePaginateWhere(['is_active' => true], limit: 2);
+
+ $this->assertInstanceOf(Paginator::class, $page);
+ $this->assertCount(2, $page->items());
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Eager loading
+ // ─────────────────────────────────────────────────────────────────────────
+
+ public function test_load_relation_hydrates_a_belongs_to_relation(): void
+ {
+ $category = $this->makeCategory('Tools');
+ $widget = $this->makeWidget(['category_id' => $category->id]);
+
+ $found = $this->repository
+ ->loadRelation(new Relationship(WidgetCategoryDomainObject::class, name: 'category'))
+ ->findById($widget->id);
+
+ $this->assertNotNull($found->getCategory());
+ $this->assertInstanceOf(WidgetCategoryDomainObject::class, $found->getCategory());
+ $this->assertSame('Tools', $found->getCategory()->getName());
+ }
+
+ public function test_load_relation_hydrates_a_has_many_relation_as_a_collection(): void
+ {
+ $category = $this->makeCategory('Bolts');
+ $this->makeWidget(['category_id' => $category->id, 'name' => 'M3']);
+ $this->makeWidget(['category_id' => $category->id, 'name' => 'M4']);
+
+ $found = $this->categoryRepository
+ ->loadRelation(new Relationship(WidgetDomainObject::class, name: 'widgets'))
+ ->findById($category->id);
+
+ $this->assertInstanceOf(Collection::class, $found->getWidgets());
+ $this->assertCount(2, $found->getWidgets());
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Soft deletes / includeDeleted
+ // ─────────────────────────────────────────────────────────────────────────
+
+ public function test_include_deleted_returns_soft_deleted_rows(): void
+ {
+ $widget = $this->makeWidget();
+ $this->repository->deleteById($widget->id);
+
+ $this->assertNull($this->repository->findFirstWhere(['id' => $widget->id]));
+
+ $found = $this->repository->includeDeleted()->findFirstWhere(['id' => $widget->id]);
+ $this->assertNotNull($found);
+ $this->assertSame($widget->id, $found->getId());
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // State reset (the actual point of the refactor)
+ // ─────────────────────────────────────────────────────────────────────────
+
+ public function test_consecutive_finds_do_not_leak_where_clauses(): void
+ {
+ $a = $this->makeWidget(['is_active' => true]);
+ $b = $this->makeWidget(['is_active' => false]);
+
+ // First call applies a where(is_active, true)
+ $first = $this->repository->findWhere(['is_active' => true]);
+ $this->assertCount(1, $first);
+
+ // Second call must NOT inherit the previous where clause
+ $second = $this->repository->findWhere([]);
+ $this->assertCount(2, $second, 'Second findWhere([]) inherited state from the previous query');
+ }
+
+ public function test_eager_loads_are_reset_between_queries(): void
+ {
+ $category = $this->makeCategory('Cat');
+ $widgetA = $this->makeWidget(['category_id' => $category->id]);
+ $widgetB = $this->makeWidget(['category_id' => $category->id]);
+
+ $first = $this->repository
+ ->loadRelation(new Relationship(WidgetCategoryDomainObject::class, name: 'category'))
+ ->findById($widgetA->id);
+ $this->assertNotNull($first->getCategory());
+
+ // After the call, eagerLoads MUST be cleared. Previously this was a bug —
+ // resetModel() reset the builder but left $eagerLoads populated, so the
+ // array would grow unboundedly across calls on the same instance.
+ $this->assertSame([], $this->repository->exposeEagerLoads());
+
+ // A subsequent call without loadRelation() must produce an unhydrated relation.
+ $second = $this->repository->findById($widgetB->id);
+ $this->assertNull($second->getCategory());
+ }
+
+ public function test_state_is_reset_even_when_the_query_throws(): void
+ {
+ $this->makeWidget(['is_active' => true]);
+
+ try {
+ // findById on a missing id throws ModelNotFoundException — but only
+ // AFTER the loadRelation call has registered an eager load and added
+ // a where clause.
+ $this->repository
+ ->loadRelation(new Relationship(WidgetCategoryDomainObject::class, name: 'category'))
+ ->findById(999_999);
+ $this->fail('Expected ModelNotFoundException');
+ } catch (ModelNotFoundException) {
+ // expected
+ }
+
+ // The next call on the same repository instance must start clean.
+ $this->assertSame([], $this->repository->exposeEagerLoads());
+ $this->assertFalse($this->repository->exposeBuilderHasWheres());
+ }
+
+ public function test_set_max_per_page_caps_pagination_size(): void
+ {
+ for ($i = 0; $i < 10; $i++) {
+ $this->makeWidget();
+ }
+
+ $page = $this->repository->setMaxPerPage(3)->paginate(limit: 100);
+
+ $this->assertCount(3, $page->items());
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Hydration edge cases
+ // ─────────────────────────────────────────────────────────────────────────
+
+ public function test_hydration_calls_setters_via_studly_case(): void
+ {
+ // category_id is a snake_case column → setCategoryId on the domain object
+ $category = $this->makeCategory();
+ $widget = $this->makeWidget(['category_id' => $category->id]);
+
+ $found = $this->repository->findById($widget->id);
+
+ $this->assertSame($category->id, $found->getCategoryId());
+ }
+
+ public function test_hydration_silently_skips_columns_with_no_setter(): void
+ {
+ // No setter exists on WidgetDomainObject for an unknown column.
+ // Add a column on the fly via raw SQL so the model picks it up.
+ Schema::table('br_test_widgets', function (Blueprint $table) {
+ $table->string('mystery_field')->nullable();
+ });
+
+ $widget = $this->makeWidget();
+ WidgetModel::query()->where('id', $widget->id)->update(['mystery_field' => 'something']);
+
+ // Should not throw — the silent-skip behaviour is documented.
+ $found = $this->repository->findById($widget->id);
+ $this->assertNotNull($found);
+ }
+}
diff --git a/backend/tests/Feature/Repository/Eloquent/OrderItemRepositoryTest.php b/backend/tests/Feature/Repository/Eloquent/OrderItemRepositoryTest.php
new file mode 100644
index 0000000000..5ab3478bae
--- /dev/null
+++ b/backend/tests/Feature/Repository/Eloquent/OrderItemRepositoryTest.php
@@ -0,0 +1,272 @@
+withAccount() to bootstrap
+ * an account + user, then raw DB inserts for the rest of the FK chain so the test does not
+ * depend on factories that the codebase does not yet provide for organizers / events /
+ * products / orders. The DatabaseTransactions trait rolls everything back per test.
+ */
+class OrderItemRepositoryTest extends TestCase
+{
+ use DatabaseTransactions;
+
+ private OrderItemRepository $repository;
+
+ private int $eventId;
+ private int $occurrenceId;
+ private int $otherOccurrenceId;
+ private int $productId;
+ private int $productPriceId;
+ private int $accountId;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->repository = $this->app->make(OrderItemRepository::class);
+
+ // Bootstrap account + user via factory.
+ $user = User::factory()->withAccount()->create();
+ $this->accountId = $user->accounts()->first()->id;
+
+ $now = now()->toDateTimeString();
+
+ $organizerId = DB::table('organizers')->insertGetId([
+ 'account_id' => $this->accountId,
+ 'name' => 'Test Organizer',
+ 'email' => 'organizer@example.test',
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->eventId = DB::table('events')->insertGetId([
+ 'title' => 'Test Event',
+ 'account_id' => $this->accountId,
+ 'user_id' => $user->id,
+ 'organizer_id' => $organizerId,
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'short_id' => 'test_evt_' . uniqid(),
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->occurrenceId = DB::table('event_occurrences')->insertGetId([
+ 'short_id' => 'occ_' . uniqid(),
+ 'event_id' => $this->eventId,
+ 'start_date' => now()->addDay()->toDateTimeString(),
+ 'end_date' => now()->addDays(1)->addHours(2)->toDateTimeString(),
+ 'status' => 'ACTIVE',
+ 'used_capacity' => 0,
+ 'is_overridden' => false,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->otherOccurrenceId = DB::table('event_occurrences')->insertGetId([
+ 'short_id' => 'occ_' . uniqid(),
+ 'event_id' => $this->eventId,
+ 'start_date' => now()->addDays(2)->toDateTimeString(),
+ 'end_date' => now()->addDays(2)->addHours(2)->toDateTimeString(),
+ 'status' => 'ACTIVE',
+ 'used_capacity' => 0,
+ 'is_overridden' => false,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->productId = DB::table('products')->insertGetId([
+ 'title' => 'Test Product',
+ 'event_id' => $this->eventId,
+ 'order' => 1,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->productPriceId = DB::table('product_prices')->insertGetId([
+ 'product_id' => $this->productId,
+ 'price' => 10.00,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+
+ public function testReturnsZeroWhenNoReservations(): void
+ {
+ $this->assertSame(0, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId));
+ }
+
+ public function testSumsActiveReservationsForOccurrence(): void
+ {
+ $this->insertOrderWithItems(
+ status: OrderStatus::RESERVED->name,
+ reservedUntil: now()->addHour(),
+ occurrenceQuantities: [$this->occurrenceId => 3],
+ );
+ $this->insertOrderWithItems(
+ status: OrderStatus::RESERVED->name,
+ reservedUntil: now()->addMinutes(30),
+ occurrenceQuantities: [$this->occurrenceId => 2],
+ );
+
+ $this->assertSame(5, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId));
+ }
+
+ public function testIgnoresExpiredReservations(): void
+ {
+ $this->insertOrderWithItems(
+ status: OrderStatus::RESERVED->name,
+ reservedUntil: now()->subMinute(),
+ occurrenceQuantities: [$this->occurrenceId => 5],
+ );
+ $this->insertOrderWithItems(
+ status: OrderStatus::RESERVED->name,
+ reservedUntil: now()->addHour(),
+ occurrenceQuantities: [$this->occurrenceId => 4],
+ );
+
+ $this->assertSame(4, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId));
+ }
+
+ public function testIgnoresNonReservedOrders(): void
+ {
+ $this->insertOrderWithItems(
+ status: OrderStatus::COMPLETED->name,
+ reservedUntil: now()->addHour(),
+ occurrenceQuantities: [$this->occurrenceId => 7],
+ );
+ $this->insertOrderWithItems(
+ status: OrderStatus::CANCELLED->name,
+ reservedUntil: now()->addHour(),
+ occurrenceQuantities: [$this->occurrenceId => 9],
+ );
+ $this->insertOrderWithItems(
+ status: OrderStatus::RESERVED->name,
+ reservedUntil: now()->addHour(),
+ occurrenceQuantities: [$this->occurrenceId => 1],
+ );
+
+ $this->assertSame(1, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId));
+ }
+
+ public function testIgnoresSoftDeletedOrders(): void
+ {
+ $this->insertOrderWithItems(
+ status: OrderStatus::RESERVED->name,
+ reservedUntil: now()->addHour(),
+ occurrenceQuantities: [$this->occurrenceId => 6],
+ deletedAt: now(),
+ );
+ $this->insertOrderWithItems(
+ status: OrderStatus::RESERVED->name,
+ reservedUntil: now()->addHour(),
+ occurrenceQuantities: [$this->occurrenceId => 2],
+ );
+
+ $this->assertSame(2, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId));
+ }
+
+ public function testScopesByOccurrenceId(): void
+ {
+ $this->insertOrderWithItems(
+ status: OrderStatus::RESERVED->name,
+ reservedUntil: now()->addHour(),
+ occurrenceQuantities: [
+ $this->occurrenceId => 3,
+ $this->otherOccurrenceId => 7,
+ ],
+ );
+
+ $this->assertSame(3, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId));
+ $this->assertSame(7, $this->repository->getReservedQuantityForOccurrence($this->otherOccurrenceId));
+ }
+
+ public function testIgnoresSoftDeletedOrderItems(): void
+ {
+ $orderId = DB::table('orders')->insertGetId([
+ 'short_id' => 'ord_' . uniqid(),
+ 'event_id' => $this->eventId,
+ 'currency' => 'USD',
+ 'status' => OrderStatus::RESERVED->name,
+ 'reserved_until' => now()->addHour(),
+ 'public_id' => 'pub_' . uniqid(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ // One live item (counts), one soft-deleted item (does not).
+ DB::table('order_items')->insert([
+ 'order_id' => $orderId,
+ 'product_id' => $this->productId,
+ 'product_price_id' => $this->productPriceId,
+ 'event_occurrence_id' => $this->occurrenceId,
+ 'quantity' => 4,
+ 'price' => 10.00,
+ 'total_before_additions' => 40.00,
+ ]);
+
+ DB::table('order_items')->insert([
+ 'order_id' => $orderId,
+ 'product_id' => $this->productId,
+ 'product_price_id' => $this->productPriceId,
+ 'event_occurrence_id' => $this->occurrenceId,
+ 'quantity' => 6,
+ 'price' => 10.00,
+ 'total_before_additions' => 60.00,
+ 'deleted_at' => now(),
+ ]);
+
+ $this->assertSame(4, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId));
+ }
+
+ /**
+ * @param array $occurrenceQuantities Map of event_occurrence_id => quantity
+ */
+ private function insertOrderWithItems(
+ string $status,
+ \DateTimeInterface $reservedUntil,
+ array $occurrenceQuantities,
+ ?\DateTimeInterface $deletedAt = null,
+ ): int {
+ $orderId = DB::table('orders')->insertGetId([
+ 'short_id' => 'ord_' . uniqid(),
+ 'event_id' => $this->eventId,
+ 'currency' => 'USD',
+ 'status' => $status,
+ 'reserved_until' => $reservedUntil,
+ 'public_id' => 'pub_' . uniqid(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ 'deleted_at' => $deletedAt,
+ ]);
+
+ foreach ($occurrenceQuantities as $occurrenceId => $quantity) {
+ DB::table('order_items')->insert([
+ 'order_id' => $orderId,
+ 'product_id' => $this->productId,
+ 'product_price_id' => $this->productPriceId,
+ 'event_occurrence_id' => $occurrenceId,
+ 'quantity' => $quantity,
+ 'price' => 10.00,
+ 'total_before_additions' => $quantity * 10.00,
+ ]);
+ }
+
+ return $orderId;
+ }
+}
diff --git a/backend/tests/Feature/Repository/Fixtures/WidgetCategoryDomainObject.php b/backend/tests/Feature/Repository/Fixtures/WidgetCategoryDomainObject.php
new file mode 100644
index 0000000000..c71558aec2
--- /dev/null
+++ b/backend/tests/Feature/Repository/Fixtures/WidgetCategoryDomainObject.php
@@ -0,0 +1,65 @@
+id = $id;
+
+ return $this;
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function setName(?string $name): self
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ public function getName(): ?string
+ {
+ return $this->name;
+ }
+
+ public function setWidgets(?Collection $widgets): self
+ {
+ $this->widgets = $widgets;
+
+ return $this;
+ }
+
+ public function getWidgets(): ?Collection
+ {
+ return $this->widgets;
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'name' => $this->name,
+ ];
+ }
+}
diff --git a/backend/tests/Feature/Repository/Fixtures/WidgetCategoryModel.php b/backend/tests/Feature/Repository/Fixtures/WidgetCategoryModel.php
new file mode 100644
index 0000000000..08c57c97f5
--- /dev/null
+++ b/backend/tests/Feature/Repository/Fixtures/WidgetCategoryModel.php
@@ -0,0 +1,23 @@
+hasMany(WidgetModel::class, 'category_id');
+ }
+}
diff --git a/backend/tests/Feature/Repository/Fixtures/WidgetCategoryRepository.php b/backend/tests/Feature/Repository/Fixtures/WidgetCategoryRepository.php
new file mode 100644
index 0000000000..ff7f7520ed
--- /dev/null
+++ b/backend/tests/Feature/Repository/Fixtures/WidgetCategoryRepository.php
@@ -0,0 +1,23 @@
+
+ */
+class WidgetCategoryRepository extends BaseRepository
+{
+ protected function getModel(): string
+ {
+ return WidgetCategoryModel::class;
+ }
+
+ public function getDomainObject(): string
+ {
+ return WidgetCategoryDomainObject::class;
+ }
+}
diff --git a/backend/tests/Feature/Repository/Fixtures/WidgetDomainObject.php b/backend/tests/Feature/Repository/Fixtures/WidgetDomainObject.php
new file mode 100644
index 0000000000..c673e4470a
--- /dev/null
+++ b/backend/tests/Feature/Repository/Fixtures/WidgetDomainObject.php
@@ -0,0 +1,199 @@
+id = $id;
+
+ return $this;
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function setCategoryId(?int $category_id): self
+ {
+ $this->category_id = $category_id;
+
+ return $this;
+ }
+
+ public function getCategoryId(): ?int
+ {
+ return $this->category_id;
+ }
+
+ public function setName(?string $name): self
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ public function getName(): ?string
+ {
+ return $this->name;
+ }
+
+ public function setSku(?string $sku): self
+ {
+ $this->sku = $sku;
+
+ return $this;
+ }
+
+ public function getSku(): ?string
+ {
+ return $this->sku;
+ }
+
+ public function setQuantity(?int $quantity): self
+ {
+ $this->quantity = $quantity;
+
+ return $this;
+ }
+
+ public function getQuantity(): ?int
+ {
+ return $this->quantity;
+ }
+
+ public function setPrice(float|int|null $price): self
+ {
+ $this->price = $price === null ? null : (float) $price;
+
+ return $this;
+ }
+
+ public function getPrice(): ?float
+ {
+ return $this->price;
+ }
+
+ public function setIsActive(?bool $is_active): self
+ {
+ $this->is_active = $is_active;
+
+ return $this;
+ }
+
+ public function getIsActive(): ?bool
+ {
+ return $this->is_active;
+ }
+
+ public function setDescription(?string $description): self
+ {
+ $this->description = $description;
+
+ return $this;
+ }
+
+ public function getDescription(): ?string
+ {
+ return $this->description;
+ }
+
+ public function setCreatedAt(?string $created_at): self
+ {
+ $this->created_at = $created_at;
+
+ return $this;
+ }
+
+ public function getCreatedAt(): ?string
+ {
+ return $this->created_at;
+ }
+
+ public function setUpdatedAt(?string $updated_at): self
+ {
+ $this->updated_at = $updated_at;
+
+ return $this;
+ }
+
+ public function getUpdatedAt(): ?string
+ {
+ return $this->updated_at;
+ }
+
+ public function setDeletedAt(?string $deleted_at): self
+ {
+ $this->deleted_at = $deleted_at;
+
+ return $this;
+ }
+
+ public function getDeletedAt(): ?string
+ {
+ return $this->deleted_at;
+ }
+
+ public function setCategory(?WidgetCategoryDomainObject $category): self
+ {
+ $this->category = $category;
+
+ return $this;
+ }
+
+ public function getCategory(): ?WidgetCategoryDomainObject
+ {
+ return $this->category;
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'category_id' => $this->category_id,
+ 'name' => $this->name,
+ 'sku' => $this->sku,
+ 'quantity' => $this->quantity,
+ 'price' => $this->price,
+ 'is_active' => $this->is_active,
+ 'description' => $this->description,
+ 'created_at' => $this->created_at,
+ 'updated_at' => $this->updated_at,
+ 'deleted_at' => $this->deleted_at,
+ ];
+ }
+}
diff --git a/backend/tests/Feature/Repository/Fixtures/WidgetModel.php b/backend/tests/Feature/Repository/Fixtures/WidgetModel.php
new file mode 100644
index 0000000000..387a657ef3
--- /dev/null
+++ b/backend/tests/Feature/Repository/Fixtures/WidgetModel.php
@@ -0,0 +1,43 @@
+ 'boolean',
+ 'quantity' => 'integer',
+ 'price' => 'float',
+ ];
+ }
+
+ public function category(): BelongsTo
+ {
+ return $this->belongsTo(WidgetCategoryModel::class, 'category_id');
+ }
+}
diff --git a/backend/tests/Feature/Repository/Fixtures/WidgetRepository.php b/backend/tests/Feature/Repository/Fixtures/WidgetRepository.php
new file mode 100644
index 0000000000..6afa6610f9
--- /dev/null
+++ b/backend/tests/Feature/Repository/Fixtures/WidgetRepository.php
@@ -0,0 +1,44 @@
+
+ */
+class WidgetRepository extends BaseRepository
+{
+ protected function getModel(): string
+ {
+ return WidgetModel::class;
+ }
+
+ public function getDomainObject(): string
+ {
+ return WidgetDomainObject::class;
+ }
+
+ /**
+ * Test hooks: expose protected state so we can assert reset behaviour
+ * without resorting to reflection.
+ */
+ public function exposeEagerLoads(): array
+ {
+ return $this->eagerLoads;
+ }
+
+ public function exposeBuilderHasWheres(): bool
+ {
+ $base = $this->model->getQuery();
+
+ return ! empty($base->wheres);
+ }
+}
diff --git a/backend/tests/Unit/DomainObjects/EventDomainObjectTest.php b/backend/tests/Unit/DomainObjects/EventDomainObjectTest.php
new file mode 100644
index 0000000000..81fab8f06b
--- /dev/null
+++ b/backend/tests/Unit/DomainObjects/EventDomainObjectTest.php
@@ -0,0 +1,297 @@
+setStartDate($startDate);
+ $occurrence->setEndDate($endDate);
+ $occurrence->setStatus($status);
+
+ return $occurrence;
+ }
+
+ private function createEvent(?Collection $occurrences = null, ?string $timezone = null): EventDomainObject
+ {
+ $event = new EventDomainObject();
+
+ if ($occurrences !== null) {
+ $event->setEventOccurrences($occurrences);
+ }
+
+ if ($timezone !== null) {
+ $event->setTimezone($timezone);
+ }
+
+ return $event;
+ }
+
+ public function testGetStartDateReturnsEarliestOccurrenceStartDate(): void
+ {
+ $earlier = Carbon::now()->subDays(3)->toDateTimeString();
+ $later = Carbon::now()->subDay()->toDateTimeString();
+
+ $occurrences = collect([
+ $this->createOccurrence($later),
+ $this->createOccurrence($earlier),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertEquals($earlier, $event->getStartDate());
+ }
+
+ public function testGetStartDateReturnsNullWhenNoOccurrences(): void
+ {
+ $event = $this->createEvent();
+ $this->assertNull($event->getStartDate());
+
+ $eventWithEmpty = $this->createEvent(collect([]));
+ $this->assertNull($eventWithEmpty->getStartDate());
+ }
+
+ public function testGetEndDateReturnsLatestOccurrenceEndDate(): void
+ {
+ $earlierEnd = Carbon::now()->addDay()->toDateTimeString();
+ $laterEnd = Carbon::now()->addDays(3)->toDateTimeString();
+
+ $occurrences = collect([
+ $this->createOccurrence(
+ Carbon::now()->subDay()->toDateTimeString(),
+ $earlierEnd,
+ ),
+ $this->createOccurrence(
+ Carbon::now()->toDateTimeString(),
+ $laterEnd,
+ ),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertEquals($laterEnd, $event->getEndDate());
+ }
+
+ public function testGetEndDateFallsBackToLatestStartDateWhenNoEndDates(): void
+ {
+ $earlierStart = Carbon::now()->subDay()->toDateTimeString();
+ $laterStart = Carbon::now()->addDay()->toDateTimeString();
+
+ $occurrences = collect([
+ $this->createOccurrence($earlierStart),
+ $this->createOccurrence($laterStart),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertEquals($laterStart, $event->getEndDate());
+ }
+
+ public function testGetEndDateReturnsNullWhenNoOccurrences(): void
+ {
+ $event = $this->createEvent();
+ $this->assertNull($event->getEndDate());
+
+ $eventWithEmpty = $this->createEvent(collect([]));
+ $this->assertNull($eventWithEmpty->getEndDate());
+ }
+
+ public function testIsEventInPastReturnsTrueWhenAllOccurrencesArePast(): 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(),
+ ),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertTrue($event->isEventInPast());
+ }
+
+ public function testIsEventInPastReturnsFalseWhenSomeOccurrencesAreFuture(): 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(),
+ ),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertFalse($event->isEventInPast());
+ }
+
+ public function testIsEventInFutureReturnsTrueWhenEarliestStartIsFuture(): 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(),
+ ),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertTrue($event->isEventInFuture());
+ }
+
+ public function testIsEventInFutureReturnsFalseWhenEarliestStartIsPast(): 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);
+
+ $this->assertFalse($event->isEventInFuture());
+ }
+
+ public function testIsEventOngoingReturnsTrueWhenActiveOccurrenceHasStartedButNotEnded(): void
+ {
+ $occurrences = collect([
+ $this->createOccurrence(
+ Carbon::now()->subHour()->toDateTimeString(),
+ Carbon::now()->addHour()->toDateTimeString(),
+ EventOccurrenceStatus::ACTIVE->name,
+ ),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertTrue($event->isEventOngoing());
+ }
+
+ public function testIsEventOngoingReturnsFalseForCancelledOccurrences(): void
+ {
+ $occurrences = collect([
+ $this->createOccurrence(
+ Carbon::now()->subHour()->toDateTimeString(),
+ Carbon::now()->addHour()->toDateTimeString(),
+ EventOccurrenceStatus::CANCELLED->name,
+ ),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertFalse($event->isEventOngoing());
+ }
+
+ public function testIsEventOngoingReturnsTrueWhenActiveOccurrenceHasNoEndDate(): void
+ {
+ $occurrences = collect([
+ $this->createOccurrence(
+ Carbon::now()->subHour()->toDateTimeString(),
+ null,
+ EventOccurrenceStatus::ACTIVE->name,
+ ),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertTrue($event->isEventOngoing());
+ }
+
+ public function testIsEventOngoingReturnsFalseWhenNoOccurrences(): void
+ {
+ $event = $this->createEvent();
+ $this->assertFalse($event->isEventOngoing());
+
+ $eventWithEmpty = $this->createEvent(collect([]));
+ $this->assertFalse($eventWithEmpty->isEventOngoing());
+ }
+
+ public function testGetLifecycleStatusReturnsOngoingWhenOngoing(): void
+ {
+ $occurrences = collect([
+ $this->createOccurrence(
+ Carbon::now()->subHour()->toDateTimeString(),
+ Carbon::now()->addHour()->toDateTimeString(),
+ EventOccurrenceStatus::ACTIVE->name,
+ ),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertEquals(EventLifecycleStatus::ONGOING->name, $event->getLifecycleStatus());
+ }
+
+ public function testGetLifecycleStatusReturnsUpcomingWhenAllFuture(): void
+ {
+ $occurrences = collect([
+ $this->createOccurrence(
+ Carbon::now()->addDay()->toDateTimeString(),
+ Carbon::now()->addDays(2)->toDateTimeString(),
+ ),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertEquals(EventLifecycleStatus::UPCOMING->name, $event->getLifecycleStatus());
+ }
+
+ public function testGetLifecycleStatusReturnsEndedWhenAllPast(): void
+ {
+ $occurrences = collect([
+ $this->createOccurrence(
+ Carbon::now()->subDays(3)->toDateTimeString(),
+ Carbon::now()->subDay()->toDateTimeString(),
+ ),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertEquals(EventLifecycleStatus::ENDED->name, $event->getLifecycleStatus());
+ }
+
+ public function testIsRecurringReturnsTrueForRecurringType(): void
+ {
+ $event = new EventDomainObject();
+ $event->setType(EventType::RECURRING->name);
+
+ $this->assertTrue($event->isRecurring());
+ }
+
+ public function testIsRecurringReturnsFalseForSingleType(): void
+ {
+ $event = new EventDomainObject();
+ $event->setType(EventType::SINGLE->name);
+
+ $this->assertFalse($event->isRecurring());
+ }
+}
diff --git a/backend/tests/Unit/Jobs/Occurrence/BulkCancelOccurrencesJobTest.php b/backend/tests/Unit/Jobs/Occurrence/BulkCancelOccurrencesJobTest.php
new file mode 100644
index 0000000000..82679abb1b
--- /dev/null
+++ b/backend/tests/Unit/Jobs/Occurrence/BulkCancelOccurrencesJobTest.php
@@ -0,0 +1,166 @@
+andReturnUsing(fn ($callback) => $callback());
+
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->exclusionService = Mockery::mock(RecurrenceRuleExclusionService::class);
+ $this->cancelAttendeesService = Mockery::mock(CancelOccurrenceAttendeesService::class);
+ $this->cancelAttendeesService->shouldReceive('cancelForOccurrence')->byDefault();
+ $this->exclusionService->shouldReceive('addExclusions')->byDefault();
+ }
+
+ public function test_handle_cancels_multiple_occurrences(): void
+ {
+ Log::shouldReceive('info')->once();
+
+ $occ1 = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occ1->shouldReceive('getEventId')->andReturn(1);
+ $occ1->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name);
+ $occ1->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00');
+
+ $occ2 = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occ2->shouldReceive('getEventId')->andReturn(1);
+ $occ2->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name);
+ $occ2->shouldReceive('getStartDate')->andReturn('2026-06-22 10:00:00');
+
+ $this->occurrenceRepository->shouldReceive('findByIdLocked')
+ ->with(10)
+ ->once()
+ ->andReturn($occ1);
+ $this->occurrenceRepository->shouldReceive('findByIdLocked')
+ ->with(20)
+ ->once()
+ ->andReturn($occ2);
+
+ $this->occurrenceRepository->shouldReceive('updateWhere')->twice();
+
+ $this->exclusionService
+ ->shouldReceive('addExclusions')
+ ->once()
+ ->with(1, ['2026-06-15 10:00:00', '2026-06-22 10:00:00']);
+
+ $job = new BulkCancelOccurrencesJob(1, [10, 20]);
+ $job->handle($this->occurrenceRepository, $this->exclusionService, $this->cancelAttendeesService);
+
+ Event::assertDispatchedTimes(OccurrenceCancelledEvent::class, 2);
+ }
+
+ public function test_handle_skips_already_cancelled_occurrences(): void
+ {
+ Log::shouldReceive('info')->once();
+
+ $occ = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occ->shouldReceive('getEventId')->andReturn(1);
+ $occ->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::CANCELLED->name);
+
+ $this->occurrenceRepository->shouldReceive('findByIdLocked')
+ ->with(10)
+ ->once()
+ ->andReturn($occ);
+ $this->occurrenceRepository->shouldNotReceive('updateWhere');
+ $this->exclusionService->shouldNotReceive('addExclusions');
+
+ $job = new BulkCancelOccurrencesJob(1, [10]);
+ $job->handle($this->occurrenceRepository, $this->exclusionService, $this->cancelAttendeesService);
+
+ Event::assertNotDispatched(OccurrenceCancelledEvent::class);
+ }
+
+ public function test_it_skips_occurrences_not_belonging_to_event(): void
+ {
+ Log::shouldReceive('info')->once();
+
+ $foreignOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $foreignOccurrence->shouldReceive('getEventId')->andReturn(99);
+
+ $this->occurrenceRepository->shouldReceive('findByIdLocked')
+ ->with(10)
+ ->once()
+ ->andReturn($foreignOccurrence);
+
+ $this->occurrenceRepository->shouldNotReceive('updateWhere');
+ $this->exclusionService->shouldNotReceive('addExclusions');
+
+ $job = new BulkCancelOccurrencesJob(1, [10]);
+ $job->handle($this->occurrenceRepository, $this->exclusionService, $this->cancelAttendeesService);
+
+ Event::assertNotDispatched(OccurrenceCancelledEvent::class);
+ }
+
+ public function test_handle_dispatches_event_with_refund_flag_true(): void
+ {
+ Log::shouldReceive('info')->once();
+
+ $occ = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occ->shouldReceive('getEventId')->andReturn(1);
+ $occ->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name);
+ $occ->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00');
+
+ $this->occurrenceRepository->shouldReceive('findByIdLocked')
+ ->with(10)
+ ->once()
+ ->andReturn($occ);
+ $this->occurrenceRepository->shouldReceive('updateWhere')->once();
+
+ $job = new BulkCancelOccurrencesJob(1, [10], refundOrders: true);
+ $job->handle($this->occurrenceRepository, $this->exclusionService, $this->cancelAttendeesService);
+
+ Event::assertDispatched(OccurrenceCancelledEvent::class, fn ($e) => $e->occurrenceId === 10 && $e->refundOrders === true);
+ }
+
+ public function test_handle_dispatches_event_with_refund_flag_false(): void
+ {
+ Log::shouldReceive('info')->once();
+
+ $occ = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occ->shouldReceive('getEventId')->andReturn(1);
+ $occ->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name);
+ $occ->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00');
+
+ $this->occurrenceRepository->shouldReceive('findByIdLocked')
+ ->with(10)
+ ->once()
+ ->andReturn($occ);
+ $this->occurrenceRepository->shouldReceive('updateWhere')->once();
+
+ $job = new BulkCancelOccurrencesJob(1, [10], refundOrders: false);
+ $job->handle($this->occurrenceRepository, $this->exclusionService, $this->cancelAttendeesService);
+
+ Event::assertDispatched(OccurrenceCancelledEvent::class, fn ($e) => $e->refundOrders === false);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Jobs/Occurrence/RefundOccurrenceOrdersJobTest.php b/backend/tests/Unit/Jobs/Occurrence/RefundOccurrenceOrdersJobTest.php
new file mode 100644
index 0000000000..47bc59bb43
--- /dev/null
+++ b/backend/tests/Unit/Jobs/Occurrence/RefundOccurrenceOrdersJobTest.php
@@ -0,0 +1,213 @@
+refundHandler = Mockery::mock(RefundOrderHandler::class);
+ $this->auditLogRepository = Mockery::mock(OrderAuditLogRepositoryInterface::class);
+ }
+
+ private function mockDbChain(int $occurrenceId, array $orderIds, array $refundableOrders, array $multiOccurrenceOrderIds = []): void
+ {
+ $orderItemsBuilder = Mockery::mock('orderItemsBuilder');
+ $ordersBuilder = Mockery::mock('ordersBuilder');
+ $batchBuilder = Mockery::mock('batchBuilder');
+
+ // First call: get order_ids for occurrence
+ // Third call: batch multi-occurrence check
+ DB::shouldReceive('table')->with('order_items')->andReturn($orderItemsBuilder, $batchBuilder);
+ DB::shouldReceive('table')->with('orders')->andReturn($ordersBuilder);
+
+ // First order_items query: get order IDs
+ $orderItemsBuilder->shouldReceive('where')->with('event_occurrence_id', $occurrenceId)->andReturnSelf();
+ $orderItemsBuilder->shouldReceive('whereNull')->with('deleted_at')->andReturnSelf();
+ $orderItemsBuilder->shouldReceive('distinct')->andReturnSelf();
+ $orderItemsBuilder->shouldReceive('pluck')->with('order_id')->andReturn(collect($orderIds));
+
+ // Orders query: already-refunded rows are filtered out with whereNull('refund_status')
+ $ordersBuilder->shouldReceive('whereIn')->with('id', Mockery::any())->andReturnSelf();
+ $ordersBuilder->shouldReceive('where')->with('status', 'COMPLETED')->andReturnSelf();
+ $ordersBuilder->shouldReceive('where')->with('payment_status', 'PAYMENT_RECEIVED')->andReturnSelf();
+ $ordersBuilder->shouldReceive('whereNull')->with('refund_status')->andReturnSelf();
+ $ordersBuilder->shouldReceive('get')->with(['id', 'total_gross', 'currency'])->andReturn(
+ collect(array_map(fn ($o) => (object) $o, $refundableOrders))
+ );
+
+ // Batch multi-occurrence check
+ $batchBuilder->shouldReceive('whereIn')->andReturnSelf();
+ $batchBuilder->shouldReceive('whereNull')->andReturnSelf();
+ $batchBuilder->shouldReceive('select')->andReturnSelf();
+ $batchBuilder->shouldReceive('groupBy')->andReturnSelf();
+ $batchBuilder->shouldReceive('havingRaw')->andReturnSelf();
+ $batchBuilder->shouldReceive('pluck')->with('order_id')->andReturn(collect($multiOccurrenceOrderIds));
+ }
+
+ public function test_handle_refunds_single_occurrence_orders(): void
+ {
+ $this->mockDbChain(
+ occurrenceId: 10,
+ orderIds: [100],
+ refundableOrders: [['id' => 100, 'total_gross' => 50.00, 'currency' => 'USD']],
+ multiOccurrenceOrderIds: [],
+ );
+
+ $this->refundHandler
+ ->shouldReceive('handle')
+ ->once()
+ ->with(Mockery::on(fn (RefundOrderDTO $dto) => $dto->event_id === 1
+ && $dto->order_id === 100
+ && $dto->amount === 50.00
+ && $dto->notify_buyer === true
+ && $dto->cancel_order === true
+ ));
+
+ $job = new RefundOccurrenceOrdersJob(1, 10);
+ $job->handle($this->refundHandler, $this->auditLogRepository);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_handle_skips_multi_occurrence_orders(): void
+ {
+ Log::shouldReceive('warning')->once();
+
+ $this->mockDbChain(
+ occurrenceId: 10,
+ orderIds: [100],
+ refundableOrders: [['id' => 100, 'total_gross' => 50.00, 'currency' => 'USD']],
+ multiOccurrenceOrderIds: [100],
+ );
+
+ $this->refundHandler->shouldNotReceive('handle');
+
+ // Skipped orders must leave a trail on the order's audit log so the
+ // admin can see which orders need manual refunds.
+ $this->auditLogRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with(Mockery::on(fn (array $attributes) => $attributes['order_id'] === 100
+ && $attributes['event_id'] === 1
+ && $attributes['action'] === OrderAuditAction::AUTOMATIC_REFUND_SKIPPED->value
+ && $attributes['new_values']['cancelled_occurrence_id'] === 10));
+
+ $job = new RefundOccurrenceOrdersJob(1, 10);
+ $job->handle($this->refundHandler, $this->auditLogRepository);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_handle_returns_early_when_no_order_items(): void
+ {
+ $builder = Mockery::mock('builder');
+ DB::shouldReceive('table')->with('order_items')->andReturn($builder);
+ $builder->shouldReceive('where')->with('event_occurrence_id', 10)->andReturnSelf();
+ $builder->shouldReceive('whereNull')->with('deleted_at')->andReturnSelf();
+ $builder->shouldReceive('distinct')->andReturnSelf();
+ $builder->shouldReceive('pluck')->with('order_id')->andReturn(collect());
+
+ $this->refundHandler->shouldNotReceive('handle');
+
+ $job = new RefundOccurrenceOrdersJob(1, 10);
+ $job->handle($this->refundHandler, $this->auditLogRepository);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_handle_skips_orders_already_refunded(): void
+ {
+ // whereNull('refund_status') filters out orders that already entered a refund
+ // workflow. The mocked "refundableOrders" collection is empty because the
+ // filter excluded them — the handler must not be called.
+ $this->mockDbChain(
+ occurrenceId: 10,
+ orderIds: [100, 101],
+ refundableOrders: [],
+ multiOccurrenceOrderIds: [],
+ );
+
+ $this->refundHandler->shouldNotReceive('handle');
+
+ $job = new RefundOccurrenceOrdersJob(1, 10);
+ $job->handle($this->refundHandler, $this->auditLogRepository);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_unique_id_is_occurrence_scoped(): void
+ {
+ $job = new RefundOccurrenceOrdersJob(1, 10);
+ $this->assertSame('occurrence:10', $job->uniqueId());
+ }
+
+ public function test_handle_continues_on_refund_error_and_writes_audit_log(): void
+ {
+ Log::shouldReceive('error')->once();
+
+ $this->mockDbChain(
+ occurrenceId: 10,
+ orderIds: [100],
+ refundableOrders: [['id' => 100, 'total_gross' => 50.00, 'currency' => 'USD']],
+ multiOccurrenceOrderIds: [],
+ );
+
+ $this->refundHandler
+ ->shouldReceive('handle')
+ ->once()
+ ->andThrow(new \RuntimeException('Stripe error'));
+
+ // Failed refunds must surface on the order's audit log so admins can see
+ // which orders need manual intervention.
+ $this->auditLogRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with(Mockery::on(fn (array $attributes) => $attributes['order_id'] === 100
+ && $attributes['event_id'] === 1
+ && $attributes['action'] === OrderAuditAction::AUTOMATIC_REFUND_FAILED->value
+ && $attributes['new_values']['cancelled_occurrence_id'] === 10
+ && $attributes['new_values']['error'] === 'Stripe error'));
+
+ $job = new RefundOccurrenceOrdersJob(1, 10);
+ $job->handle($this->refundHandler, $this->auditLogRepository);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_failed_logs_critical(): void
+ {
+ Log::shouldReceive('critical')
+ ->once()
+ ->with('RefundOccurrenceOrdersJob permanently failed after retries', Mockery::on(fn (array $context) => $context['event_id'] === 1
+ && $context['occurrence_id'] === 10
+ && $context['error'] === 'queue exhausted'));
+
+ $job = new RefundOccurrenceOrdersJob(1, 10);
+ $job->failed(new \RuntimeException('queue exhausted'));
+
+ $this->assertTrue(true);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Jobs/Occurrence/SendOccurrenceCancellationEmailJobTest.php b/backend/tests/Unit/Jobs/Occurrence/SendOccurrenceCancellationEmailJobTest.php
new file mode 100644
index 0000000000..5ee544b81d
--- /dev/null
+++ b/backend/tests/Unit/Jobs/Occurrence/SendOccurrenceCancellationEmailJobTest.php
@@ -0,0 +1,186 @@
+eventRepository = Mockery::mock(EventRepositoryInterface::class);
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class);
+ $this->mailer = Mockery::mock(Mailer::class);
+ $this->mailBuilderService = Mockery::mock(MailBuilderService::class);
+ $this->mailBuilderService->shouldReceive('buildOccurrenceCancellationMail')
+ ->andReturn(Mockery::mock(OccurrenceCancellationMail::class));
+ }
+
+ private function makeEvent(): EventDomainObject|Mockery\MockInterface
+ {
+ $organizer = Mockery::mock(OrganizerDomainObject::class);
+ $eventSettings = Mockery::mock(EventSettingDomainObject::class);
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getTimezone')->andReturn('America/New_York');
+ $event->shouldReceive('getOrganizer')->andReturn($organizer);
+ $event->shouldReceive('getEventSettings')->andReturn($eventSettings);
+
+ return $event;
+ }
+
+ private function makeAttendee(string $email, string $locale = 'en'): AttendeeDomainObject|Mockery\MockInterface
+ {
+ $attendee = Mockery::mock(AttendeeDomainObject::class);
+ $attendee->shouldReceive('getEmail')->andReturn($email);
+ $attendee->shouldReceive('getLocale')->andReturn($locale);
+
+ return $attendee;
+ }
+
+ private function setupCommon(array $attendees): void
+ {
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 14:00:00');
+
+ $this->occurrenceRepository->shouldReceive('findById')->with(10)->once()->andReturn($occurrence);
+ $this->eventRepository->shouldReceive('loadRelation')->twice()->andReturnSelf();
+ $this->eventRepository->shouldReceive('findById')->with(1)->once()->andReturn($this->makeEvent());
+ $this->attendeeRepository->shouldReceive('findWhere')->once()->andReturn(collect($attendees));
+ }
+
+ public function test_handle_sends_email_to_each_unique_attendee(): void
+ {
+ $this->setupCommon([
+ $this->makeAttendee('alice@example.com'),
+ $this->makeAttendee('bob@example.com'),
+ ]);
+
+ $emailsSent = [];
+ $pendingMail = Mockery::mock(PendingMail::class);
+ $pendingMail->shouldReceive('locale')->andReturnSelf();
+ $pendingMail->shouldReceive('send')->with(Mockery::type(OccurrenceCancellationMail::class));
+
+ $this->mailer->shouldReceive('to')->andReturnUsing(function ($email) use (&$emailsSent, $pendingMail) {
+ $emailsSent[] = $email;
+
+ return $pendingMail;
+ });
+
+ $job = new SendOccurrenceCancellationEmailJob(1, 10);
+ $job->handle($this->eventRepository, $this->occurrenceRepository, $this->attendeeRepository, $this->mailer, $this->mailBuilderService);
+
+ $this->assertCount(2, $emailsSent);
+ $this->assertContains('alice@example.com', $emailsSent);
+ $this->assertContains('bob@example.com', $emailsSent);
+ }
+
+ public function test_handle_deduplicates_by_email(): void
+ {
+ $this->setupCommon([
+ $this->makeAttendee('same@example.com'),
+ $this->makeAttendee('same@example.com'),
+ ]);
+
+ $emailsSent = [];
+ $pendingMail = Mockery::mock(PendingMail::class);
+ $pendingMail->shouldReceive('locale')->andReturnSelf();
+ $pendingMail->shouldReceive('send');
+
+ $this->mailer->shouldReceive('to')->andReturnUsing(function ($email) use (&$emailsSent, $pendingMail) {
+ $emailsSent[] = $email;
+
+ return $pendingMail;
+ });
+
+ $job = new SendOccurrenceCancellationEmailJob(1, 10);
+ $job->handle($this->eventRepository, $this->occurrenceRepository, $this->attendeeRepository, $this->mailer, $this->mailBuilderService);
+
+ $this->assertCount(1, $emailsSent);
+ }
+
+ public function test_handle_does_not_filter_out_cancelled_attendees(): void
+ {
+ // Regression guard: CancelOccurrenceHandler marks attendees CANCELLED
+ // inside the transaction that fires this job's event. If this query
+ // re-introduces a status filter it will silently exclude the very
+ // attendees we need to notify.
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 14:00:00');
+
+ $this->occurrenceRepository->shouldReceive('findById')->once()->andReturn($occurrence);
+ $this->eventRepository->shouldReceive('loadRelation')->twice()->andReturnSelf();
+ $this->eventRepository->shouldReceive('findById')->once()->andReturn($this->makeEvent());
+
+ $this->attendeeRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->with(Mockery::on(function (array $where) {
+ // Exactly one condition: the occurrence filter. No status clause.
+ return array_keys($where) === ['event_occurrence_id']
+ && $where['event_occurrence_id'] === 10;
+ }))
+ ->andReturn(collect([$this->makeAttendee('cancelled@example.com')]));
+
+ $pendingMail = Mockery::mock(PendingMail::class);
+ $pendingMail->shouldReceive('locale')->andReturnSelf();
+ $pendingMail->shouldReceive('send');
+ $this->mailer->shouldReceive('to')->andReturn($pendingMail);
+
+ $job = new SendOccurrenceCancellationEmailJob(1, 10);
+ $job->handle($this->eventRepository, $this->occurrenceRepository, $this->attendeeRepository, $this->mailer, $this->mailBuilderService);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_handle_returns_early_when_no_attendees(): void
+ {
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 14:00:00');
+
+ $this->occurrenceRepository->shouldReceive('findById')->once()->andReturn($occurrence);
+ $this->eventRepository->shouldReceive('loadRelation')->twice()->andReturnSelf();
+ $this->eventRepository->shouldReceive('findById')->once()->andReturn($this->makeEvent());
+ $this->attendeeRepository->shouldReceive('findWhere')->once()->andReturn(collect());
+
+ $this->mailer->shouldNotReceive('to');
+
+ $job = new SendOccurrenceCancellationEmailJob(1, 10);
+ $job->handle($this->eventRepository, $this->occurrenceRepository, $this->attendeeRepository, $this->mailer, $this->mailBuilderService);
+
+ $this->assertTrue(true);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Jobs/Vat/ValidateVatNumberJobTest.php b/backend/tests/Unit/Jobs/Vat/ValidateVatNumberJobTest.php
deleted file mode 100644
index 1a91aa3564..0000000000
--- a/backend/tests/Unit/Jobs/Vat/ValidateVatNumberJobTest.php
+++ /dev/null
@@ -1,154 +0,0 @@
-viesService = Mockery::mock(ViesValidationService::class);
- $this->repository = Mockery::mock(AccountVatSettingRepositoryInterface::class);
- $this->logger = Mockery::mock(LoggerInterface::class);
- $this->logger->shouldReceive('info')->byDefault();
- $this->logger->shouldReceive('warning')->byDefault();
- }
-
- public function testJobUpdatesSettingsOnSuccessfulValidation(): void
- {
- $accountVatSettingId = 123;
- $vatNumber = 'IE1234567A';
-
- $job = new ValidateVatNumberJob($accountVatSettingId, $vatNumber);
-
- $validationResponse = new ViesValidationResponseDTO(
- valid: true,
- businessName: 'Test Company Ltd',
- businessAddress: '123 Test Street',
- countryCode: 'IE',
- vatNumber: '1234567A',
- isTransientError: false,
- );
-
- $domainObject = Mockery::mock(AccountVatSettingDomainObject::class);
-
- $this->viesService
- ->shouldReceive('validateVatNumber')
- ->with($vatNumber)
- ->once()
- ->andReturn($validationResponse);
-
- $this->repository
- ->shouldReceive('updateFromArray')
- ->twice()
- ->withArgs(function ($id, $data) use ($accountVatSettingId) {
- if ($id !== $accountVatSettingId) {
- return false;
- }
-
- if (isset($data['vat_validation_status']) && $data['vat_validation_status'] === VatValidationStatus::VALIDATING->value) {
- return true;
- }
-
- if (isset($data['vat_validated']) && $data['vat_validated'] === true) {
- return $data['vat_validation_status'] === VatValidationStatus::VALID->value
- && $data['business_name'] === 'Test Company Ltd';
- }
-
- return false;
- })
- ->andReturn($domainObject);
-
- $job->handle($this->viesService, $this->repository, $this->logger);
-
- $this->assertTrue(true);
- }
-
- public function testJobUpdatesSettingsOnInvalidVatNumber(): void
- {
- $accountVatSettingId = 123;
- $vatNumber = 'IE9999999ZZ';
-
- $job = new ValidateVatNumberJob($accountVatSettingId, $vatNumber);
-
- $validationResponse = new ViesValidationResponseDTO(
- valid: false,
- countryCode: 'IE',
- vatNumber: '9999999ZZ',
- isTransientError: false,
- errorMessage: 'VAT number is not valid',
- );
-
- $domainObject = Mockery::mock(AccountVatSettingDomainObject::class);
-
- $this->viesService
- ->shouldReceive('validateVatNumber')
- ->with($vatNumber)
- ->once()
- ->andReturn($validationResponse);
-
- $this->repository
- ->shouldReceive('updateFromArray')
- ->twice()
- ->withArgs(function ($id, $data) use ($accountVatSettingId) {
- if ($id !== $accountVatSettingId) {
- return false;
- }
-
- if (isset($data['vat_validation_status']) && $data['vat_validation_status'] === VatValidationStatus::VALIDATING->value) {
- return true;
- }
-
- if (isset($data['vat_validated']) && $data['vat_validated'] === false) {
- return $data['vat_validation_status'] === VatValidationStatus::INVALID->value;
- }
-
- return false;
- })
- ->andReturn($domainObject);
-
- $job->handle($this->viesService, $this->repository, $this->logger);
-
- $this->assertTrue(true);
- }
-
- public function testJobHasCorrectRetryConfiguration(): void
- {
- $job = new ValidateVatNumberJob(1, 'IE1234567A');
-
- $this->assertEquals(15, $job->tries);
- $this->assertEquals(15, $job->maxExceptions);
- $this->assertEquals(15, $job->timeout);
- }
-
- public function testJobBackoffConfiguration(): void
- {
- $job = new ValidateVatNumberJob(1, 'IE1234567A');
-
- $backoffs = $job->backoff();
-
- $this->assertCount(15, $backoffs);
- $this->assertEquals(10, $backoffs[0]);
- $this->assertEquals(1800, $backoffs[14]);
- }
-
- protected function tearDown(): void
- {
- Mockery::close();
- parent::tearDown();
- }
-}
diff --git a/backend/tests/Unit/Listeners/Occurrence/RefundOccurrenceOrdersListenerTest.php b/backend/tests/Unit/Listeners/Occurrence/RefundOccurrenceOrdersListenerTest.php
new file mode 100644
index 0000000000..fedee293b6
--- /dev/null
+++ b/backend/tests/Unit/Listeners/Occurrence/RefundOccurrenceOrdersListenerTest.php
@@ -0,0 +1,41 @@
+handle(new OccurrenceCancelledEvent(
+ eventId: 5,
+ occurrenceId: 42,
+ refundOrders: true,
+ ));
+
+ Bus::assertDispatched(
+ RefundOccurrenceOrdersJob::class,
+ fn (RefundOccurrenceOrdersJob $job) => $job->eventId === 5 && $job->occurrenceId === 42,
+ );
+ }
+
+ public function test_does_not_dispatch_when_refund_orders_false(): void
+ {
+ Bus::fake();
+
+ (new RefundOccurrenceOrdersListener)->handle(new OccurrenceCancelledEvent(
+ eventId: 5,
+ occurrenceId: 42,
+ refundOrders: false,
+ ));
+
+ Bus::assertNotDispatched(RefundOccurrenceOrdersJob::class);
+ }
+}
diff --git a/backend/tests/Unit/Listeners/Waitlist/CancelWaitlistEntriesOnOccurrenceCancelledListenerTest.php b/backend/tests/Unit/Listeners/Waitlist/CancelWaitlistEntriesOnOccurrenceCancelledListenerTest.php
new file mode 100644
index 0000000000..2b6789e4d8
--- /dev/null
+++ b/backend/tests/Unit/Listeners/Waitlist/CancelWaitlistEntriesOnOccurrenceCancelledListenerTest.php
@@ -0,0 +1,122 @@
+waitlistEntryRepository = Mockery::mock(WaitlistEntryRepositoryInterface::class);
+ $this->listener = new CancelWaitlistEntriesOnOccurrenceCancelledListener(
+ $this->waitlistEntryRepository,
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ public function test_cancels_waiting_and_offered_entries_for_occurrence(): void
+ {
+ // The listener bulk-updates by event + occurrence + status. Without
+ // this, capacity released by attendee cancellation would let those
+ // entries pass the per-entry capacity check and fire offer emails for
+ // an occurrence that no longer exists.
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $this->waitlistEntryRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ Mockery::on(static function (array $attrs): bool {
+ // Must mark CANCELLED *and* stamp cancelled_at. Without the
+ // timestamp, bulk-cancelled entries are indistinguishable
+ // from the historical-data cohort and break audit queries.
+ return ($attrs['status'] ?? null) === WaitlistEntryStatus::CANCELLED->name
+ && array_key_exists('cancelled_at', $attrs);
+ }),
+ Mockery::on(static function (array $where) use ($eventId, $occurrenceId): bool {
+ // event_id, event_occurrence_id, status-in-clause must all be present.
+ if (($where['event_id'] ?? null) !== $eventId) {
+ return false;
+ }
+ if (($where['event_occurrence_id'] ?? null) !== $occurrenceId) {
+ return false;
+ }
+ foreach ($where as $clause) {
+ if (is_array($clause) && $clause[0] === 'status' && $clause[1] === 'in') {
+ return $clause[2] === [
+ WaitlistEntryStatus::WAITING->name,
+ WaitlistEntryStatus::OFFERED->name,
+ ];
+ }
+ }
+
+ return false;
+ }),
+ );
+
+ $this->listener->handle(new OccurrenceCancelledEvent(
+ eventId: $eventId,
+ occurrenceId: $occurrenceId,
+ refundOrders: false,
+ ));
+
+ $this->assertTrue(true);
+ }
+
+ public function test_only_targets_waiting_and_offered_status(): void
+ {
+ // PURCHASED, OFFER_EXPIRED, and already-CANCELLED entries should not
+ // be touched — flipping a PURCHASED entry to CANCELLED would lose
+ // sale-attribution data.
+ $captured = null;
+
+ $this->waitlistEntryRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->andReturnUsing(function (array $attrs, array $where) use (&$captured): int {
+ $captured = $where;
+
+ return 0;
+ });
+
+ $this->listener->handle(new OccurrenceCancelledEvent(
+ eventId: 1,
+ occurrenceId: 10,
+ refundOrders: true,
+ ));
+
+ $statusClause = null;
+ foreach ($captured as $clause) {
+ if (is_array($clause) && $clause[0] === 'status') {
+ $statusClause = $clause;
+ break;
+ }
+ }
+
+ $this->assertNotNull($statusClause);
+ $this->assertSame('in', $statusClause[1]);
+ $this->assertEqualsCanonicalizing(
+ [WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFERED->name],
+ $statusClause[2],
+ );
+ }
+}
diff --git a/backend/tests/Unit/Resources/Event/EventResourcePublicTest.php b/backend/tests/Unit/Resources/Event/EventResourcePublicTest.php
new file mode 100644
index 0000000000..b7d48239f2
--- /dev/null
+++ b/backend/tests/Unit/Resources/Event/EventResourcePublicTest.php
@@ -0,0 +1,79 @@
+map(fn (int $id) => $this->makeOccurrence($id))
+ ->push($this->makeOccurrence(250));
+
+ $event = (new EventDomainObject)
+ ->setId(1)
+ ->setAccountId(1)
+ ->setUserId(1)
+ ->setTitle('Recurring show')
+ ->setShortId('event')
+ ->setType(EventType::RECURRING->name)
+ ->setCurrency('USD')
+ ->setTimezone('UTC')
+ ->setCreatedAt('2026-01-01 00:00:00')
+ ->setUpdatedAt('2026-01-01 00:00:00')
+ ->setEventOccurrences($occurrences);
+
+ $payload = (new EventResourcePublic($event))->toArray(new Request);
+ $resolvedOccurrences = $payload['occurrences']->resolve(new Request);
+
+ $this->assertCount(201, $resolvedOccurrences);
+ $this->assertTrue(
+ collect($resolvedOccurrences)->contains(fn (array $occurrence) => $occurrence['id'] === 250)
+ );
+ }
+
+ public function test_public_resource_keeps_past_hidden_occurrence_for_single_event(): void
+ {
+ $pastOccurrence = $this->makeOccurrence(10)
+ ->setStartDate('2026-01-01 10:00:00')
+ ->setEndDate('2026-01-01 11:00:00');
+
+ $event = (new EventDomainObject)
+ ->setId(1)
+ ->setAccountId(1)
+ ->setUserId(1)
+ ->setTitle('Single show')
+ ->setShortId('event')
+ ->setType(EventType::SINGLE->name)
+ ->setCurrency('USD')
+ ->setTimezone('UTC')
+ ->setCreatedAt('2026-01-01 00:00:00')
+ ->setUpdatedAt('2026-01-01 00:00:00')
+ ->setEventOccurrences(collect([$pastOccurrence]));
+
+ $payload = (new EventResourcePublic($event))->toArray(new Request);
+ $resolvedOccurrences = $payload['occurrences']->resolve(new Request);
+
+ $this->assertCount(1, $resolvedOccurrences);
+ $this->assertSame(10, $resolvedOccurrences[0]['id']);
+ }
+
+ private function makeOccurrence(int $id): EventOccurrenceDomainObject
+ {
+ return (new EventOccurrenceDomainObject)
+ ->setId($id)
+ ->setEventId(1)
+ ->setShortId((string) $id)
+ ->setStartDate('2027-01-01 10:00:00')
+ ->setEndDate('2027-01-01 11:00:00')
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name);
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandlerTest.php
deleted file mode 100644
index 1ac7ca3c33..0000000000
--- a/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandlerTest.php
+++ /dev/null
@@ -1,90 +0,0 @@
-config = m::mock(Repository::class);
- $stripeClientFactory = m::mock(StripeClientFactory::class);
- $stripeConfigurationService = m::mock(StripeConfigurationService::class);
- $stripeAccountSyncService = m::mock(StripeAccountSyncService::class);
-
- $this->handler = new CreateStripeConnectAccountHandler(
- $accountRepository,
- $accountStripePlatformRepository,
- $databaseManager,
- $logger,
- $this->config,
- $stripeClientFactory,
- $stripeConfigurationService,
- $stripeAccountSyncService,
- );
- }
-
- public function testHandleThrowsExceptionWhenSaasModeDisabled(): void
- {
- $dto = new CreateStripeConnectAccountDTO(accountId: 1);
-
- $this->config
- ->shouldReceive('get')
- ->with('app.saas_mode_enabled')
- ->andReturn(false);
-
- $this->expectException(SaasModeEnabledException::class);
- $this->expectExceptionMessage('Stripe Connect Account creation is only available in Saas Mode.');
-
- $this->handler->handle($dto);
- }
-
- public function testHandleAllowsExecutionWhenSaasModeEnabled(): void
- {
- $dto = new CreateStripeConnectAccountDTO(accountId: 1);
-
- $this->config
- ->shouldReceive('get')
- ->with('app.saas_mode_enabled')
- ->andReturn(true);
-
- // We expect this to NOT throw the SaasModeEnabledException
- // It will fail later due to missing mocks, but that proves SaaS mode check passed
- try {
- $this->handler->handle($dto);
- } catch (SaasModeEnabledException $e) {
- $this->fail('Should not throw SaasModeEnabledException when saas mode is enabled');
- } catch (\Exception $e) {
- // Expected - will fail on missing mocks, but SaaS check passed
- $this->assertTrue(true);
- }
- }
-
- protected function tearDown(): void
- {
- m::close();
- parent::tearDown();
- }
-}
\ No newline at end of file
diff --git a/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/GetStripeConnectAccountsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/GetStripeConnectAccountsHandlerTest.php
deleted file mode 100644
index aa2f48f223..0000000000
--- a/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/GetStripeConnectAccountsHandlerTest.php
+++ /dev/null
@@ -1,148 +0,0 @@
-accountRepository = m::mock(AccountRepositoryInterface::class);
- $stripeClientFactory = m::mock(StripeClientFactory::class);
- $stripeAccountSyncService = m::mock(StripeAccountSyncService::class);
- $logger = m::mock(LoggerInterface::class);
-
- $this->handler = new GetStripeConnectAccountsHandler(
- $this->accountRepository,
- $stripeClientFactory,
- $stripeAccountSyncService,
- $logger,
- );
- }
-
- public function testHandleReturnsEmptyCollectionWhenNoStripePlatforms(): void
- {
- $accountId = 1;
- $account = m::mock(AccountDomainObject::class);
-
- $this->accountRepository
- ->shouldReceive('loadRelation')
- ->with(AccountStripePlatformDomainObject::class)
- ->andReturnSelf();
-
- $this->accountRepository
- ->shouldReceive('findById')
- ->with($accountId)
- ->andReturn($account);
-
- $account
- ->shouldReceive('getAccountStripePlatforms')
- ->andReturn(null);
-
- $account
- ->shouldReceive('getActiveStripeAccountId')
- ->andReturn(null);
-
- $account
- ->shouldReceive('isStripeSetupComplete')
- ->andReturn(false);
-
- $result = $this->handler->handle($accountId);
-
- $this->assertSame($account, $result->account);
- $this->assertTrue($result->stripeConnectAccounts->isEmpty());
- $this->assertNull($result->primaryStripeAccountId);
- $this->assertFalse($result->hasCompletedSetup);
- }
-
- public function testHandleReturnsEmptyCollectionWhenStripePlatformsEmpty(): void
- {
- $accountId = 1;
- $account = m::mock(AccountDomainObject::class);
- $emptyCollection = collect([]);
-
- $this->accountRepository
- ->shouldReceive('loadRelation')
- ->with(AccountStripePlatformDomainObject::class)
- ->andReturnSelf();
-
- $this->accountRepository
- ->shouldReceive('findById')
- ->with($accountId)
- ->andReturn($account);
-
- $account
- ->shouldReceive('getAccountStripePlatforms')
- ->andReturn($emptyCollection);
-
- $account
- ->shouldReceive('getActiveStripeAccountId')
- ->andReturn(null);
-
- $account
- ->shouldReceive('isStripeSetupComplete')
- ->andReturn(false);
-
- $result = $this->handler->handle($accountId);
-
- $this->assertTrue($result->stripeConnectAccounts->isEmpty());
- }
-
- public function testHandleSkipsAccountWithoutStripeAccountId(): void
- {
- $accountId = 1;
- $account = m::mock(AccountDomainObject::class);
- $stripePlatform = m::mock(AccountStripePlatformDomainObject::class);
- $stripePlatforms = collect([$stripePlatform]);
-
- $this->accountRepository
- ->shouldReceive('loadRelation')
- ->with(AccountStripePlatformDomainObject::class)
- ->andReturnSelf();
-
- $this->accountRepository
- ->shouldReceive('findById')
- ->with($accountId)
- ->andReturn($account);
-
- $account
- ->shouldReceive('getAccountStripePlatforms')
- ->andReturn($stripePlatforms);
-
- $stripePlatform
- ->shouldReceive('getStripeAccountId')
- ->andReturn(null);
-
- $account
- ->shouldReceive('getActiveStripeAccountId')
- ->andReturn(null);
-
- $account
- ->shouldReceive('isStripeSetupComplete')
- ->andReturn(false);
-
- $result = $this->handler->handle($accountId);
-
- $this->assertTrue($result->stripeConnectAccounts->isEmpty());
- }
-
- protected function tearDown(): void
- {
- m::close();
- parent::tearDown();
- }
-}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Account/Vat/GetAccountVatSettingHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Account/Vat/GetAccountVatSettingHandlerTest.php
deleted file mode 100644
index 5eddde1a39..0000000000
--- a/backend/tests/Unit/Services/Application/Handlers/Account/Vat/GetAccountVatSettingHandlerTest.php
+++ /dev/null
@@ -1,59 +0,0 @@
-repository = Mockery::mock(AccountVatSettingRepositoryInterface::class);
- $this->handler = new GetAccountVatSettingHandler($this->repository);
- }
-
- public function testHandleReturnsVatSetting(): void
- {
- $accountId = 123;
- $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class);
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn($vatSetting);
-
- $result = $this->handler->handle($accountId);
-
- $this->assertSame($vatSetting, $result);
- }
-
- public function testHandleReturnsNullWhenNotFound(): void
- {
- $accountId = 456;
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn(null);
-
- $result = $this->handler->handle($accountId);
-
- $this->assertNull($result);
- }
-
- protected function tearDown(): void
- {
- Mockery::close();
- parent::tearDown();
- }
-}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Account/Vat/UpsertAccountVatSettingHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Account/Vat/UpsertAccountVatSettingHandlerTest.php
deleted file mode 100644
index 30f6932a92..0000000000
--- a/backend/tests/Unit/Services/Application/Handlers/Account/Vat/UpsertAccountVatSettingHandlerTest.php
+++ /dev/null
@@ -1,325 +0,0 @@
-repository = Mockery::mock(AccountVatSettingRepositoryInterface::class);
- $this->viesService = Mockery::mock(ViesValidationService::class);
- $this->logger = Mockery::mock(LoggerInterface::class);
- $this->logger->shouldReceive('info')->byDefault();
- $this->handler = new UpsertAccountVatSettingHandler(
- $this->repository,
- $this->viesService,
- $this->logger
- );
- }
-
- public function testHandleCreatesVatSettingWithSyncValidationSuccess(): void
- {
- $accountId = 123;
- $vatNumber = 'IE1234567A';
- $dto = new UpsertAccountVatSettingDTO(
- accountId: $accountId,
- vatRegistered: true,
- vatNumber: $vatNumber,
- );
-
- $validationResponse = new ViesValidationResponseDTO(
- valid: true,
- businessName: 'Test Company Ltd',
- businessAddress: '123 Test Street',
- countryCode: 'IE',
- vatNumber: '1234567A',
- isTransientError: false,
- );
-
- $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class);
- $vatSetting->shouldReceive('getId')->andReturn(1);
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn(null);
-
- $this->viesService
- ->shouldReceive('validateVatNumber')
- ->with($vatNumber)
- ->once()
- ->andReturn($validationResponse);
-
- $this->repository
- ->shouldReceive('create')
- ->once()
- ->withArgs(function ($data) use ($accountId, $vatNumber) {
- return $data['account_id'] === $accountId
- && $data['vat_registered'] === true
- && $data['vat_number'] === $vatNumber
- && $data['vat_validated'] === true
- && $data['vat_validation_status'] === VatValidationStatus::VALID->value
- && $data['business_name'] === 'Test Company Ltd'
- && $data['vat_country_code'] === 'IE';
- })
- ->andReturn($vatSetting);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($vatSetting, $result);
- Queue::assertNotPushed(ValidateVatNumberJob::class);
- }
-
- public function testHandleQueuesJobOnTransientError(): void
- {
- $accountId = 123;
- $vatNumber = 'IE1234567A';
- $dto = new UpsertAccountVatSettingDTO(
- accountId: $accountId,
- vatRegistered: true,
- vatNumber: $vatNumber,
- );
-
- $validationResponse = new ViesValidationResponseDTO(
- valid: false,
- countryCode: 'IE',
- vatNumber: '1234567A',
- isTransientError: true,
- errorMessage: 'VIES service is temporarily busy',
- );
-
- $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class);
- $vatSetting->shouldReceive('getId')->andReturn(1);
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn(null);
-
- $this->viesService
- ->shouldReceive('validateVatNumber')
- ->with($vatNumber)
- ->once()
- ->andReturn($validationResponse);
-
- $this->repository
- ->shouldReceive('create')
- ->once()
- ->withArgs(function ($data) use ($accountId, $vatNumber) {
- return $data['account_id'] === $accountId
- && $data['vat_registered'] === true
- && $data['vat_number'] === $vatNumber
- && $data['vat_validated'] === false
- && $data['vat_validation_status'] === VatValidationStatus::PENDING->value
- && $data['vat_country_code'] === 'IE';
- })
- ->andReturn($vatSetting);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($vatSetting, $result);
- Queue::assertPushed(ValidateVatNumberJob::class);
- }
-
- public function testHandleDoesNotQueueJobOnInvalidVatNumber(): void
- {
- $accountId = 123;
- $vatNumber = 'IE9999999ZZ';
- $dto = new UpsertAccountVatSettingDTO(
- accountId: $accountId,
- vatRegistered: true,
- vatNumber: $vatNumber,
- );
-
- $validationResponse = new ViesValidationResponseDTO(
- valid: false,
- countryCode: 'IE',
- vatNumber: '9999999ZZ',
- isTransientError: false,
- errorMessage: 'VAT number not found',
- );
-
- $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class);
- $vatSetting->shouldReceive('getId')->andReturn(1);
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn(null);
-
- $this->viesService
- ->shouldReceive('validateVatNumber')
- ->with($vatNumber)
- ->once()
- ->andReturn($validationResponse);
-
- $this->repository
- ->shouldReceive('create')
- ->once()
- ->withArgs(function ($data) use ($accountId, $vatNumber) {
- return $data['account_id'] === $accountId
- && $data['vat_registered'] === true
- && $data['vat_number'] === $vatNumber
- && $data['vat_validated'] === false
- && $data['vat_validation_status'] === VatValidationStatus::INVALID->value;
- })
- ->andReturn($vatSetting);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($vatSetting, $result);
- Queue::assertNotPushed(ValidateVatNumberJob::class);
- }
-
- public function testHandleCreatesVatSettingWithInvalidFormat(): void
- {
- $accountId = 123;
- $vatNumber = 'INVALID';
- $dto = new UpsertAccountVatSettingDTO(
- accountId: $accountId,
- vatRegistered: true,
- vatNumber: $vatNumber,
- );
-
- $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class);
- $vatSetting->shouldReceive('getId')->andReturn(1);
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn(null);
-
- $this->viesService
- ->shouldNotReceive('validateVatNumber');
-
- $this->repository
- ->shouldReceive('create')
- ->once()
- ->withArgs(function ($data) use ($accountId) {
- return $data['account_id'] === $accountId
- && $data['vat_registered'] === true
- && $data['vat_number'] === 'INVALID'
- && $data['vat_validated'] === false
- && $data['vat_validation_status'] === VatValidationStatus::INVALID->value
- && $data['business_name'] === null;
- })
- ->andReturn($vatSetting);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($vatSetting, $result);
- Queue::assertNotPushed(ValidateVatNumberJob::class);
- }
-
- public function testHandleCreatesVatSettingForNonRegistered(): void
- {
- $accountId = 123;
- $dto = new UpsertAccountVatSettingDTO(
- accountId: $accountId,
- vatRegistered: false,
- );
-
- $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class);
- $vatSetting->shouldReceive('getId')->andReturn(1);
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn(null);
-
- $this->viesService
- ->shouldNotReceive('validateVatNumber');
-
- $this->repository
- ->shouldReceive('create')
- ->once()
- ->withArgs(function ($data) use ($accountId) {
- return $data['account_id'] === $accountId
- && $data['vat_registered'] === false
- && $data['vat_number'] === null
- && $data['vat_validated'] === false;
- })
- ->andReturn($vatSetting);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($vatSetting, $result);
- Queue::assertNotPushed(ValidateVatNumberJob::class);
- }
-
- public function testHandleDoesNotValidateIfVatNumberUnchanged(): void
- {
- $accountId = 123;
- $existingId = 456;
- $vatNumber = 'DE123456789';
-
- $existing = Mockery::mock(AccountVatSettingDomainObject::class);
- $existing->shouldReceive('getId')->andReturn($existingId);
- $existing->shouldReceive('getVatNumber')->andReturn($vatNumber);
-
- $dto = new UpsertAccountVatSettingDTO(
- accountId: $accountId,
- vatRegistered: true,
- vatNumber: $vatNumber,
- );
-
- $updated = Mockery::mock(AccountVatSettingDomainObject::class);
- $updated->shouldReceive('getId')->andReturn($existingId);
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn($existing);
-
- $this->viesService
- ->shouldNotReceive('validateVatNumber');
-
- $this->repository
- ->shouldReceive('updateFromArray')
- ->once()
- ->with($existingId, Mockery::on(function ($data) use ($accountId, $vatNumber) {
- return $data['account_id'] === $accountId
- && $data['vat_registered'] === true
- && $data['vat_number'] === $vatNumber
- && !isset($data['vat_validated']);
- }))
- ->andReturn($updated);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($updated, $result);
- Queue::assertNotPushed(ValidateVatNumberJob::class);
- }
-
- protected function tearDown(): void
- {
- Mockery::close();
- parent::tearDown();
- }
-}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Admin/AssignConfigurationHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Admin/AssignConfigurationHandlerTest.php
deleted file mode 100644
index 4752bf23c8..0000000000
--- a/backend/tests/Unit/Services/Application/Handlers/Admin/AssignConfigurationHandlerTest.php
+++ /dev/null
@@ -1,79 +0,0 @@
-accountRepository = Mockery::mock(AccountRepositoryInterface::class);
- $this->configurationRepository = Mockery::mock(AccountConfigurationRepositoryInterface::class);
- $this->handler = new AssignConfigurationHandler(
- $this->accountRepository,
- $this->configurationRepository
- );
- }
-
- public function testHandleSuccessfullyAssignsConfiguration(): void
- {
- $accountId = 123;
- $configurationId = 456;
- $configuration = Mockery::mock(AccountConfigurationDomainObject::class);
- $account = Mockery::mock(AccountDomainObject::class);
-
- $this->configurationRepository
- ->shouldReceive('findById')
- ->with($configurationId)
- ->once()
- ->andReturn($configuration);
-
- $this->accountRepository
- ->shouldReceive('updateFromArray')
- ->with($accountId, ['account_configuration_id' => $configurationId])
- ->once()
- ->andReturn($account);
-
- $this->handler->handle($accountId, $configurationId);
-
- $this->assertTrue(true);
- }
-
- public function testHandleThrowsExceptionWhenConfigurationNotFound(): void
- {
- $accountId = 123;
- $configurationId = 999;
-
- $this->configurationRepository
- ->shouldReceive('findById')
- ->with($configurationId)
- ->once()
- ->andThrow(new ModelNotFoundException());
-
- $this->accountRepository
- ->shouldNotReceive('updateFromArray');
-
- $this->expectException(ModelNotFoundException::class);
-
- $this->handler->handle($accountId, $configurationId);
- }
-
- protected function tearDown(): void
- {
- Mockery::close();
- parent::tearDown();
- }
-}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Admin/DeleteConfigurationHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Admin/DeleteConfigurationHandlerTest.php
deleted file mode 100644
index 8844fb8c13..0000000000
--- a/backend/tests/Unit/Services/Application/Handlers/Admin/DeleteConfigurationHandlerTest.php
+++ /dev/null
@@ -1,95 +0,0 @@
-repository = Mockery::mock(AccountConfigurationRepositoryInterface::class);
- $this->handler = new DeleteConfigurationHandler($this->repository);
- }
-
- public function testHandleSuccessfullyDeletesConfiguration(): void
- {
- $configurationId = 123;
- $configuration = Mockery::mock(AccountConfigurationDomainObject::class);
-
- $configuration
- ->shouldReceive('getIsSystemDefault')
- ->once()
- ->andReturn(false);
-
- $this->repository
- ->shouldReceive('findById')
- ->with($configurationId)
- ->once()
- ->andReturn($configuration);
-
- $this->repository
- ->shouldReceive('deleteById')
- ->with($configurationId)
- ->once();
-
- $this->handler->handle($configurationId);
-
- $this->assertTrue(true);
- }
-
- public function testHandleThrowsExceptionWhenDeletingSystemDefault(): void
- {
- $configurationId = 1;
- $configuration = Mockery::mock(AccountConfigurationDomainObject::class);
-
- $configuration
- ->shouldReceive('getIsSystemDefault')
- ->once()
- ->andReturn(true);
-
- $this->repository
- ->shouldReceive('findById')
- ->with($configurationId)
- ->once()
- ->andReturn($configuration);
-
- $this->repository
- ->shouldNotReceive('deleteById');
-
- $this->expectException(CannotDeleteEntityException::class);
- $this->expectExceptionMessage('The system default configuration cannot be deleted.');
-
- $this->handler->handle($configurationId);
- }
-
- public function testHandleThrowsExceptionWhenConfigurationNotFound(): void
- {
- $configurationId = 999;
-
- $this->repository
- ->shouldReceive('findById')
- ->with($configurationId)
- ->once()
- ->andThrow(new \Illuminate\Database\Eloquent\ModelNotFoundException());
-
- $this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class);
-
- $this->handler->handle($configurationId);
- }
-
- protected function tearDown(): void
- {
- Mockery::close();
- parent::tearDown();
- }
-}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Admin/GetAccountHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Admin/GetAccountHandlerTest.php
deleted file mode 100644
index 343d34549d..0000000000
--- a/backend/tests/Unit/Services/Application/Handlers/Admin/GetAccountHandlerTest.php
+++ /dev/null
@@ -1,59 +0,0 @@
-repository = Mockery::mock(AccountRepositoryInterface::class);
- $this->handler = new GetAccountHandler($this->repository);
- }
-
- public function testHandleReturnsAccountWithDetails(): void
- {
- $accountId = 123;
- $account = Mockery::mock(Account::class);
-
- $this->repository
- ->shouldReceive('getAccountWithDetails')
- ->with($accountId)
- ->once()
- ->andReturn($account);
-
- $result = $this->handler->handle($accountId);
-
- $this->assertSame($account, $result);
- }
-
- public function testHandleThrowsExceptionWhenAccountNotFound(): void
- {
- $accountId = 999;
-
- $this->repository
- ->shouldReceive('getAccountWithDetails')
- ->with($accountId)
- ->once()
- ->andThrow(new \Illuminate\Database\Eloquent\ModelNotFoundException());
-
- $this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class);
-
- $this->handler->handle($accountId);
- }
-
- protected function tearDown(): void
- {
- Mockery::close();
- parent::tearDown();
- }
-}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Admin/GetAllOrdersHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Admin/GetAllOrdersHandlerTest.php
deleted file mode 100644
index 52010d725f..0000000000
--- a/backend/tests/Unit/Services/Application/Handlers/Admin/GetAllOrdersHandlerTest.php
+++ /dev/null
@@ -1,134 +0,0 @@
-repository = Mockery::mock(OrderRepositoryInterface::class);
- $this->handler = new GetAllOrdersHandler($this->repository);
- }
-
- public function testHandleReturnsPaginatedOrders(): void
- {
- $dto = new GetAllOrdersDTO(
- perPage: 20,
- search: null,
- sortBy: 'created_at',
- sortDirection: 'desc',
- );
-
- $paginator = Mockery::mock(LengthAwarePaginator::class);
-
- $this->repository
- ->shouldReceive('getAllOrdersForAdmin')
- ->with(null, 20, 'created_at', 'desc')
- ->once()
- ->andReturn($paginator);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($paginator, $result);
- }
-
- public function testHandleWithSearchQuery(): void
- {
- $dto = new GetAllOrdersDTO(
- perPage: 10,
- search: 'test@example.com',
- sortBy: 'created_at',
- sortDirection: 'desc',
- );
-
- $paginator = Mockery::mock(LengthAwarePaginator::class);
-
- $this->repository
- ->shouldReceive('getAllOrdersForAdmin')
- ->with('test@example.com', 10, 'created_at', 'desc')
- ->once()
- ->andReturn($paginator);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($paginator, $result);
- }
-
- public function testHandleWithCustomSorting(): void
- {
- $dto = new GetAllOrdersDTO(
- perPage: 25,
- search: null,
- sortBy: 'total_gross',
- sortDirection: 'asc',
- );
-
- $paginator = Mockery::mock(LengthAwarePaginator::class);
-
- $this->repository
- ->shouldReceive('getAllOrdersForAdmin')
- ->with(null, 25, 'total_gross', 'asc')
- ->once()
- ->andReturn($paginator);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($paginator, $result);
- }
-
- public function testHandleWithDefaultValues(): void
- {
- $dto = new GetAllOrdersDTO();
-
- $paginator = Mockery::mock(LengthAwarePaginator::class);
-
- $this->repository
- ->shouldReceive('getAllOrdersForAdmin')
- ->with(null, 20, 'created_at', 'desc')
- ->once()
- ->andReturn($paginator);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($paginator, $result);
- }
-
- public function testHandleWithNameSearch(): void
- {
- $dto = new GetAllOrdersDTO(
- perPage: 20,
- search: 'John Doe',
- sortBy: 'first_name',
- sortDirection: 'asc',
- );
-
- $paginator = Mockery::mock(LengthAwarePaginator::class);
-
- $this->repository
- ->shouldReceive('getAllOrdersForAdmin')
- ->with('John Doe', 20, 'first_name', 'asc')
- ->once()
- ->andReturn($paginator);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($paginator, $result);
- }
-
- protected function tearDown(): void
- {
- Mockery::close();
- parent::tearDown();
- }
-}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Admin/UpdateAccountConfigurationHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Admin/UpdateAccountConfigurationHandlerTest.php
deleted file mode 100644
index 2f594daaa7..0000000000
--- a/backend/tests/Unit/Services/Application/Handlers/Admin/UpdateAccountConfigurationHandlerTest.php
+++ /dev/null
@@ -1,170 +0,0 @@
-configurationRepository = Mockery::mock(AccountConfigurationRepositoryInterface::class);
- $this->accountRepository = Mockery::mock(AccountRepositoryInterface::class);
- $this->handler = new UpdateAccountConfigurationHandler(
- $this->configurationRepository,
- $this->accountRepository
- );
- }
-
- public function testHandleUpdatesExistingConfiguration(): void
- {
- $accountId = 123;
- $configurationId = 456;
- $applicationFees = ['fixed' => 100, 'percentage' => 2.5];
-
- $existingConfig = Mockery::mock(AccountConfigurationDomainObject::class);
- $existingConfig->shouldReceive('getId')->andReturn($configurationId);
-
- $account = Mockery::mock(AccountDomainObject::class);
- $account->shouldReceive('getConfiguration')->andReturn($existingConfig);
-
- $updatedConfig = Mockery::mock(AccountConfigurationDomainObject::class);
-
- $dto = new UpdateAccountConfigurationDTO(
- accountId: $accountId,
- applicationFees: $applicationFees,
- );
-
- $this->accountRepository
- ->shouldReceive('loadRelation')
- ->with('configuration')
- ->once()
- ->andReturnSelf();
-
- $this->accountRepository
- ->shouldReceive('findById')
- ->with($accountId)
- ->once()
- ->andReturn($account);
-
- $this->configurationRepository
- ->shouldReceive('updateFromArray')
- ->with($configurationId, ['application_fees' => $applicationFees])
- ->once()
- ->andReturn($updatedConfig);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($updatedConfig, $result);
- }
-
- public function testHandleCreatesNewConfigurationWhenNoneExists(): void
- {
- $accountId = 123;
- $applicationFees = ['fixed' => 50, 'percentage' => 1.5];
-
- $account = Mockery::mock(AccountDomainObject::class);
- $account->shouldReceive('getConfiguration')->andReturn(null);
- $account->shouldReceive('getId')->andReturn($accountId);
-
- $newConfig = Mockery::mock(AccountConfigurationDomainObject::class);
- $newConfig->shouldReceive('getId')->andReturn(789);
-
- $dto = new UpdateAccountConfigurationDTO(
- accountId: $accountId,
- applicationFees: $applicationFees,
- );
-
- $this->accountRepository
- ->shouldReceive('loadRelation')
- ->with('configuration')
- ->once()
- ->andReturnSelf();
-
- $this->accountRepository
- ->shouldReceive('findById')
- ->with($accountId)
- ->once()
- ->andReturn($account);
-
- $this->configurationRepository
- ->shouldReceive('create')
- ->with([
- 'name' => 'Account Configuration',
- 'is_system_default' => false,
- 'application_fees' => $applicationFees,
- ])
- ->once()
- ->andReturn($newConfig);
-
- $this->accountRepository
- ->shouldReceive('updateFromArray')
- ->with($accountId, ['account_configuration_id' => 789])
- ->once()
- ->andReturn($account);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($newConfig, $result);
- }
-
- public function testHandleWithZeroFees(): void
- {
- $accountId = 123;
- $configurationId = 456;
- $applicationFees = ['fixed' => 0, 'percentage' => 0];
-
- $existingConfig = Mockery::mock(AccountConfigurationDomainObject::class);
- $existingConfig->shouldReceive('getId')->andReturn($configurationId);
-
- $account = Mockery::mock(AccountDomainObject::class);
- $account->shouldReceive('getConfiguration')->andReturn($existingConfig);
-
- $updatedConfig = Mockery::mock(AccountConfigurationDomainObject::class);
-
- $dto = new UpdateAccountConfigurationDTO(
- accountId: $accountId,
- applicationFees: $applicationFees,
- );
-
- $this->accountRepository
- ->shouldReceive('loadRelation')
- ->with('configuration')
- ->once()
- ->andReturnSelf();
-
- $this->accountRepository
- ->shouldReceive('findById')
- ->with($accountId)
- ->once()
- ->andReturn($account);
-
- $this->configurationRepository
- ->shouldReceive('updateFromArray')
- ->with($configurationId, ['application_fees' => $applicationFees])
- ->once()
- ->andReturn($updatedConfig);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($updatedConfig, $result);
- }
-
- protected function tearDown(): void
- {
- Mockery::close();
- parent::tearDown();
- }
-}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Admin/UpdateAdminAccountVatSettingHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Admin/UpdateAdminAccountVatSettingHandlerTest.php
deleted file mode 100644
index 5a8e11b0b9..0000000000
--- a/backend/tests/Unit/Services/Application/Handlers/Admin/UpdateAdminAccountVatSettingHandlerTest.php
+++ /dev/null
@@ -1,185 +0,0 @@
-repository = Mockery::mock(AccountVatSettingRepositoryInterface::class);
- $this->handler = new UpdateAdminAccountVatSettingHandler($this->repository);
- }
-
- public function testHandleCreatesNewVatSetting(): void
- {
- $accountId = 123;
- $dto = new UpdateAdminAccountVatSettingDTO(
- accountId: $accountId,
- vatRegistered: true,
- vatNumber: 'DE123456789',
- vatValidated: true,
- businessName: 'Test Company',
- businessAddress: '123 Test St',
- vatCountryCode: 'DE',
- );
-
- $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class);
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn(null);
-
- $this->repository
- ->shouldReceive('create')
- ->once()
- ->withArgs(function ($data) use ($accountId) {
- return $data['account_id'] === $accountId
- && $data['vat_registered'] === true
- && $data['vat_number'] === 'DE123456789'
- && $data['vat_validated'] === true
- && $data['business_name'] === 'Test Company'
- && $data['business_address'] === '123 Test St'
- && $data['vat_country_code'] === 'DE';
- })
- ->andReturn($vatSetting);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($vatSetting, $result);
- }
-
- public function testHandleUpdatesExistingVatSetting(): void
- {
- $accountId = 123;
- $existingId = 456;
-
- $existing = Mockery::mock(AccountVatSettingDomainObject::class);
- $existing->shouldReceive('getId')->andReturn($existingId);
-
- $dto = new UpdateAdminAccountVatSettingDTO(
- accountId: $accountId,
- vatRegistered: true,
- vatNumber: 'IE1234567A',
- vatValidated: false,
- businessName: 'Updated Company',
- businessAddress: '456 New St',
- vatCountryCode: 'IE',
- );
-
- $updated = Mockery::mock(AccountVatSettingDomainObject::class);
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn($existing);
-
- $this->repository
- ->shouldReceive('updateFromArray')
- ->once()
- ->with($existingId, Mockery::on(function ($data) use ($accountId) {
- return $data['account_id'] === $accountId
- && $data['vat_registered'] === true
- && $data['vat_number'] === 'IE1234567A'
- && $data['vat_validated'] === false
- && $data['business_name'] === 'Updated Company'
- && $data['business_address'] === '456 New St'
- && $data['vat_country_code'] === 'IE';
- }))
- ->andReturn($updated);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($updated, $result);
- }
-
- public function testHandleCreatesNonRegisteredVatSetting(): void
- {
- $accountId = 123;
- $dto = new UpdateAdminAccountVatSettingDTO(
- accountId: $accountId,
- vatRegistered: false,
- );
-
- $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class);
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn(null);
-
- $this->repository
- ->shouldReceive('create')
- ->once()
- ->withArgs(function ($data) use ($accountId) {
- return $data['account_id'] === $accountId
- && $data['vat_registered'] === false
- && $data['vat_number'] === null
- && $data['vat_validated'] === false
- && $data['business_name'] === null
- && $data['business_address'] === null
- && $data['vat_country_code'] === null;
- })
- ->andReturn($vatSetting);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($vatSetting, $result);
- }
-
- public function testHandleWithPartialData(): void
- {
- $accountId = 123;
- $dto = new UpdateAdminAccountVatSettingDTO(
- accountId: $accountId,
- vatRegistered: true,
- vatNumber: 'FR12345678901',
- );
-
- $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class);
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn(null);
-
- $this->repository
- ->shouldReceive('create')
- ->once()
- ->withArgs(function ($data) use ($accountId) {
- return $data['account_id'] === $accountId
- && $data['vat_registered'] === true
- && $data['vat_number'] === 'FR12345678901'
- && $data['vat_validated'] === false
- && $data['business_name'] === null
- && $data['business_address'] === null
- && $data['vat_country_code'] === null;
- })
- ->andReturn($vatSetting);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($vatSetting, $result);
- }
-
- protected function tearDown(): void
- {
- Mockery::close();
- parent::tearDown();
- }
-}
diff --git a/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeeDetailPublicHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeeDetailPublicHandlerTest.php
new file mode 100644
index 0000000000..c1f90f8a2a
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeeDetailPublicHandlerTest.php
@@ -0,0 +1,174 @@
+checkInListRepository = m::mock(CheckInListRepositoryInterface::class);
+ $this->attendeeRepository = m::mock(AttendeeRepositoryInterface::class);
+
+ $this->handler = new GetCheckInListAttendeeDetailPublicHandler(
+ $this->attendeeRepository,
+ $this->checkInListRepository
+ );
+ }
+
+ public function testHandleThrowsNotFoundIfCheckInListMissing(): void
+ {
+ $this->checkInListRepository
+ ->shouldReceive('loadRelation')->andReturnSelf();
+ $this->checkInListRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturnNull();
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle('short-id', 'A-123', null);
+ }
+
+ public function testHandleThrowsNotFoundIfAttendeeMissing(): void
+ {
+ $checkInList = $this->buildList(eventId: 5);
+
+ $this->checkInListRepository
+ ->shouldReceive('loadRelation')->andReturnSelf();
+ $this->checkInListRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn($checkInList);
+
+ $this->attendeeRepository
+ ->shouldReceive('loadRelation')->andReturnSelf();
+ $this->attendeeRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['public_id' => 'A-123', 'event_id' => 5])
+ ->andReturnNull();
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle('short-id', 'A-123', null);
+ }
+
+ public function testAnonymousRequestRespectsListVisibilityFlags(): void
+ {
+ $checkInList = $this->buildList(
+ eventId: 5,
+ accountId: 77,
+ showNotes: false,
+ showQuestions: true,
+ showOrderDetails: false,
+ );
+ $attendee = m::mock(AttendeeDomainObject::class);
+ $attendee->shouldReceive('getCheckIns')->andReturn(null);
+
+ $this->setupRepos($checkInList, $attendee);
+
+ $result = $this->handler->handle('short-id', 'A-123', null);
+
+ $this->assertFalse($result->showNotes);
+ $this->assertTrue($result->showQuestionAnswers);
+ $this->assertFalse($result->showOrderDetails);
+ }
+
+ public function testAuthenticatedStaffBypassesVisibilityFlags(): void
+ {
+ $checkInList = $this->buildList(
+ eventId: 5,
+ accountId: 77,
+ showNotes: false,
+ showQuestions: false,
+ showOrderDetails: false,
+ );
+ $attendee = m::mock(AttendeeDomainObject::class);
+ $attendee->shouldReceive('getCheckIns')->andReturn(null);
+
+ $this->setupRepos($checkInList, $attendee);
+
+ $result = $this->handler->handle('short-id', 'A-123', staffAccountId: 77);
+
+ $this->assertTrue($result->showNotes);
+ $this->assertTrue($result->showQuestionAnswers);
+ $this->assertTrue($result->showOrderDetails);
+ }
+
+ public function testAuthenticatedUserFromDifferentAccountStillFiltered(): void
+ {
+ $checkInList = $this->buildList(
+ eventId: 5,
+ accountId: 77,
+ showNotes: false,
+ showQuestions: false,
+ showOrderDetails: false,
+ );
+ $attendee = m::mock(AttendeeDomainObject::class);
+ $attendee->shouldReceive('getCheckIns')->andReturn(null);
+
+ $this->setupRepos($checkInList, $attendee);
+
+ $result = $this->handler->handle('short-id', 'A-123', staffAccountId: 88);
+
+ $this->assertFalse($result->showNotes);
+ $this->assertFalse($result->showQuestionAnswers);
+ $this->assertFalse($result->showOrderDetails);
+ }
+
+ private function buildList(
+ int $eventId = 5,
+ int $accountId = 1,
+ bool $showNotes = true,
+ bool $showQuestions = true,
+ bool $showOrderDetails = true,
+ ): CheckInListDomainObject {
+ $event = m::mock(EventDomainObject::class);
+ $event->shouldReceive('getAccountId')->andReturn($accountId);
+
+ $checkInList = m::mock(CheckInListDomainObject::class);
+ $checkInList->shouldReceive('getId')->andReturn(1);
+ $checkInList->shouldReceive('getEventId')->andReturn($eventId);
+ $checkInList->shouldReceive('getEvent')->andReturn($event);
+ $checkInList->shouldReceive('getExpiresAt')->andReturn(null);
+ $checkInList->shouldReceive('getActivatesAt')->andReturn(null);
+ $checkInList->shouldReceive('getProducts')->andReturn(new Collection());
+ $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(null);
+ $checkInList->shouldReceive('getPublicShowAttendeeNotes')->andReturn($showNotes);
+ $checkInList->shouldReceive('getPublicShowQuestionAnswers')->andReturn($showQuestions);
+ $checkInList->shouldReceive('getPublicShowOrderDetails')->andReturn($showOrderDetails);
+ return $checkInList;
+ }
+
+ private function setupRepos(CheckInListDomainObject $checkInList, $attendee): void
+ {
+ $this->checkInListRepository
+ ->shouldReceive('loadRelation')->andReturnSelf();
+ $this->checkInListRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn($checkInList);
+
+ $this->attendeeRepository
+ ->shouldReceive('loadRelation')->andReturnSelf();
+ $this->attendeeRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn($attendee);
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeePublicHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeePublicHandlerTest.php
index d07cf0bae8..c2b5bbea09 100644
--- a/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeePublicHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeePublicHandlerTest.php
@@ -8,6 +8,7 @@
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface;
use HiEvents\Services\Application\Handlers\CheckInList\Public\GetCheckInListAttendeePublicHandler;
+use Illuminate\Support\Collection;
use Mockery as m;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Tests\TestCase;
@@ -95,6 +96,8 @@ public function testHandleReturnsAttendeeSuccessfully(): void
$checkInList->shouldReceive('getExpiresAt')->once()->andReturn(null);
$checkInList->shouldReceive('getActivatesAt')->once()->andReturn(null);
$checkInList->shouldReceive('getEventId')->once()->andReturn(123);
+ $checkInList->shouldReceive('getProducts')->once()->andReturn(new Collection());
+ $checkInList->shouldReceive('getEventOccurrenceId')->once()->andReturn(null);
$attendee = m::mock(AttendeeDomainObject::class);
diff --git a/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandlerTest.php
new file mode 100644
index 0000000000..988e1c07cb
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandlerTest.php
@@ -0,0 +1,149 @@
+checkInListRepository = m::mock(CheckInListRepositoryInterface::class);
+ $this->attendeeRepository = m::mock(AttendeeRepositoryInterface::class);
+
+ $this->handler = new GetCheckInListAttendeesPublicHandler(
+ $this->attendeeRepository,
+ $this->checkInListRepository,
+ );
+ }
+
+ private function buildCheckInList(?int $occurrenceId): CheckInListDomainObject
+ {
+ $list = m::mock(CheckInListDomainObject::class);
+ $list->shouldReceive('getId')->andReturn(1);
+ $list->shouldReceive('getEventOccurrenceId')->andReturn($occurrenceId);
+ $list->shouldReceive('getExpiresAt')->andReturn(null);
+ $list->shouldReceive('getActivatesAt')->andReturn(null);
+
+ return $list;
+ }
+
+ private function expectCheckInListLoaded(CheckInListDomainObject $list): void
+ {
+ $this->checkInListRepository
+ ->shouldReceive('loadRelation')->andReturnSelf();
+ $this->checkInListRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn($list);
+ }
+
+ private function emptyAttendeePaginator(): Paginator
+ {
+ return new Paginator(collect([]), 10);
+ }
+
+ public function testInjectsCheckInListOccurrenceFilterWhenListIsScoped(): void
+ {
+ $this->expectCheckInListLoaded($this->buildCheckInList(occurrenceId: 42));
+
+ $capturedParams = null;
+ $this->attendeeRepository
+ ->shouldReceive('getAttendeesByCheckInShortId')
+ ->once()
+ ->with('short-id', m::on(function (QueryParamsDTO $params) use (&$capturedParams) {
+ $capturedParams = $params;
+ return true;
+ }))
+ ->andReturn($this->emptyAttendeePaginator());
+
+ $this->handler->handle('short-id', new QueryParamsDTO());
+
+ $this->assertNotNull($capturedParams);
+ $filter = $capturedParams->filter_fields->firstWhere('field', 'event_occurrence_id');
+ $this->assertNotNull($filter, 'expected event_occurrence_id filter to be injected');
+ $this->assertSame('42', $filter->value);
+ }
+
+ public function testOverridesClientSuppliedOccurrenceFilterForScopedList(): void
+ {
+ // A misbehaving or stale client might try to pass a different occurrence id.
+ // For a scoped list we must ignore it and force the list's own occurrence.
+ $this->expectCheckInListLoaded($this->buildCheckInList(occurrenceId: 42));
+
+ $capturedParams = null;
+ $this->attendeeRepository
+ ->shouldReceive('getAttendeesByCheckInShortId')
+ ->once()
+ ->with('short-id', m::on(function (QueryParamsDTO $params) use (&$capturedParams) {
+ $capturedParams = $params;
+ return true;
+ }))
+ ->andReturn($this->emptyAttendeePaginator());
+
+ $clientParams = new QueryParamsDTO(filter_fields: collect([
+ new FilterFieldDTO(field: 'event_occurrence_id', operator: 'eq', value: '99'),
+ new FilterFieldDTO(field: 'status', operator: 'eq', value: 'ACTIVE'),
+ ]));
+
+ $this->handler->handle('short-id', $clientParams);
+
+ /** @var Collection $filters */
+ $filters = $capturedParams->filter_fields;
+ $occurrenceFilters = $filters->where('field', 'event_occurrence_id');
+ $this->assertCount(1, $occurrenceFilters, 'client filter must be replaced, not appended');
+ $this->assertSame('42', $occurrenceFilters->first()->value);
+
+ // Non-occurrence client filters are preserved.
+ $this->assertCount(1, $filters->where('field', 'status'));
+ }
+
+ public function testLeavesClientSuppliedOccurrenceFilterAloneWhenListIsNotScoped(): void
+ {
+ // Unscoped ("All occurrences") list — respect the client's optional filter
+ // (this is how the filter pill in the check-in UI works).
+ $this->expectCheckInListLoaded($this->buildCheckInList(occurrenceId: null));
+
+ $capturedParams = null;
+ $this->attendeeRepository
+ ->shouldReceive('getAttendeesByCheckInShortId')
+ ->once()
+ ->with('short-id', m::on(function (QueryParamsDTO $params) use (&$capturedParams) {
+ $capturedParams = $params;
+ return true;
+ }))
+ ->andReturn($this->emptyAttendeePaginator());
+
+ $clientParams = new QueryParamsDTO(filter_fields: collect([
+ new FilterFieldDTO(field: 'event_occurrence_id', operator: 'eq', value: '77'),
+ ]));
+
+ $this->handler->handle('short-id', $clientParams);
+
+ $filter = $capturedParams->filter_fields->firstWhere('field', 'event_occurrence_id');
+ $this->assertNotNull($filter);
+ $this->assertSame('77', $filter->value);
+ }
+
+ protected function tearDown(): void
+ {
+ m::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListStatsPublicHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListStatsPublicHandlerTest.php
new file mode 100644
index 0000000000..859527e3a3
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListStatsPublicHandlerTest.php
@@ -0,0 +1,173 @@
+checkInListRepository = m::mock(CheckInListRepositoryInterface::class);
+
+ $this->handler = new GetCheckInListStatsPublicHandler(
+ $this->checkInListRepository
+ );
+ }
+
+ public function testHandleThrowsNotFoundIfCheckInListMissing(): void
+ {
+ $this->checkInListRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['short_id' => 'short-id'])
+ ->andReturnNull();
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle('short-id');
+ }
+
+ public function testHandleReturnsStatsDTO(): void
+ {
+ $checkInList = m::mock(CheckInListDomainObject::class);
+ $checkInList->shouldReceive('getId')->andReturn(42);
+ $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(null);
+
+ $this->checkInListRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['short_id' => 'short-id'])
+ ->andReturn($checkInList);
+
+ $this->checkInListRepository
+ ->shouldReceive('getCheckedInAttendeeCountById')
+ ->once()
+ ->with(42, null)
+ ->andReturn(new CheckedInAttendeesCountDTO(
+ checkInListId: 42,
+ checkedInCount: 5,
+ totalAttendeesCount: 20,
+ ));
+
+ $productStats = collect([
+ new CheckInListProductStatDTO(
+ productId: 1,
+ productTitle: 'VIP',
+ totalAttendees: 10,
+ checkedInAttendees: 3,
+ ),
+ new CheckInListProductStatDTO(
+ productId: 2,
+ productTitle: 'General',
+ totalAttendees: 10,
+ checkedInAttendees: 2,
+ ),
+ ]);
+
+ $this->checkInListRepository
+ ->shouldReceive('getPerProductCheckInStatsById')
+ ->once()
+ ->with(42, null)
+ ->andReturn($productStats);
+
+ $recentCheckIns = collect([
+ new CheckInListRecentCheckInDTO(
+ attendeePublicId: 'A-AAAAAAAA',
+ firstName: 'Alice',
+ lastName: 'Smith',
+ productTitle: 'VIP',
+ checkedInAt: '2026-04-20T10:00:00Z',
+ ),
+ ]);
+
+ $this->checkInListRepository
+ ->shouldReceive('getRecentCheckInsById')
+ ->once()
+ ->with(42, 20, null)
+ ->andReturn($recentCheckIns);
+
+ $stats = $this->handler->handle('short-id');
+
+ $this->assertSame(20, $stats->totalAttendees);
+ $this->assertSame(5, $stats->checkedInAttendees);
+ $this->assertCount(2, $stats->perProduct);
+ $this->assertSame('VIP', $stats->perProduct[0]->productTitle);
+ $this->assertSame(3, $stats->perProduct[0]->checkedInAttendees);
+ $this->assertCount(1, $stats->recentCheckIns);
+ $this->assertSame('Alice', $stats->recentCheckIns[0]->firstName);
+ }
+
+ public function testScopedListIgnoresClientOccurrenceFilter(): void
+ {
+ // A check-in list scoped to occurrence 99 must always report its own scope,
+ // regardless of what the client passes. Passing null to the repository lets
+ // it auto-scope via cil.event_occurrence_id.
+ $checkInList = m::mock(CheckInListDomainObject::class);
+ $checkInList->shouldReceive('getId')->andReturn(42);
+ $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(99);
+
+ $this->checkInListRepository
+ ->shouldReceive('findFirstWhere')->once()
+ ->andReturn($checkInList);
+
+ $this->checkInListRepository->shouldReceive('getCheckedInAttendeeCountById')
+ ->once()->with(42, null)
+ ->andReturn(new CheckedInAttendeesCountDTO(checkInListId: 42, checkedInCount: 0, totalAttendeesCount: 0));
+
+ $this->checkInListRepository->shouldReceive('getPerProductCheckInStatsById')
+ ->once()->with(42, null)
+ ->andReturn(collect());
+
+ $this->checkInListRepository->shouldReceive('getRecentCheckInsById')
+ ->once()->with(42, 20, null)
+ ->andReturn(collect());
+
+ // Client tries to override with occurrence 77; handler should ignore it.
+ $this->handler->handle('short-id', 77);
+
+ $this->assertTrue(true);
+ }
+
+ public function testUnscopedListRespectsClientOccurrenceFilter(): void
+ {
+ // Unscoped list ("All occurrences") — the client's filter-pill selection
+ // propagates through to the repository so stats reflect the filtered view.
+ $checkInList = m::mock(CheckInListDomainObject::class);
+ $checkInList->shouldReceive('getId')->andReturn(42);
+ $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(null);
+
+ $this->checkInListRepository
+ ->shouldReceive('findFirstWhere')->once()
+ ->andReturn($checkInList);
+
+ $this->checkInListRepository->shouldReceive('getCheckedInAttendeeCountById')
+ ->once()->with(42, 77)
+ ->andReturn(new CheckedInAttendeesCountDTO(checkInListId: 42, checkedInCount: 0, totalAttendeesCount: 0));
+
+ $this->checkInListRepository->shouldReceive('getPerProductCheckInStatsById')
+ ->once()->with(42, 77)
+ ->andReturn(collect());
+
+ $this->checkInListRepository->shouldReceive('getRecentCheckInsById')
+ ->once()->with(42, 20, 77)
+ ->andReturn(collect());
+
+ $this->handler->handle('short-id', 77);
+
+ $this->assertTrue(true);
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php
index 9dffed4623..3cb83cc2dd 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php
@@ -2,23 +2,35 @@
namespace Tests\Unit\Services\Application\Handlers\Event;
+use HiEvents\DomainObjects\Enums\EventType;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
+use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract;
use HiEvents\DomainObjects\PromoCodeDomainObject;
+use HiEvents\DomainObjects\Status\EventOccurrenceStatus;
+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\Product\ProductFilterService;
+use Illuminate\Support\Collection;
use Mockery as m;
use Tests\TestCase;
class GetPublicEventHandlerTest extends TestCase
{
private EventRepositoryInterface $eventRepository;
+
+ private EventOccurrenceRepositoryInterface $occurrenceRepository;
+
private PromoCodeRepositoryInterface $promoCodeRepository;
+
private ProductFilterService $ticketFilterService;
+
private EventPageViewIncrementService $eventPageViewIncrementService;
+
private GetPublicEventHandler $handler;
protected function setUp(): void
@@ -26,22 +38,24 @@ protected function setUp(): void
parent::setUp();
$this->eventRepository = m::mock(EventRepositoryInterface::class);
+ $this->occurrenceRepository = m::mock(EventOccurrenceRepositoryInterface::class);
$this->promoCodeRepository = m::mock(PromoCodeRepositoryInterface::class);
$this->ticketFilterService = m::mock(ProductFilterService::class);
$this->eventPageViewIncrementService = m::mock(EventPageViewIncrementService::class);
$this->handler = new GetPublicEventHandler(
$this->eventRepository,
+ $this->occurrenceRepository,
$this->promoCodeRepository,
$this->ticketFilterService,
$this->eventPageViewIncrementService
);
}
- public function testHandleWithoutPromoCodeAndUnauthenticatedUser(): void
+ public function test_handle_without_promo_code_and_unauthenticated_user(): void
{
$data = new GetPublicEventDTO(eventId: 1, isAuthenticated: false, ipAddress: '127.0.0.1', promoCode: null);
- $event = new EventDomainObject();
+ $event = new EventDomainObject;
$event->setProductCategories(collect());
$this->setupEventRepositoryMock($event, $data->eventId);
@@ -52,10 +66,10 @@ public function testHandleWithoutPromoCodeAndUnauthenticatedUser(): void
$this->handler->handle($data);
}
- public function testHandleWithInvalidPromoCode(): void
+ public function test_handle_with_invalid_promo_code(): void
{
$data = new GetPublicEventDTO(eventId: 1, isAuthenticated: false, ipAddress: '127.0.0.1', promoCode: 'INVALID');
- $event = new EventDomainObject();
+ $event = new EventDomainObject;
$event->setProductCategories(collect());
$promoCode = m::mock(PromoCodeDomainObject::class)->makePartial();
$promoCode->shouldReceive('isValid')->andReturn(false);
@@ -68,10 +82,10 @@ public function testHandleWithInvalidPromoCode(): void
$this->handler->handle($data);
}
- public function testHandleWithValidPromoCode(): void
+ public function test_handle_with_valid_promo_code(): void
{
$data = new GetPublicEventDTO(eventId: 1, isAuthenticated: false, ipAddress: '127.0.0.1', promoCode: 'VALID');
- $event = new EventDomainObject();
+ $event = new EventDomainObject;
$event->setProductCategories(collect());
$promoCode = m::mock(PromoCodeDomainObject::class)->makePartial();
$promoCode->shouldReceive('isValid')->andReturn(true);
@@ -84,9 +98,170 @@ public function testHandleWithValidPromoCode(): void
$this->handler->handle($data);
}
+ public function test_handle_loads_single_event_occurrence_without_future_filter(): void
+ {
+ $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: true, ipAddress: '127.0.0.1', promoCode: null);
+ $event = (new EventDomainObject)
+ ->setType(EventType::SINGLE->name)
+ ->setProductCategories(collect());
+ $pastOccurrence = $this->makeOccurrence(10, '2024-01-01 10:00:00');
+
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf()->times(4);
+ $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event);
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->with(
+ m::on(static fn (array $where): bool => ! collect($where)->contains(
+ fn ($condition): bool => is_array($condition)
+ && ($condition[0] ?? null) === EventOccurrenceDomainObjectAbstract::START_DATE
+ )),
+ m::any(),
+ m::any(),
+ m::any(),
+ )
+ ->andReturn(collect([$pastOccurrence]));
+ $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
+ $this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect());
+ $this->eventPageViewIncrementService->shouldNotReceive('increment');
+
+ $result = $this->handler->handle($data);
+
+ $this->assertTrue($result->getEventOccurrences()->contains(
+ fn (EventOccurrenceDomainObject $occurrence) => $occurrence->getId() === 10
+ ));
+ }
+
+ public function test_handle_ignores_requested_occurrence_when_it_does_not_belong_to_event(): void
+ {
+ $data = new GetPublicEventDTO(
+ eventId: 1,
+ isAuthenticated: true,
+ ipAddress: '127.0.0.1',
+ promoCode: null,
+ eventOccurrenceId: 999,
+ );
+ $event = new EventDomainObject;
+ $event->setProductCategories(collect());
+
+ $this->setupEventRepositoryMock($event, $data->eventId);
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => 999,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => 1,
+ [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name],
+ ])
+ ->andReturnNull();
+ $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
+
+ $capturedOccurrenceId = 'not-called';
+ $this->ticketFilterService
+ ->shouldReceive('filter')
+ ->once()
+ ->andReturnUsing(function (
+ Collection $productsCategories,
+ ?PromoCodeDomainObject $promoCode = null,
+ bool $hideSoldOutProducts = true,
+ ?int $eventOccurrenceId = null,
+ ) use (&$capturedOccurrenceId) {
+ $capturedOccurrenceId = $eventOccurrenceId;
+
+ return collect();
+ });
+ $this->eventPageViewIncrementService->shouldNotReceive('increment');
+
+ $this->handler->handle($data);
+
+ $this->assertNull($capturedOccurrenceId);
+ }
+
+ public function test_handle_keeps_requested_occurrence_outside_public_cap(): void
+ {
+ // The production query loads MAX_PUBLIC_OCCURRENCES + 1 (= 201) rows so
+ // the handler can detect overflow without paginating the whole
+ // recurrence series. A direct link to occurrence 5000 must therefore
+ // be resolved via findFirstWhere() and stitched back into the payload.
+ $linkedOccurrenceId = 5000;
+ $data = new GetPublicEventDTO(
+ eventId: 1,
+ isAuthenticated: true,
+ ipAddress: '127.0.0.1',
+ promoCode: null,
+ eventOccurrenceId: $linkedOccurrenceId,
+ );
+ $event = new EventDomainObject;
+ $event->setProductCategories(collect());
+
+ $loadedLimit = GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1;
+ $occurrences = collect(range(1, $loadedLimit))
+ ->map(fn (int $id) => $this->makeOccurrence($id, '2026-01-01 10:00:00'));
+ $linkedOccurrence = $this->makeOccurrence($linkedOccurrenceId, '2027-01-01 10:00:00');
+
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf()->times(4);
+ $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event);
+ // findWhere has signature ($where, $columns, $orderAndDirections, $limit)
+ // — production passes the limit by name, but Mockery sees them as
+ // positional, so we need to assert against arg index 3.
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->with(m::any(), m::any(), m::any(), m::on(static fn ($limit) => $limit === $loadedLimit))
+ ->andReturn($occurrences);
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => $linkedOccurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => 1,
+ [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name],
+ ])
+ ->andReturn($linkedOccurrence);
+ $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
+
+ $capturedOccurrenceId = null;
+ $this->ticketFilterService
+ ->shouldReceive('filter')
+ ->once()
+ ->andReturnUsing(function (
+ Collection $productsCategories,
+ ?PromoCodeDomainObject $promoCode = null,
+ bool $hideSoldOutProducts = true,
+ ?int $eventOccurrenceId = null,
+ ) use (&$capturedOccurrenceId) {
+ $capturedOccurrenceId = $eventOccurrenceId;
+
+ return collect();
+ });
+ $this->eventPageViewIncrementService->shouldNotReceive('increment');
+
+ $result = $this->handler->handle($data);
+
+ $this->assertSame($linkedOccurrenceId, $capturedOccurrenceId);
+ $this->assertTrue(
+ $result->getEventOccurrences()->contains(
+ fn (EventOccurrenceDomainObject $occurrence) => $occurrence->getId() === $linkedOccurrenceId
+ )
+ );
+ // 200 capped + 1 linked appended = MAX_PUBLIC_OCCURRENCES + 1.
+ $this->assertCount(GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1, $result->getEventOccurrences());
+ }
+
private function setupEventRepositoryMock($event, $eventId): void
{
$this->eventRepository->shouldReceive('loadRelation')->andReturnSelf()->times(4);
$this->eventRepository->shouldReceive('findById')->with($eventId)->andReturn($event);
+ $this->occurrenceRepository->shouldReceive('findWhere')->andReturn(collect());
+ }
+
+ private function makeOccurrence(int $id, string $startDate): EventOccurrenceDomainObject
+ {
+ return (new EventOccurrenceDomainObject)
+ ->setId($id)
+ ->setEventId(1)
+ ->setShortId((string) $id)
+ ->setStartDate($startDate)
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name);
}
}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandlerTest.php
new file mode 100644
index 0000000000..67652975ef
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandlerTest.php
@@ -0,0 +1,631 @@
+occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->orderItemRepository = Mockery::mock(OrderItemRepositoryInterface::class);
+ $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class);
+ $this->waitlistEntryRepository = Mockery::mock(WaitlistEntryRepositoryInterface::class);
+ $this->exclusionService = Mockery::mock(RecurrenceRuleExclusionService::class);
+ $this->databaseManager = Mockery::mock(DatabaseManager::class);
+
+ // Default: no attendees on any occurrence. Delete-path tests that need
+ // to assert the attendee guard override this expectation.
+ $this->attendeeRepository
+ ->shouldReceive('findWhereIn')
+ ->byDefault()
+ ->andReturn(new Collection);
+
+ $this->databaseManager->shouldReceive('transaction')
+ ->andReturnUsing(fn ($callback) => $callback());
+
+ // Default: exclusion update is a no-op. The DELETE branch overrides this
+ // to assert the call.
+ $this->exclusionService->shouldReceive('addExclusions')->byDefault();
+
+ // Default: waitlist cleanup is a no-op. Tests targeting the cleanup
+ // branch override this expectation.
+ $this->waitlistEntryRepository
+ ->shouldReceive('updateWhere')
+ ->byDefault()
+ ->andReturn(0);
+
+ // Capacity edits trigger SOLD_OUT/ACTIVE reconciliation via two extra
+ // scoped updateWheres on top of the main attribute update. They're
+ // a side effect — each test still asserts its specific main updateWhere
+ // explicitly, so default the reconciliation to a no-op here so it
+ // doesn't trigger NoMatchingExpectationException for unrelated tests.
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->with(
+ Mockery::on(fn (array $attrs) => array_keys($attrs) === [EventOccurrenceDomainObjectAbstract::STATUS]),
+ Mockery::any(),
+ )
+ ->zeroOrMoreTimes()
+ ->byDefault();
+
+ $this->handler = new BulkUpdateOccurrencesHandler(
+ $this->occurrenceRepository,
+ $this->orderItemRepository,
+ $this->attendeeRepository,
+ $this->waitlistEntryRepository,
+ $this->exclusionService,
+ $this->databaseManager,
+ );
+ }
+
+ public function test_handle_updates_capacity_for_future_non_overridden_occurrences(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'America/New_York',
+ capacity: 500,
+ future_only: true,
+ skip_overridden: true,
+ apply_to_all: true,
+ );
+
+ $futureOccurrence = $this->createOccurrenceMock(10, false, false);
+ $pastOccurrence = $this->createOccurrenceMock(11, true, false);
+ $overriddenOccurrence = $this->createOccurrenceMock(12, false, true);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$futureOccurrence, $pastOccurrence, $overriddenOccurrence]));
+
+ // Capacity changes pin the occurrence as overridden so future regenerates
+ // don't reset it back to the rule's default.
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ [
+ EventOccurrenceDomainObjectAbstract::CAPACITY => 500,
+ EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => true,
+ ],
+ [[EventOccurrenceDomainObjectAbstract::ID, 'in', [10]]]
+ );
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(1, $result->updated_count);
+ }
+
+ public function test_handle_shifts_time_by_minutes(): void
+ {
+ // Occurrence stored as 09:00 UTC, shift forward by 60 minutes → 10:00 UTC
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'America/New_York',
+ start_time_shift: 60,
+ end_time_shift: 60,
+ future_only: false,
+ skip_overridden: false,
+ apply_to_all: true,
+ );
+
+ $occurrence = $this->createOccurrenceMock(10, false, false, '2026-03-01 14:00:00', '2026-03-01 16:00:00');
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occurrence]));
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ Mockery::on(function ($attributes) {
+ return $attributes[EventOccurrenceDomainObjectAbstract::START_DATE] === '2026-03-01 15:00:00'
+ && $attributes[EventOccurrenceDomainObjectAbstract::END_DATE] === '2026-03-01 17:00:00';
+ }),
+ [EventOccurrenceDomainObjectAbstract::ID => 10]
+ );
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(1, $result->updated_count);
+ }
+
+ public function test_time_shift_pins_as_overridden_so_regenerate_doesnt_revert_it(): void
+ {
+ // Without is_overridden, the next regenerate would treat the shifted
+ // occurrence as stale (no candidate at its new time) and delete it.
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'UTC',
+ start_time_shift: 60,
+ future_only: false,
+ skip_overridden: false,
+ apply_to_all: true,
+ );
+
+ $occurrence = $this->createOccurrenceMock(10, false, false, '2026-03-01 14:00:00', null);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occurrence]));
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true),
+ Mockery::any(),
+ );
+
+ $result = $this->handler->handle($dto);
+ $this->assertEquals(1, $result->updated_count);
+ }
+
+ public function test_handle_shifts_time_backwards(): void
+ {
+ // Shift backward by 30 minutes: 14:00 → 13:30, 16:00 → 15:30
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'UTC',
+ start_time_shift: -30,
+ end_time_shift: -30,
+ future_only: false,
+ skip_overridden: false,
+ apply_to_all: true,
+ );
+
+ $occurrence = $this->createOccurrenceMock(10, false, false, '2026-03-01 14:00:00', '2026-03-01 16:00:00');
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occurrence]));
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ Mockery::on(function ($attributes) {
+ return $attributes[EventOccurrenceDomainObjectAbstract::START_DATE] === '2026-03-01 13:30:00'
+ && $attributes[EventOccurrenceDomainObjectAbstract::END_DATE] === '2026-03-01 15:30:00';
+ }),
+ [EventOccurrenceDomainObjectAbstract::ID => 10]
+ );
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(1, $result->updated_count);
+ }
+
+ public function test_handle_shifts_only_start_time_when_end_time_shift_is_null(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'UTC',
+ start_time_shift: 90,
+ future_only: false,
+ skip_overridden: false,
+ apply_to_all: true,
+ );
+
+ $occurrence = $this->createOccurrenceMock(10, false, false, '2026-03-01 09:00:00', '2026-03-01 11:00:00');
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occurrence]));
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ Mockery::on(function ($attributes) {
+ return $attributes[EventOccurrenceDomainObjectAbstract::START_DATE] === '2026-03-01 10:30:00'
+ && ! array_key_exists(EventOccurrenceDomainObjectAbstract::END_DATE, $attributes);
+ }),
+ [EventOccurrenceDomainObjectAbstract::ID => 10]
+ );
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(1, $result->updated_count);
+ }
+
+ public function test_handle_shift_times_does_not_add_end_date_when_null(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'UTC',
+ start_time_shift: 60,
+ end_time_shift: 60,
+ future_only: false,
+ skip_overridden: false,
+ apply_to_all: true,
+ );
+
+ $occurrence = $this->createOccurrenceMock(10, false, false, '2026-03-01 14:00:00', null);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occurrence]));
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ Mockery::on(function ($attributes) {
+ return $attributes[EventOccurrenceDomainObjectAbstract::START_DATE] === '2026-03-01 15:00:00'
+ && ! array_key_exists(EventOccurrenceDomainObjectAbstract::END_DATE, $attributes);
+ }),
+ [EventOccurrenceDomainObjectAbstract::ID => 10]
+ );
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(1, $result->updated_count);
+ }
+
+ public function test_handle_cancels_all_future_occurrences_via_job(): void
+ {
+ Bus::fake([BulkCancelOccurrencesJob::class]);
+
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::CANCEL,
+ timezone: 'UTC',
+ future_only: true,
+ skip_overridden: false,
+ refund_orders: true,
+ apply_to_all: true,
+ );
+
+ $futureOccurrence1 = $this->createOccurrenceMock(10, false, false, '2026-03-15 09:00:00');
+ $futureOccurrence2 = $this->createOccurrenceMock(11, false, true, '2026-03-22 09:00:00');
+ $pastOccurrence = $this->createOccurrenceMock(12, true, false);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$futureOccurrence1, $futureOccurrence2, $pastOccurrence]));
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(2, $result->updated_count);
+
+ Bus::assertDispatched(BulkCancelOccurrencesJob::class, function (BulkCancelOccurrencesJob $job) {
+ return $job->eventId === 1
+ && $job->occurrenceIds === [10, 11]
+ && $job->refundOrders === true;
+ });
+ }
+
+ public function test_handle_skips_cancelled_occurrences(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'UTC',
+ capacity: 100,
+ future_only: false,
+ skip_overridden: false,
+ apply_to_all: true,
+ );
+
+ $activeOccurrence = $this->createOccurrenceMock(10, false, false, '2026-03-01 09:00:00', null, EventOccurrenceStatus::ACTIVE->name);
+ $cancelledOccurrence = $this->createOccurrenceMock(11, false, false, '2026-03-02 09:00:00', null, EventOccurrenceStatus::CANCELLED->name);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$activeOccurrence, $cancelledOccurrence]));
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ [
+ EventOccurrenceDomainObjectAbstract::CAPACITY => 100,
+ EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => true,
+ ],
+ [[EventOccurrenceDomainObjectAbstract::ID, 'in', [10]]]
+ );
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(1, $result->updated_count);
+ }
+
+ public function test_handle_returns_zero_when_no_fields_to_update(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'UTC',
+ future_only: false,
+ skip_overridden: false,
+ apply_to_all: true,
+ );
+
+ $occurrence = $this->createOccurrenceMock(10, false, false);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occurrence]));
+
+ $this->occurrenceRepository
+ ->shouldNotReceive('updateWhere');
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(0, $result->updated_count);
+ }
+
+ public function test_handle_clears_capacity(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'UTC',
+ clear_capacity: true,
+ future_only: false,
+ skip_overridden: false,
+ apply_to_all: true,
+ );
+
+ $occurrence = $this->createOccurrenceMock(10, false, false);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occurrence]));
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ [
+ EventOccurrenceDomainObjectAbstract::CAPACITY => null,
+ EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => true,
+ ],
+ [[EventOccurrenceDomainObjectAbstract::ID, 'in', [10]]]
+ );
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(1, $result->updated_count);
+ }
+
+ public function test_handle_filters_to_specific_occurrence_ids(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'UTC',
+ capacity: 200,
+ future_only: false,
+ skip_overridden: false,
+ occurrence_ids: [10, 12],
+ );
+
+ $occ10 = $this->createOccurrenceMock(10, false, false);
+ $occ11 = $this->createOccurrenceMock(11, false, false);
+ $occ12 = $this->createOccurrenceMock(12, false, false);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occ10, $occ11, $occ12]));
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ [
+ EventOccurrenceDomainObjectAbstract::CAPACITY => 200,
+ EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => true,
+ ],
+ [[EventOccurrenceDomainObjectAbstract::ID, 'in', [10, 12]]]
+ );
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(2, $result->updated_count);
+ }
+
+ public function test_handle_deletes_occurrences_without_orders(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::DELETE,
+ timezone: 'UTC',
+ future_only: false,
+ skip_overridden: false,
+ occurrence_ids: [10, 11],
+ );
+
+ $occNoOrders = $this->createOccurrenceMock(10, false, false, '2026-03-01 09:00:00');
+ $occWithOrders = $this->createOccurrenceMock(11, false, false, '2026-03-08 09:00:00');
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occNoOrders, $occWithOrders]));
+
+ // Batched: a single findWhereIn returns one row per occurrence-with-orders.
+ // Occurrence 11 has orders, occurrence 10 doesn't.
+ $orderItem11 = Mockery::mock(OrderItemDomainObject::class);
+ $orderItem11->shouldReceive('getEventOccurrenceId')->andReturn(11);
+
+ $this->orderItemRepository
+ ->shouldReceive('findWhereIn')
+ ->once()
+ ->withArgs(function ($field, $values, $additionalWhere = [], $columns = []) {
+ return $field === OrderItemDomainObjectAbstract::EVENT_OCCURRENCE_ID
+ && $values === [10, 11]
+ && $columns === [OrderItemDomainObjectAbstract::EVENT_OCCURRENCE_ID];
+ })
+ ->andReturn(new Collection([$orderItem11]));
+
+ $this->occurrenceRepository
+ ->shouldReceive('deleteWhere')
+ ->once()
+ ->with([[EventOccurrenceDomainObjectAbstract::ID, 'in', [10]]]);
+
+ $this->exclusionService
+ ->shouldReceive('addExclusions')
+ ->once()
+ ->with(1, ['2026-03-01 09:00:00']);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(1, $result->updated_count);
+ }
+
+ public function test_handle_skips_deletion_for_occurrences_with_attendees_but_no_orders(): void
+ {
+ // Mirrors the single-delete handler's attendee guard. Attendees can
+ // exist without order_items in legacy/imported data, so checking only
+ // orders would soft-delete an occurrence that still has live attendees.
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::DELETE,
+ timezone: 'UTC',
+ future_only: false,
+ skip_overridden: false,
+ occurrence_ids: [10, 11],
+ );
+
+ $occClean = $this->createOccurrenceMock(10, false, false, '2026-03-01 09:00:00');
+ $occWithAttendees = $this->createOccurrenceMock(11, false, false, '2026-03-08 09:00:00');
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occClean, $occWithAttendees]));
+
+ // Batched orders lookup: no occurrences have orders.
+ $this->orderItemRepository
+ ->shouldReceive('findWhereIn')
+ ->once()
+ ->withArgs(function ($field, $values, $additionalWhere = [], $columns = []) {
+ return $field === OrderItemDomainObjectAbstract::EVENT_OCCURRENCE_ID
+ && $values === [10, 11]
+ && $columns === [OrderItemDomainObjectAbstract::EVENT_OCCURRENCE_ID];
+ })
+ ->andReturn(new Collection);
+
+ // Override default attendee no-op: occurrence 11 has attendees and
+ // must be excluded from the delete batch even though it has no orders.
+ $attendee11 = Mockery::mock(AttendeeDomainObject::class);
+ $attendee11->shouldReceive('getEventOccurrenceId')->andReturn(11);
+
+ $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class);
+ $this->attendeeRepository
+ ->shouldReceive('findWhereIn')
+ ->once()
+ ->withArgs(function ($field, $values, $additionalWhere = [], $columns = []) {
+ return $field === AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID
+ && $values === [10, 11]
+ && $columns === [AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID];
+ })
+ ->andReturn(new Collection([$attendee11]));
+
+ $this->handler = new BulkUpdateOccurrencesHandler(
+ $this->occurrenceRepository,
+ $this->orderItemRepository,
+ $this->attendeeRepository,
+ $this->waitlistEntryRepository,
+ $this->exclusionService,
+ $this->databaseManager,
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('deleteWhere')
+ ->once()
+ ->with([[EventOccurrenceDomainObjectAbstract::ID, 'in', [10]]]);
+
+ $this->exclusionService
+ ->shouldReceive('addExclusions')
+ ->once()
+ ->with(1, ['2026-03-01 09:00:00']);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(1, $result->updated_count);
+ }
+
+ private function createOccurrenceMock(
+ int $id,
+ bool $isPast,
+ bool $isOverridden,
+ string $startDate = '2026-03-01 09:00:00',
+ ?string $endDate = '2026-03-01 11:00:00',
+ string $status = 'ACTIVE',
+ ): EventOccurrenceDomainObject|MockInterface {
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('isPast')->andReturn($isPast);
+ $occurrence->shouldReceive('getIsOverridden')->andReturn($isOverridden);
+ $occurrence->shouldReceive('getId')->andReturn($id);
+ $occurrence->shouldReceive('getStatus')->andReturn($status);
+ $occurrence->shouldReceive('getStartDate')->andReturn($startDate);
+ $occurrence->shouldReceive('getEndDate')->andReturn($endDate);
+
+ return $occurrence;
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/CancelOccurrenceHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/CancelOccurrenceHandlerTest.php
new file mode 100644
index 0000000000..53478ee141
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/CancelOccurrenceHandlerTest.php
@@ -0,0 +1,273 @@
+occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->exclusionService = Mockery::mock(RecurrenceRuleExclusionService::class);
+ $this->cancelAttendeesService = Mockery::mock(CancelOccurrenceAttendeesService::class);
+ $this->databaseManager = Mockery::mock(DatabaseManager::class);
+
+ $this->databaseManager->shouldReceive('transaction')
+ ->andReturnUsing(fn ($callback) => $callback());
+
+ $this->handler = new CancelOccurrenceHandler(
+ $this->occurrenceRepository,
+ $this->exclusionService,
+ $this->cancelAttendeesService,
+ $this->databaseManager,
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ private function expectAttendeeCancelCalled(int $eventId, int $occurrenceId): void
+ {
+ $this->cancelAttendeesService
+ ->shouldReceive('cancelForOccurrence')
+ ->once()
+ ->with($eventId, $occurrenceId);
+ }
+
+ public function test_handle_sets_status_to_cancelled(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00');
+ $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name);
+ $occurrence->shouldReceive('getEventId')->andReturn($eventId);
+
+ $updatedOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn($occurrence);
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with($occurrenceId, [
+ EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::CANCELLED->name,
+ ])
+ ->andReturn($updatedOccurrence);
+ $this->expectAttendeeCancelCalled($eventId, $occurrenceId);
+ $this->exclusionService
+ ->shouldReceive('addExclusions')
+ ->once()
+ ->with($eventId, ['2026-06-15 10:00:00']);
+
+ $result = $this->handler->handle($eventId, $occurrenceId);
+
+ $this->assertSame($updatedOccurrence, $result);
+
+ Event::assertDispatched(OccurrenceCancelledEvent::class, function ($e) use ($eventId, $occurrenceId) {
+ return $e->eventId === $eventId && $e->occurrenceId === $occurrenceId;
+ });
+ }
+
+ public function test_handle_delegates_recurrence_exclusion_with_occurrence_start_date(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-07-20 14:00:00');
+ $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name);
+ $occurrence->shouldReceive('getEventId')->andReturn($eventId);
+
+ $updatedOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn($occurrence);
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')->once()->andReturn($updatedOccurrence);
+ $this->expectAttendeeCancelCalled($eventId, $occurrenceId);
+ $this->exclusionService
+ ->shouldReceive('addExclusions')
+ ->once()
+ ->with($eventId, ['2026-07-20 14:00:00']);
+
+ $result = $this->handler->handle($eventId, $occurrenceId);
+
+ $this->assertSame($updatedOccurrence, $result);
+ }
+
+ public function test_handle_throws_exception_when_occurrence_not_found(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 999;
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn(null);
+
+ $this->occurrenceRepository->shouldNotReceive('updateFromArray');
+ $this->exclusionService->shouldNotReceive('addExclusions');
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle($eventId, $occurrenceId);
+
+ Event::assertNotDispatched(OccurrenceCancelledEvent::class);
+ }
+
+ public function test_handle_throws_when_occurrence_belongs_to_different_event(): void
+ {
+ $requestedEventId = 1;
+ $foreignEventId = 999;
+ $occurrenceId = 10;
+
+ $foreignOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $foreignOccurrence->shouldReceive('getEventId')->andReturn($foreignEventId);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn($foreignOccurrence);
+
+ $this->occurrenceRepository->shouldNotReceive('updateFromArray');
+ $this->exclusionService->shouldNotReceive('addExclusions');
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle($requestedEventId, $occurrenceId);
+
+ Event::assertNotDispatched(OccurrenceCancelledEvent::class);
+ }
+
+ public function test_handle_dispatches_event_with_refund_flag_true(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00');
+ $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name);
+ $occurrence->shouldReceive('getEventId')->andReturn($eventId);
+
+ $updatedOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn($occurrence);
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')->once()->andReturn($updatedOccurrence);
+ $this->expectAttendeeCancelCalled($eventId, $occurrenceId);
+ $this->exclusionService->shouldReceive('addExclusions')->once();
+
+ $this->handler->handle($eventId, $occurrenceId, refundOrders: true);
+
+ Event::assertDispatched(OccurrenceCancelledEvent::class, function ($e) use ($eventId, $occurrenceId) {
+ return $e->eventId === $eventId
+ && $e->occurrenceId === $occurrenceId
+ && $e->refundOrders === true;
+ });
+ }
+
+ public function test_handle_dispatches_event_with_refund_flag_false(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00');
+ $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name);
+ $occurrence->shouldReceive('getEventId')->andReturn($eventId);
+
+ $updatedOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn($occurrence);
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')->once()->andReturn($updatedOccurrence);
+ $this->expectAttendeeCancelCalled($eventId, $occurrenceId);
+ $this->exclusionService->shouldReceive('addExclusions')->once();
+
+ $this->handler->handle($eventId, $occurrenceId, refundOrders: false);
+
+ Event::assertDispatched(OccurrenceCancelledEvent::class, function ($e) {
+ return $e->refundOrders === false;
+ });
+ }
+
+ public function test_it_returns_early_if_occurrence_already_cancelled(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::CANCELLED->name);
+ $occurrence->shouldReceive('getEventId')->andReturn($eventId);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn($occurrence);
+
+ $this->occurrenceRepository->shouldNotReceive('updateFromArray');
+ $this->cancelAttendeesService->shouldNotReceive('cancelForOccurrence');
+ $this->exclusionService->shouldNotReceive('addExclusions');
+
+ $result = $this->handler->handle($eventId, $occurrenceId, refundOrders: true);
+
+ $this->assertSame($occurrence, $result);
+
+ Event::assertNotDispatched(OccurrenceCancelledEvent::class);
+ }
+
+ public function test_delegates_attendee_cancellation_to_service(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00');
+ $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name);
+ $occurrence->shouldReceive('getEventId')->andReturn($eventId);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->andReturn($occurrence);
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')->once()->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $this->cancelAttendeesService
+ ->shouldReceive('cancelForOccurrence')
+ ->once()
+ ->with($eventId, $occurrenceId);
+
+ $this->exclusionService->shouldReceive('addExclusions')->once();
+
+ $result = $this->handler->handle($eventId, $occurrenceId);
+ $this->assertNotNull($result);
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandlerTest.php
new file mode 100644
index 0000000000..1c70a0aba5
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandlerTest.php
@@ -0,0 +1,107 @@
+occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->databaseManager = Mockery::mock(DatabaseManager::class);
+
+ $this->databaseManager->shouldReceive('transaction')
+ ->andReturnUsing(fn($callback) => $callback());
+
+ $this->handler = new CreateEventOccurrenceHandler(
+ $this->occurrenceRepository,
+ $this->databaseManager,
+ );
+ }
+
+ public function testHandleSuccessfullyCreatesOccurrence(): void
+ {
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: 1,
+ start_date: '2026-06-01 10:00:00',
+ end_date: '2026-06-01 18:00:00',
+ capacity: 100,
+ label: 'Morning Session',
+ is_overridden: false,
+ );
+
+ $expectedOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with(Mockery::on(function ($attrs) {
+ return $attrs[EventOccurrenceDomainObjectAbstract::EVENT_ID] === 1
+ && $attrs[EventOccurrenceDomainObjectAbstract::START_DATE] === '2026-06-01 10:00:00'
+ && $attrs[EventOccurrenceDomainObjectAbstract::END_DATE] === '2026-06-01 18:00:00'
+ && $attrs[EventOccurrenceDomainObjectAbstract::STATUS] === EventOccurrenceStatus::ACTIVE->name
+ && $attrs[EventOccurrenceDomainObjectAbstract::CAPACITY] === 100
+ && $attrs[EventOccurrenceDomainObjectAbstract::USED_CAPACITY] === 0
+ && $attrs[EventOccurrenceDomainObjectAbstract::LABEL] === 'Morning Session'
+ && str_starts_with($attrs[EventOccurrenceDomainObjectAbstract::SHORT_ID], 'oc_');
+ }))
+ ->andReturn($expectedOccurrence);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertSame($expectedOccurrence, $result);
+ }
+
+ public function testHandleAlwaysCreatesOccurrencesAsActive(): void
+ {
+ // Status is no longer client-controllable on create — it always starts
+ // ACTIVE. Cancellation goes through CancelOccurrenceHandler, SOLD_OUT
+ // is computed by ProductQuantityUpdateService.
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: 2,
+ start_date: '2026-07-01 09:00:00',
+ end_date: null,
+ capacity: null,
+ );
+
+ $expectedOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with(Mockery::on(function ($attrs) {
+ return $attrs[EventOccurrenceDomainObjectAbstract::EVENT_ID] === 2
+ && $attrs[EventOccurrenceDomainObjectAbstract::START_DATE] === '2026-07-01 09:00:00'
+ && $attrs[EventOccurrenceDomainObjectAbstract::END_DATE] === null
+ && $attrs[EventOccurrenceDomainObjectAbstract::STATUS] === EventOccurrenceStatus::ACTIVE->name
+ && str_starts_with($attrs[EventOccurrenceDomainObjectAbstract::SHORT_ID], 'oc_');
+ }))
+ ->andReturn($expectedOccurrence);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertSame($expectedOccurrence, $result);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/DeleteEventOccurrenceHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/DeleteEventOccurrenceHandlerTest.php
new file mode 100644
index 0000000000..315b16170f
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/DeleteEventOccurrenceHandlerTest.php
@@ -0,0 +1,305 @@
+occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->eventRepository = Mockery::mock(EventRepositoryInterface::class);
+ $this->orderItemRepository = Mockery::mock(OrderItemRepositoryInterface::class);
+ $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class);
+ $this->waitlistEntryRepository = Mockery::mock(WaitlistEntryRepositoryInterface::class);
+ $this->databaseManager = Mockery::mock(DatabaseManager::class);
+
+ // Default: waitlist cleanup is a no-op (no entries). Tests that
+ // exercise the cleanup branch override this.
+ $this->waitlistEntryRepository
+ ->shouldReceive('updateWhere')
+ ->byDefault()
+ ->andReturn(0);
+
+ $this->databaseManager->shouldReceive('transaction')
+ ->andReturnUsing(fn ($callback) => $callback());
+
+ $this->handler = new DeleteEventOccurrenceHandler(
+ $this->occurrenceRepository,
+ $this->eventRepository,
+ $this->orderItemRepository,
+ $this->attendeeRepository,
+ $this->waitlistEntryRepository,
+ $this->databaseManager,
+ );
+ }
+
+ public function test_handle_successfully_deletes_occurrence_with_no_orders(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00');
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => $occurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
+ ])
+ ->andReturn($occurrence);
+
+ $this->orderItemRepository
+ ->shouldReceive('countWhere')
+ ->once()
+ ->with(['event_occurrence_id' => $occurrenceId])
+ ->andReturn(0);
+
+ $this->attendeeRepository
+ ->shouldReceive('countWhere')
+ ->once()
+ ->with(['event_occurrence_id' => $occurrenceId])
+ ->andReturn(0);
+
+ $this->occurrenceRepository
+ ->shouldReceive('deleteWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => $occurrenceId,
+ ]);
+
+ // Non-recurring event: excluded_dates step is a no-op.
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getType')->andReturn(EventType::SINGLE->name);
+ $this->eventRepository
+ ->shouldReceive('findByIdLocked')->once()->andReturn($event);
+ $this->eventRepository->shouldNotReceive('updateFromArray');
+
+ $this->handler->handle($eventId, $occurrenceId);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_delete_adds_date_to_recurrence_excluded_dates_for_recurring_event(): void
+ {
+ // Without this, the next regenerate would parse the rule, see the same
+ // candidate date, and recreate the deleted occurrence — silently
+ // undoing the delete.
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00');
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($occurrence);
+ $this->orderItemRepository->shouldReceive('countWhere')->once()->andReturn(0);
+ $this->attendeeRepository->shouldReceive('countWhere')->once()->andReturn(0);
+ $this->occurrenceRepository->shouldReceive('deleteWhere')->once();
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getType')->andReturn(EventType::RECURRING->name);
+ $event->shouldReceive('getTimezone')->andReturn('UTC');
+ $event->shouldReceive('getRecurrenceRule')->andReturn([
+ 'excluded_dates' => ['2026-05-01'],
+ ]);
+
+ $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event);
+ $this->eventRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with($eventId, Mockery::on(static function (array $attrs): bool {
+ $rule = $attrs[EventDomainObjectAbstract::RECURRENCE_RULE] ?? null;
+
+ return is_array($rule)
+ && $rule['excluded_dates'] === ['2026-05-01']
+ && $rule['excluded_occurrences'] === ['2026-06-15 10:00'];
+ }))
+ ->andReturn(Mockery::mock(EventDomainObject::class));
+
+ $this->handler->handle($eventId, $occurrenceId);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_delete_does_not_duplicate_existing_excluded_date(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00');
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($occurrence);
+ $this->orderItemRepository->shouldReceive('countWhere')->once()->andReturn(0);
+ $this->attendeeRepository->shouldReceive('countWhere')->once()->andReturn(0);
+ $this->occurrenceRepository->shouldReceive('deleteWhere')->once();
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getType')->andReturn(EventType::RECURRING->name);
+ $event->shouldReceive('getTimezone')->andReturn('UTC');
+ $event->shouldReceive('getRecurrenceRule')->andReturn([
+ 'excluded_dates' => ['2026-06-15'],
+ 'excluded_occurrences' => ['2026-06-15 10:00'],
+ ]);
+
+ $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event);
+ // No event update — the date is already excluded.
+ $this->eventRepository->shouldNotReceive('updateFromArray');
+
+ $this->handler->handle($eventId, $occurrenceId);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_handle_throws_validation_exception_when_occurrence_has_orders(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => $occurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
+ ])
+ ->andReturn($occurrence);
+
+ $this->orderItemRepository
+ ->shouldReceive('countWhere')
+ ->once()
+ ->with(['event_occurrence_id' => $occurrenceId])
+ ->andReturn(5);
+
+ $this->occurrenceRepository
+ ->shouldNotReceive('deleteWhere');
+
+ $this->expectException(ValidationException::class);
+
+ $this->handler->handle($eventId, $occurrenceId);
+ }
+
+ public function test_handle_throws_exception_when_occurrence_not_found(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 999;
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => $occurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
+ ])
+ ->andReturn(null);
+
+ $this->orderItemRepository
+ ->shouldNotReceive('countWhere');
+
+ $this->occurrenceRepository
+ ->shouldNotReceive('deleteWhere');
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle($eventId, $occurrenceId);
+ }
+
+ public function test_delete_cancels_waiting_and_offered_waitlist_entries_for_occurrence(): void
+ {
+ // The FK is nullOnDelete — without an explicit cancel here, WAITING
+ // entries pointing at this occurrence would be left as orphans (their
+ // event_occurrence_id nulled), which crashes ProcessWaitlistService
+ // on the next CapacityChangedEvent for recurring events.
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00');
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($occurrence);
+ $this->orderItemRepository->shouldReceive('countWhere')->once()->andReturn(0);
+ $this->attendeeRepository->shouldReceive('countWhere')->once()->andReturn(0);
+ $this->occurrenceRepository->shouldReceive('deleteWhere')->once();
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getType')->andReturn(EventType::SINGLE->name);
+ $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event);
+ $this->eventRepository->shouldNotReceive('updateFromArray');
+
+ $this->waitlistEntryRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ Mockery::on(static function (array $attrs): bool {
+ return $attrs === ['status' => WaitlistEntryStatus::CANCELLED->name];
+ }),
+ Mockery::on(static function (array $where) use ($eventId, $occurrenceId): bool {
+ if (($where['event_id'] ?? null) !== $eventId) {
+ return false;
+ }
+ if (($where['event_occurrence_id'] ?? null) !== $occurrenceId) {
+ return false;
+ }
+ foreach ($where as $clause) {
+ if (is_array($clause) && $clause[0] === 'status' && $clause[1] === 'in') {
+ return $clause[2] === [
+ WaitlistEntryStatus::WAITING->name,
+ WaitlistEntryStatus::OFFERED->name,
+ ];
+ }
+ }
+
+ return false;
+ }),
+ )
+ ->andReturn(0);
+
+ $this->handler->handle($eventId, $occurrenceId);
+
+ $this->assertTrue(true);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandlerTest.php
new file mode 100644
index 0000000000..b3926878cc
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandlerTest.php
@@ -0,0 +1,133 @@
+generatorService = Mockery::mock(EventOccurrenceGeneratorService::class);
+ $this->eventRepository = Mockery::mock(EventRepositoryInterface::class);
+ $this->ruleParserService = Mockery::mock(RecurrenceRuleParserService::class);
+ $this->databaseManager = Mockery::mock(DatabaseManager::class);
+
+ $this->databaseManager->shouldReceive('transaction')
+ ->andReturnUsing(fn($callback) => $callback());
+
+ $this->handler = new GenerateOccurrencesFromRuleHandler(
+ $this->generatorService,
+ $this->eventRepository,
+ $this->ruleParserService,
+ $this->databaseManager,
+ );
+ }
+
+ public function testHandleGeneratesOccurrencesAndUpdatesEventType(): void
+ {
+ $rule = ['frequency' => 'weekly', 'range' => ['type' => 'count', 'count' => 10]];
+ $dto = new GenerateOccurrencesDTO(event_id: 1, recurrence_rule: $rule);
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getTimezone')->andReturn('America/New_York');
+ $event->shouldReceive('getId')->andReturn(1);
+ $event->shouldReceive('setRecurrenceRule')->once()->with($rule);
+
+ $this->eventRepository->shouldReceive('findById')->with(1)->once()->andReturn($event);
+
+ $this->ruleParserService->shouldReceive('parse')
+ ->with($rule, 'America/New_York')
+ ->once()
+ ->andReturn(collect(range(1, 10)));
+
+ $this->eventRepository->shouldReceive('updateFromArray')
+ ->once()
+ ->with(1, [
+ EventDomainObjectAbstract::RECURRENCE_RULE => $rule,
+ EventDomainObjectAbstract::TYPE => EventType::RECURRING->name,
+ ]);
+
+ $generatedOccurrences = collect(['occ1', 'occ2']);
+ $this->generatorService->shouldReceive('generate')
+ ->once()
+ ->with($event, $rule)
+ ->andReturn($generatedOccurrences);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertSame($generatedOccurrences, $result);
+ }
+
+ public function testHandleThrowsValidationExceptionWhenTooManyOccurrences(): void
+ {
+ $rule = ['frequency' => 'daily', 'range' => ['type' => 'count', 'count' => 2000]];
+ $dto = new GenerateOccurrencesDTO(event_id: 1, recurrence_rule: $rule);
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getTimezone')->andReturn('UTC');
+
+ $this->eventRepository->shouldReceive('findById')->with(1)->once()->andReturn($event);
+
+ $this->ruleParserService->shouldReceive('parse')
+ ->with($rule, 'UTC')
+ ->once()
+ ->andReturn(collect(range(1, RecurrenceRuleParserService::MAX_OCCURRENCES + 1)));
+
+ $this->generatorService->shouldNotReceive('generate');
+
+ $this->expectException(ValidationException::class);
+
+ $this->handler->handle($dto);
+ }
+
+ public function testHandleUsesUtcWhenEventHasNoTimezone(): void
+ {
+ $rule = ['frequency' => 'weekly'];
+ $dto = new GenerateOccurrencesDTO(event_id: 1, recurrence_rule: $rule);
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getTimezone')->andReturn(null);
+ $event->shouldReceive('getId')->andReturn(1);
+ $event->shouldReceive('setRecurrenceRule')->once();
+
+ $this->eventRepository->shouldReceive('findById')->once()->andReturn($event);
+
+ $this->ruleParserService->shouldReceive('parse')
+ ->with($rule, 'UTC')
+ ->once()
+ ->andReturn(collect(range(1, 5)));
+
+ $this->eventRepository->shouldReceive('updateFromArray')->once();
+ $this->generatorService->shouldReceive('generate')->once()->andReturn(collect());
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandlerTest.php
new file mode 100644
index 0000000000..4da7f1d134
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandlerTest.php
@@ -0,0 +1,73 @@
+occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->handler = new GetEventOccurrenceHandler($this->occurrenceRepository);
+ }
+
+ public function testHandleReturnsOccurrenceWithStats(): void
+ {
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('loadRelation')
+ ->with(EventOccurrenceStatisticDomainObject::class)
+ ->once()
+ ->andReturnSelf();
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => 10,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => 1,
+ ])
+ ->andReturn($occurrence);
+
+ $result = $this->handler->handle(1, 10);
+
+ $this->assertSame($occurrence, $result);
+ }
+
+ public function testHandleThrowsWhenOccurrenceNotFound(): void
+ {
+ $this->occurrenceRepository
+ ->shouldReceive('loadRelation')
+ ->once()
+ ->andReturnSelf();
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn(null);
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle(1, 999);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandlerTest.php
new file mode 100644
index 0000000000..e5e208e1d2
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandlerTest.php
@@ -0,0 +1,53 @@
+occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->handler = new GetEventOccurrencesHandler($this->occurrenceRepository);
+ }
+
+ public function testHandleReturnsPaginatedOccurrencesWithStats(): void
+ {
+ $queryParams = Mockery::mock(QueryParamsDTO::class);
+ $paginator = Mockery::mock(LengthAwarePaginator::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('loadRelation')
+ ->with(EventOccurrenceStatisticDomainObject::class)
+ ->once()
+ ->andReturnSelf();
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByEventId')
+ ->once()
+ ->with(1, $queryParams)
+ ->andReturn($paginator);
+
+ $result = $this->handler->handle(1, $queryParams);
+
+ $this->assertSame($paginator, $result);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandlerTest.php
new file mode 100644
index 0000000000..7f82080cc5
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandlerTest.php
@@ -0,0 +1,68 @@
+visibilityRepository = Mockery::mock(ProductOccurrenceVisibilityRepositoryInterface::class);
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->handler = new GetProductVisibilityHandler($this->visibilityRepository, $this->occurrenceRepository);
+ }
+
+ public function testHandleReturnsVisibilityRecords(): void
+ {
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => 10,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => 1,
+ ])
+ ->andReturn($occurrence);
+
+ $records = collect([Mockery::mock(ProductOccurrenceVisibilityDomainObject::class)]);
+ $this->visibilityRepository->shouldReceive('findWhere')
+ ->once()
+ ->with([ProductOccurrenceVisibilityDomainObjectAbstract::EVENT_OCCURRENCE_ID => 10])
+ ->andReturn($records);
+
+ $result = $this->handler->handle(1, 10);
+
+ $this->assertCount(1, $result);
+ }
+
+ public function testHandleThrowsWhenOccurrenceNotFound(): void
+ {
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn(null);
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle(1, 999);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/DeletePriceOverrideHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/DeletePriceOverrideHandlerTest.php
new file mode 100644
index 0000000000..b3589ae332
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/DeletePriceOverrideHandlerTest.php
@@ -0,0 +1,197 @@
+overrideRepository = Mockery::mock(ProductPriceOccurrenceOverrideRepositoryInterface::class);
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->databaseManager = Mockery::mock(DatabaseManager::class);
+
+ $this->databaseManager->shouldReceive('transaction')
+ ->andReturnUsing(fn($callback) => $callback());
+
+ $this->handler = new DeletePriceOverrideHandler(
+ $this->overrideRepository,
+ $this->occurrenceRepository,
+ $this->databaseManager,
+ );
+ }
+
+ public function testHandleSuccessfullyDeletesOverrideScopedToOccurrence(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+ $overrideId = 5;
+
+ $existingOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $existingOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => $occurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
+ ])
+ ->andReturn($existingOccurrence);
+
+ $this->overrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::ID => $overrideId,
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrenceId,
+ ])
+ ->andReturn($existingOverride);
+
+ $this->overrideRepository
+ ->shouldReceive('deleteWhere')
+ ->once()
+ ->with([
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::ID => $overrideId,
+ ]);
+
+ $this->handler->handle($eventId, $occurrenceId, $overrideId);
+
+ $this->assertTrue(true);
+ }
+
+ public function testHandleThrowsExceptionWhenOccurrenceDoesNotBelongToEvent(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+ $overrideId = 5;
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => $occurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
+ ])
+ ->andReturn(null);
+
+ $this->overrideRepository->shouldNotReceive('findFirstWhere');
+ $this->overrideRepository->shouldNotReceive('deleteWhere');
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle($eventId, $occurrenceId, $overrideId);
+ }
+
+ public function testHandleThrowsExceptionWhenOverrideNotFound(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+ $overrideId = 999;
+
+ $existingOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn($existingOccurrence);
+
+ $this->overrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::ID => $overrideId,
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrenceId,
+ ])
+ ->andReturn(null);
+
+ $this->overrideRepository->shouldNotReceive('deleteWhere');
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle($eventId, $occurrenceId, $overrideId);
+ }
+
+ public function testHandleScopesLookupToOccurrenceId(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 42;
+ $overrideId = 7;
+
+ $existingOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn($existingOccurrence);
+
+ $this->overrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(Mockery::on(function ($arg) use ($occurrenceId, $overrideId) {
+ return $arg[ProductPriceOccurrenceOverrideDomainObjectAbstract::ID] === $overrideId
+ && $arg[ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID] === $occurrenceId;
+ }))
+ ->andReturn(null);
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle($eventId, $occurrenceId, $overrideId);
+ }
+
+ public function testHandleDeletesOnlyTheSpecifiedOverride(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+ $overrideId = 3;
+
+ $existingOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $existingOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn($existingOccurrence);
+
+ $this->overrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn($existingOverride);
+
+ $this->overrideRepository
+ ->shouldReceive('deleteWhere')
+ ->once()
+ ->with([
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::ID => $overrideId,
+ ]);
+
+ $this->handler->handle($eventId, $occurrenceId, $overrideId);
+
+ $this->assertTrue(true);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandlerTest.php
new file mode 100644
index 0000000000..b747358f6a
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandlerTest.php
@@ -0,0 +1,88 @@
+overrideRepository = Mockery::mock(ProductPriceOccurrenceOverrideRepositoryInterface::class);
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->handler = new GetPriceOverridesHandler($this->overrideRepository, $this->occurrenceRepository);
+ }
+
+ private function mockOccurrenceOwnership(): void
+ {
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+ }
+
+ public function testHandleReturnsCollectionOfOverridesForOccurrence(): void
+ {
+ $this->mockOccurrenceOwnership();
+
+ $override1 = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+ $override2 = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+ $expectedCollection = new Collection([$override1, $override2]);
+
+ $this->overrideRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->with([ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => 10])
+ ->andReturn($expectedCollection);
+
+ $result = $this->handler->handle(1, 10);
+
+ $this->assertCount(2, $result);
+ $this->assertSame($expectedCollection, $result);
+ }
+
+ public function testHandleReturnsEmptyCollectionWhenNoneExist(): void
+ {
+ $this->mockOccurrenceOwnership();
+
+ $this->overrideRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection());
+
+ $result = $this->handler->handle(1, 99);
+
+ $this->assertTrue($result->isEmpty());
+ }
+
+ public function testHandleThrowsWhenOccurrenceDoesNotBelongToEvent(): void
+ {
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn(null);
+
+ $this->expectException(\HiEvents\Exceptions\ResourceNotFoundException::class);
+
+ $this->handler->handle(1, 999);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandlerTest.php
new file mode 100644
index 0000000000..d92dca0fb7
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandlerTest.php
@@ -0,0 +1,329 @@
+overrideRepository = Mockery::mock(ProductPriceOccurrenceOverrideRepositoryInterface::class);
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->productPriceRepository = Mockery::mock(ProductPriceRepositoryInterface::class);
+ $this->productRepository = Mockery::mock(ProductRepositoryInterface::class);
+ $this->databaseManager = Mockery::mock(DatabaseManager::class);
+
+ $this->databaseManager->shouldReceive('transaction')
+ ->andReturnUsing(fn($callback) => $callback());
+
+ $this->handler = new UpsertPriceOverrideHandler(
+ $this->overrideRepository,
+ $this->occurrenceRepository,
+ $this->productPriceRepository,
+ $this->productRepository,
+ $this->databaseManager,
+ );
+ }
+
+ private function mockOwnershipChecks(): void
+ {
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $priceMock = Mockery::mock(ProductPriceDomainObject::class);
+ $priceMock->shouldReceive('getProductId')->andReturn(5);
+ $this->productPriceRepository
+ ->shouldReceive('findFirst')
+ ->andReturn($priceMock);
+
+ $this->productRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn(Mockery::mock(ProductDomainObject::class));
+ }
+
+ public function testHandleCreatesNewOverrideWhenNoneExists(): void
+ {
+ $this->mockOwnershipChecks();
+
+ $dto = new UpsertPriceOverrideDTO(
+ event_id: 1,
+ event_occurrence_id: 10,
+ product_price_id: 20,
+ price: 99.99,
+ );
+
+ $expectedOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+
+ $this->overrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => 10,
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::PRODUCT_PRICE_ID => 20,
+ ])
+ ->andReturn(null);
+
+ $this->overrideRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with([
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => 10,
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::PRODUCT_PRICE_ID => 20,
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::PRICE => 99.99,
+ ])
+ ->andReturn($expectedOverride);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertSame($expectedOverride, $result);
+ }
+
+ public function testHandleUpdatesExistingOverride(): void
+ {
+ $this->mockOwnershipChecks();
+
+ $dto = new UpsertPriceOverrideDTO(
+ event_id: 1,
+ event_occurrence_id: 10,
+ product_price_id: 20,
+ price: 149.99,
+ );
+
+ $existingOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+ $existingOverride->shouldReceive('getId')->andReturn(5);
+
+ $updatedOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+
+ $this->overrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => 10,
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::PRODUCT_PRICE_ID => 20,
+ ])
+ ->andReturn($existingOverride);
+
+ $this->overrideRepository
+ ->shouldNotReceive('create');
+
+ $this->overrideRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(5, [
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::PRICE => 149.99,
+ ])
+ ->andReturn($updatedOverride);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertSame($updatedOverride, $result);
+ }
+
+ public function testHandlePassesCorrectEventOccurrenceId(): void
+ {
+ $occurrenceId = 42;
+ $this->mockOwnershipChecks();
+
+ $dto = new UpsertPriceOverrideDTO(
+ event_id: 1,
+ event_occurrence_id: $occurrenceId,
+ product_price_id: 1,
+ price: 50.00,
+ );
+
+ $expectedOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+
+ $this->overrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(Mockery::on(function ($arg) use ($occurrenceId) {
+ return $arg[ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID] === $occurrenceId;
+ }))
+ ->andReturn(null);
+
+ $this->overrideRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with(Mockery::on(function ($arg) use ($occurrenceId) {
+ return $arg[ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID] === $occurrenceId;
+ }))
+ ->andReturn($expectedOverride);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertSame($expectedOverride, $result);
+ }
+
+ public function testHandlePassesCorrectProductPriceId(): void
+ {
+ $priceId = 77;
+ $this->mockOwnershipChecks();
+
+ $dto = new UpsertPriceOverrideDTO(
+ event_id: 1,
+ event_occurrence_id: 1,
+ product_price_id: $priceId,
+ price: 25.00,
+ );
+
+ $expectedOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+
+ $this->overrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(Mockery::on(function ($arg) use ($priceId) {
+ return $arg[ProductPriceOccurrenceOverrideDomainObjectAbstract::PRODUCT_PRICE_ID] === $priceId;
+ }))
+ ->andReturn(null);
+
+ $this->overrideRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with(Mockery::on(function ($arg) use ($priceId) {
+ return $arg[ProductPriceOccurrenceOverrideDomainObjectAbstract::PRODUCT_PRICE_ID] === $priceId;
+ }))
+ ->andReturn($expectedOverride);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertSame($expectedOverride, $result);
+ }
+
+ public function testHandlePassesCorrectPrice(): void
+ {
+ $price = 199.50;
+ $this->mockOwnershipChecks();
+
+ $dto = new UpsertPriceOverrideDTO(
+ event_id: 1,
+ event_occurrence_id: 1,
+ product_price_id: 2,
+ price: $price,
+ );
+
+ $expectedOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+
+ $this->overrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn(null);
+
+ $this->overrideRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with(Mockery::on(function ($arg) use ($price) {
+ return $arg[ProductPriceOccurrenceOverrideDomainObjectAbstract::PRICE] === $price;
+ }))
+ ->andReturn($expectedOverride);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertSame($expectedOverride, $result);
+ }
+
+ public function test_it_throws_when_occurrence_not_found_for_event(): void
+ {
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn(null);
+
+ $dto = new UpsertPriceOverrideDTO(
+ event_id: 1,
+ event_occurrence_id: 99,
+ product_price_id: 20,
+ price: 49.99,
+ );
+
+ $this->handler->handle($dto);
+ }
+
+ public function test_it_throws_when_product_price_not_found(): void
+ {
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $this->productPriceRepository
+ ->shouldReceive('findFirst')
+ ->once()
+ ->andReturn(null);
+
+ $dto = new UpsertPriceOverrideDTO(
+ event_id: 1,
+ event_occurrence_id: 10,
+ product_price_id: 99,
+ price: 49.99,
+ );
+
+ $this->handler->handle($dto);
+ }
+
+ public function test_it_throws_when_product_not_belonging_to_event(): void
+ {
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $priceMock = Mockery::mock(ProductPriceDomainObject::class);
+ $priceMock->shouldReceive('getProductId')->andReturn(5);
+ $this->productPriceRepository
+ ->shouldReceive('findFirst')
+ ->once()
+ ->andReturn($priceMock);
+
+ $this->productRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn(null);
+
+ $dto = new UpsertPriceOverrideDTO(
+ event_id: 1,
+ event_occurrence_id: 10,
+ product_price_id: 20,
+ price: 49.99,
+ );
+
+ $this->handler->handle($dto);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/ReactivateOccurrenceHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/ReactivateOccurrenceHandlerTest.php
new file mode 100644
index 0000000000..f857357ad7
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/ReactivateOccurrenceHandlerTest.php
@@ -0,0 +1,125 @@
+occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->exclusionService = Mockery::mock(RecurrenceRuleExclusionService::class);
+ $this->databaseManager = Mockery::mock(DatabaseManager::class);
+
+ $this->databaseManager->shouldReceive('transaction')
+ ->andReturnUsing(fn ($callback) => $callback());
+
+ $this->handler = new ReactivateOccurrenceHandler(
+ $this->occurrenceRepository,
+ $this->exclusionService,
+ $this->databaseManager,
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ public function test_reactivates_cancelled_occurrence_and_only_touches_status(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getEventId')->andReturn($eventId);
+ $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::CANCELLED->name);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-01 10:00:00');
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn($occurrence);
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with($occurrenceId, [
+ EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::ACTIVE->name,
+ ])
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $this->exclusionService
+ ->shouldReceive('removeExclusion')
+ ->once()
+ ->with($eventId, '2026-06-01 10:00:00');
+
+ $result = $this->handler->handle($eventId, $occurrenceId);
+
+ $this->assertNotNull($result);
+ }
+
+ public function test_rejects_reactivation_of_non_cancelled_occurrence(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getEventId')->andReturn($eventId);
+ $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->andReturn($occurrence);
+
+ $this->occurrenceRepository->shouldNotReceive('updateFromArray');
+ $this->exclusionService->shouldNotReceive('removeExclusion');
+
+ $this->expectException(ValidationException::class);
+
+ $this->handler->handle($eventId, $occurrenceId);
+ }
+
+ public function test_throws_when_occurrence_not_found_for_event(): void
+ {
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->andReturn(null);
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle(eventId: 1, occurrenceId: 99);
+ }
+
+ public function test_throws_when_occurrence_belongs_to_different_event(): void
+ {
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getEventId')->andReturn(2);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->andReturn($occurrence);
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle(eventId: 1, occurrenceId: 10);
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandlerTest.php
new file mode 100644
index 0000000000..b4d8d1f75b
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandlerTest.php
@@ -0,0 +1,465 @@
+occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->databaseManager = Mockery::mock(DatabaseManager::class);
+
+ $this->databaseManager->shouldReceive('transaction')
+ ->andReturnUsing(fn ($callback) => $callback());
+
+ $this->handler = new UpdateEventOccurrenceHandler(
+ $this->occurrenceRepository,
+ $this->databaseManager,
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ /**
+ * Convenience factory for the "existing occurrence" mock with the fields the
+ * override-detection logic reads.
+ */
+ private function existingOccurrence(
+ int $id = 10,
+ string $startDate = '2026-06-01 10:00:00',
+ ?string $endDate = '2026-06-01 18:00:00',
+ ?int $capacity = 100,
+ bool $isOverridden = false,
+ string $status = EventOccurrenceStatus::ACTIVE->name,
+ int $usedCapacity = 0,
+ ): MockInterface {
+ $occ = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occ->shouldReceive('getId')->andReturn($id);
+ $occ->shouldReceive('getStartDate')->andReturn($startDate);
+ $occ->shouldReceive('getEndDate')->andReturn($endDate);
+ $occ->shouldReceive('getCapacity')->andReturn($capacity);
+ $occ->shouldReceive('getIsOverridden')->andReturn($isOverridden);
+ $occ->shouldReceive('getStatus')->andReturn($status);
+ // SOLD_OUT/ACTIVE reconciliation in the handler reads used_capacity to
+ // decide whether the new ceiling has headroom.
+ $occ->shouldReceive('getUsedCapacity')->andReturn($usedCapacity);
+
+ return $occ;
+ }
+
+ public function test_flags_as_overridden_when_start_date_changes(): void
+ {
+ $occurrenceId = 10;
+ $eventId = 1;
+
+ $existing = $this->existingOccurrence(id: $occurrenceId);
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-02 10:00:00', // moved by a day
+ end_date: '2026-06-01 18:00:00',
+ capacity: 100,
+ label: 'Same label',
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with($occurrenceId, Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true))
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_flags_as_overridden_when_end_date_changes(): void
+ {
+ $occurrenceId = 10;
+ $eventId = 1;
+
+ $existing = $this->existingOccurrence(id: $occurrenceId);
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-01 10:00:00',
+ end_date: '2026-06-01 20:00:00', // extended by 2 hours
+ capacity: 100,
+ label: null,
+ );
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with($occurrenceId, Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true))
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_flags_as_overridden_when_capacity_changes(): void
+ {
+ $occurrenceId = 10;
+ $eventId = 1;
+
+ $existing = $this->existingOccurrence(id: $occurrenceId);
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-01 10:00:00',
+ end_date: '2026-06-01 18:00:00',
+ capacity: 200, // changed from 100
+ label: null,
+ );
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with($occurrenceId, Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true))
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_does_not_flag_as_overridden_for_label_only_change(): void
+ {
+ // A label-only edit shouldn't pin the occurrence against rule regenerates.
+ $occurrenceId = 10;
+ $eventId = 1;
+
+ $existing = $this->existingOccurrence(id: $occurrenceId, isOverridden: false);
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-01 10:00:00',
+ end_date: '2026-06-01 18:00:00',
+ capacity: 100,
+ label: 'Brand new label',
+ );
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with($occurrenceId, Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === false))
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_preserves_override_flag_when_already_overridden(): void
+ {
+ // Once overridden, stays overridden regardless of which fields change now —
+ // we don't un-override just because the user happened to save with
+ // rule-aligned values.
+ $occurrenceId = 10;
+ $eventId = 1;
+
+ $existing = $this->existingOccurrence(id: $occurrenceId, isOverridden: true);
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-01 10:00:00',
+ end_date: '2026-06-01 18:00:00',
+ capacity: 100,
+ label: 'Label change only',
+ );
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with($occurrenceId, Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true))
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_does_not_flag_as_overridden_when_dates_are_same_instant_different_format(): void
+ {
+ // DateHelper::convertToUTC (used by UpdateEventOccurrenceAction) returns
+ // "Mon Jun 15 2026 10:00:00 GMT+0000" style while DB-hydrated getStartDate()
+ // returns SQL format. Plain strict string equality would mark these as
+ // different even though they represent the same instant — regression test.
+ $occurrenceId = 10;
+ $eventId = 1;
+
+ $existing = $this->existingOccurrence(
+ id: $occurrenceId,
+ startDate: '2026-06-01 10:00:00',
+ endDate: '2026-06-01 18:00:00',
+ isOverridden: false,
+ );
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: 'Mon Jun 01 2026 10:00:00 GMT+0000',
+ end_date: 'Mon Jun 01 2026 18:00:00 GMT+0000',
+ capacity: 100,
+ label: 'New label',
+ );
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with($occurrenceId, Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === false))
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_handle_does_not_write_status_when_capacity_unchanged_and_status_already_correct(): void
+ {
+ // CANCELLED is owned by the dedicated cancel/reactivate handlers.
+ // For capacity-derived states (ACTIVE / SOLD_OUT) the handler only
+ // writes STATUS when the new ceiling actually changes which side of
+ // capacity the occurrence sits on. A label-only edit on an ACTIVE
+ // occurrence with no capacity change must leave STATUS untouched.
+ $occurrenceId = 10;
+ $eventId = 1;
+
+ $existing = $this->existingOccurrence(
+ id: $occurrenceId,
+ capacity: 100,
+ status: EventOccurrenceStatus::ACTIVE->name,
+ usedCapacity: 10,
+ );
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-01 10:00:00',
+ end_date: '2026-06-01 18:00:00',
+ capacity: 100,
+ label: 'New label',
+ );
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(
+ $occurrenceId,
+ Mockery::on(fn (array $attrs) => ! array_key_exists(EventOccurrenceDomainObjectAbstract::STATUS, $attrs)),
+ )
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_handle_reactivates_sold_out_occurrence_when_capacity_increases_above_used(): void
+ {
+ // ProductQuantityUpdateService::increaseOccurrenceUsedCapacity flips
+ // ACTIVE → SOLD_OUT when usage crosses the ceiling. The reverse path
+ // only runs from decreaseOccurrenceUsedCapacity, so a capacity edit
+ // that raises the ceiling above current usage is the only place the
+ // generic update handler can re-open a sold-out date.
+ $occurrenceId = 10;
+ $eventId = 1;
+
+ $existing = $this->existingOccurrence(
+ id: $occurrenceId,
+ capacity: 50,
+ status: EventOccurrenceStatus::SOLD_OUT->name,
+ usedCapacity: 50,
+ );
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-01 10:00:00',
+ end_date: '2026-06-01 18:00:00',
+ capacity: 100, // raised — sold-out should clear
+ );
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(
+ $occurrenceId,
+ Mockery::on(fn (array $attrs) => ($attrs[EventOccurrenceDomainObjectAbstract::STATUS] ?? null) === EventOccurrenceStatus::ACTIVE->name
+ && $attrs[EventOccurrenceDomainObjectAbstract::CAPACITY] === 100),
+ )
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_handle_reactivates_sold_out_occurrence_when_capacity_cleared_to_unlimited(): void
+ {
+ // Unlimited capacity (null) can never be sold out.
+ $occurrenceId = 10;
+ $eventId = 1;
+
+ $existing = $this->existingOccurrence(
+ id: $occurrenceId,
+ capacity: 50,
+ status: EventOccurrenceStatus::SOLD_OUT->name,
+ usedCapacity: 50,
+ );
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-01 10:00:00',
+ end_date: '2026-06-01 18:00:00',
+ capacity: null,
+ );
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(
+ $occurrenceId,
+ Mockery::on(fn (array $attrs) => ($attrs[EventOccurrenceDomainObjectAbstract::STATUS] ?? null) === EventOccurrenceStatus::ACTIVE->name
+ && $attrs[EventOccurrenceDomainObjectAbstract::CAPACITY] === null),
+ )
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_handle_marks_active_occurrence_sold_out_when_capacity_drops_below_used(): void
+ {
+ $occurrenceId = 10;
+ $eventId = 1;
+
+ $existing = $this->existingOccurrence(
+ id: $occurrenceId,
+ capacity: 100,
+ status: EventOccurrenceStatus::ACTIVE->name,
+ usedCapacity: 80,
+ );
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-01 10:00:00',
+ end_date: '2026-06-01 18:00:00',
+ capacity: 50, // below current usage
+ );
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(
+ $occurrenceId,
+ Mockery::on(fn (array $attrs) => ($attrs[EventOccurrenceDomainObjectAbstract::STATUS] ?? null) === EventOccurrenceStatus::SOLD_OUT->name
+ && $attrs[EventOccurrenceDomainObjectAbstract::CAPACITY] === 50),
+ )
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_handle_does_not_write_status_for_cancelled_occurrence_even_when_capacity_changes(): void
+ {
+ // CANCELLED is the load-bearing exception — its lifecycle is owned by
+ // the cancel/reactivate handlers. A capacity edit on a cancelled date
+ // (rare, but possible via direct API call) must not silently flip it
+ // back to ACTIVE.
+ $occurrenceId = 10;
+ $eventId = 1;
+
+ $existing = $this->existingOccurrence(
+ id: $occurrenceId,
+ capacity: 100,
+ status: EventOccurrenceStatus::CANCELLED->name,
+ usedCapacity: 0,
+ );
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-01 10:00:00',
+ end_date: '2026-06-01 18:00:00',
+ capacity: 200,
+ );
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(
+ $occurrenceId,
+ Mockery::on(fn (array $attrs) => ! array_key_exists(EventOccurrenceDomainObjectAbstract::STATUS, $attrs)),
+ )
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_handle_throws_exception_when_occurrence_not_found(): void
+ {
+ $occurrenceId = 999;
+ $eventId = 1;
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-01 10:00:00',
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => $occurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
+ ])
+ ->andReturn(null);
+
+ $this->occurrenceRepository->shouldNotReceive('updateFromArray');
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandlerTest.php
new file mode 100644
index 0000000000..4112178c89
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandlerTest.php
@@ -0,0 +1,169 @@
+visibilityRepository = Mockery::mock(ProductOccurrenceVisibilityRepositoryInterface::class);
+ $this->productRepository = Mockery::mock(ProductRepositoryInterface::class);
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->databaseManager = Mockery::mock(DatabaseManager::class);
+
+ $this->databaseManager->shouldReceive('transaction')
+ ->andReturnUsing(fn($callback) => $callback());
+
+ $this->handler = new UpdateProductVisibilityHandler(
+ $this->visibilityRepository,
+ $this->productRepository,
+ $this->occurrenceRepository,
+ $this->databaseManager,
+ );
+ }
+
+ private function makeProductCollection(array $ids): Collection
+ {
+ return collect(array_map(function ($id) {
+ return new class($id) {
+ public function __construct(public readonly int $id) {}
+ public function offsetGet($key) { return $this->$key; }
+ public function offsetExists($key): bool { return isset($this->$key); }
+ };
+ }, $ids));
+ }
+
+ public function testHandleCreatesVisibilityRecordsForSelectedProducts(): void
+ {
+ $dto = new UpdateProductVisibilityDTO(
+ event_id: 1,
+ event_occurrence_id: 10,
+ product_ids: [5],
+ );
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => 10,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => 1,
+ ])
+ ->andReturn($occurrence);
+
+ $this->visibilityRepository->shouldReceive('deleteWhere')->once();
+
+ $this->productRepository->shouldReceive('findWhere')
+ ->once()
+ ->with([ProductDomainObjectAbstract::EVENT_ID => 1])
+ ->andReturn($this->makeProductCollection([5, 10]));
+
+ $this->visibilityRepository->shouldReceive('create')
+ ->once()
+ ->with([
+ ProductOccurrenceVisibilityDomainObjectAbstract::EVENT_OCCURRENCE_ID => 10,
+ ProductOccurrenceVisibilityDomainObjectAbstract::PRODUCT_ID => 5,
+ ]);
+
+ $visibilityRecords = collect([Mockery::mock(ProductOccurrenceVisibilityDomainObject::class)]);
+ $this->visibilityRepository->shouldReceive('findWhere')
+ ->once()
+ ->with([ProductOccurrenceVisibilityDomainObjectAbstract::EVENT_OCCURRENCE_ID => 10])
+ ->andReturn($visibilityRecords);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertCount(1, $result);
+ }
+
+ public function testHandleReturnsEmptyWhenAllProductsSelected(): void
+ {
+ $dto = new UpdateProductVisibilityDTO(
+ event_id: 1,
+ event_occurrence_id: 10,
+ product_ids: [5, 10],
+ );
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($occurrence);
+ $this->visibilityRepository->shouldReceive('deleteWhere')->once();
+
+ $this->productRepository->shouldReceive('findWhere')->once()->andReturn($this->makeProductCollection([5, 10]));
+
+ $this->visibilityRepository->shouldNotReceive('create');
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertInstanceOf(Collection::class, $result);
+ $this->assertEmpty($result);
+ }
+
+ public function testHandleThrowsWhenOccurrenceNotFound(): void
+ {
+ $dto = new UpdateProductVisibilityDTO(
+ event_id: 1,
+ event_occurrence_id: 999,
+ product_ids: [5],
+ );
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn(null);
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle($dto);
+ }
+
+ public function testHandleThrowsWhenProductIdDoesNotBelongToEvent(): void
+ {
+ $dto = new UpdateProductVisibilityDTO(
+ event_id: 1,
+ event_occurrence_id: 10,
+ product_ids: [5, 999],
+ );
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($occurrence);
+ $this->visibilityRepository->shouldReceive('deleteWhere')->once();
+
+ $this->productRepository->shouldReceive('findWhere')->once()->andReturn($this->makeProductCollection([5]));
+
+ $this->visibilityRepository->shouldNotReceive('create');
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle($dto);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandlerTest.php
index 5dfa38f978..7324b324d8 100644
--- a/backend/tests/Unit/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandlerTest.php
@@ -2,11 +2,9 @@
namespace Tests\Unit\Services\Application\Handlers\EventSettings;
-use Brick\Money\Currency;
-use HiEvents\DomainObjects\AccountConfigurationDomainObject;
-use HiEvents\DomainObjects\AccountDomainObject;
use HiEvents\DomainObjects\EventDomainObject;
-use HiEvents\Repository\Interfaces\AccountRepositoryInterface;
+use HiEvents\DomainObjects\OrganizerConfigurationDomainObject;
+use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Services\Application\Handlers\EventSettings\DTO\GetPlatformFeePreviewDTO;
use HiEvents\Services\Application\Handlers\EventSettings\GetPlatformFeePreviewHandler;
@@ -20,7 +18,6 @@ class GetPlatformFeePreviewHandlerTest extends TestCase
{
use MockeryPHPUnitIntegration;
- private AccountRepositoryInterface $accountRepository;
private EventRepositoryInterface $eventRepository;
private CurrencyConversionClientInterface $currencyConversionClient;
private GetPlatformFeePreviewHandler $handler;
@@ -29,128 +26,89 @@ protected function setUp(): void
{
parent::setUp();
- $this->accountRepository = Mockery::mock(AccountRepositoryInterface::class);
$this->eventRepository = Mockery::mock(EventRepositoryInterface::class);
$this->currencyConversionClient = Mockery::mock(CurrencyConversionClientInterface::class);
$this->handler = new GetPlatformFeePreviewHandler(
- $this->accountRepository,
$this->eventRepository,
- $this->currencyConversionClient
+ $this->currencyConversionClient,
);
}
- public function testPreviewWithSameCurrency(): void
+ private function mockEventWithConfiguration(string $eventCurrency, ?OrganizerConfigurationDomainObject $configuration): EventDomainObject
{
- $eventId = 1;
- $price = 100.0;
+ $organizer = Mockery::mock(OrganizerDomainObject::class);
+ $organizer->shouldReceive('getOrganizerConfiguration')->andReturn($configuration);
$event = Mockery::mock(EventDomainObject::class);
- $event->shouldReceive('getCurrency')->andReturn('USD');
+ $event->shouldReceive('getCurrency')->andReturn($eventCurrency);
+ $event->shouldReceive('getOrganizer')->andReturn($organizer);
+
+ return $event;
+ }
- $configuration = Mockery::mock(AccountConfigurationDomainObject::class);
+ public function testPreviewWithSameCurrency(): void
+ {
+ $configuration = Mockery::mock(OrganizerConfigurationDomainObject::class);
$configuration->shouldReceive('getApplicationFeeCurrency')->andReturn('USD');
$configuration->shouldReceive('getFixedApplicationFee')->andReturn(1.0);
$configuration->shouldReceive('getPercentageApplicationFee')->andReturn(10.0);
- $account = Mockery::mock(AccountDomainObject::class);
- $account->shouldReceive('getConfiguration')->andReturn($configuration);
-
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->eventRepository->shouldReceive('findById')
- ->with($eventId)
- ->andReturn($event);
+ ->with(1)
+ ->andReturn($this->mockEventWithConfiguration('USD', $configuration));
- $this->accountRepository->shouldReceive('loadRelation')
- ->andReturnSelf();
- $this->accountRepository->shouldReceive('findByEventId')
- ->with($eventId)
- ->andReturn($account);
-
- $dto = new GetPlatformFeePreviewDTO(eventId: $eventId, price: $price);
- $result = $this->handler->handle($dto);
+ $result = $this->handler->handle(new GetPlatformFeePreviewDTO(eventId: 1, price: 100.0));
$this->assertEquals('USD', $result->eventCurrency);
$this->assertEquals('USD', $result->feeCurrency);
$this->assertEquals(1.0, $result->fixedFeeOriginal);
$this->assertEquals(1.0, $result->fixedFeeConverted);
$this->assertEquals(10.0, $result->percentageFee);
- $this->assertEquals(100.0, $result->samplePrice);
- // Gross-up: (1 + 100*0.1) / (1 - 0.1) = 11 / 0.9 = 12.22
$this->assertEquals(12.22, $result->platformFee);
$this->assertEquals(112.22, $result->total);
}
public function testPreviewWithCurrencyConversion(): void
{
- $eventId = 1;
- $price = 100.0;
-
- $event = Mockery::mock(EventDomainObject::class);
- $event->shouldReceive('getCurrency')->andReturn('EUR');
-
- $configuration = Mockery::mock(AccountConfigurationDomainObject::class);
+ $configuration = Mockery::mock(OrganizerConfigurationDomainObject::class);
$configuration->shouldReceive('getApplicationFeeCurrency')->andReturn('GBP');
$configuration->shouldReceive('getFixedApplicationFee')->andReturn(1.0);
$configuration->shouldReceive('getPercentageApplicationFee')->andReturn(10.0);
- $account = Mockery::mock(AccountDomainObject::class);
- $account->shouldReceive('getConfiguration')->andReturn($configuration);
-
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->eventRepository->shouldReceive('findById')
- ->with($eventId)
- ->andReturn($event);
+ ->with(1)
+ ->andReturn($this->mockEventWithConfiguration('EUR', $configuration));
- $this->accountRepository->shouldReceive('loadRelation')
- ->andReturnSelf();
- $this->accountRepository->shouldReceive('findByEventId')
- ->with($eventId)
- ->andReturn($account);
-
- // Mock GBP to EUR conversion: £1 = €1.15
$this->currencyConversionClient->shouldReceive('convert')
->with(
Mockery::on(fn($c) => $c->getCurrencyCode() === 'GBP'),
Mockery::on(fn($c) => $c->getCurrencyCode() === 'EUR'),
- 1.0
+ 1.0,
)
->andReturn(MoneyValue::fromFloat(1.15, 'EUR'));
- $dto = new GetPlatformFeePreviewDTO(eventId: $eventId, price: $price);
- $result = $this->handler->handle($dto);
+ $result = $this->handler->handle(new GetPlatformFeePreviewDTO(eventId: 1, price: 100.0));
$this->assertEquals('EUR', $result->eventCurrency);
$this->assertEquals('GBP', $result->feeCurrency);
$this->assertEquals(1.0, $result->fixedFeeOriginal);
$this->assertEquals(1.15, $result->fixedFeeConverted);
$this->assertEquals(10.0, $result->percentageFee);
- // Gross-up: (1.15 + 100*0.1) / (1 - 0.1) = 11.15 / 0.9 = 12.39
$this->assertEquals(12.39, $result->platformFee);
$this->assertEquals(112.39, $result->total);
}
public function testPreviewWithNoConfiguration(): void
{
- $eventId = 1;
- $price = 100.0;
-
- $event = Mockery::mock(EventDomainObject::class);
- $event->shouldReceive('getCurrency')->andReturn('USD');
-
- $account = Mockery::mock(AccountDomainObject::class);
- $account->shouldReceive('getConfiguration')->andReturn(null);
-
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->eventRepository->shouldReceive('findById')
- ->with($eventId)
- ->andReturn($event);
-
- $this->accountRepository->shouldReceive('loadRelation')
- ->andReturnSelf();
- $this->accountRepository->shouldReceive('findByEventId')
- ->with($eventId)
- ->andReturn($account);
+ ->with(1)
+ ->andReturn($this->mockEventWithConfiguration('USD', null));
- $dto = new GetPlatformFeePreviewDTO(eventId: $eventId, price: $price);
- $result = $this->handler->handle($dto);
+ $result = $this->handler->handle(new GetPlatformFeePreviewDTO(eventId: 1, price: 100.0));
$this->assertEquals('USD', $result->eventCurrency);
$this->assertNull($result->feeCurrency);
@@ -161,34 +119,18 @@ public function testPreviewWithNoConfiguration(): void
public function testPreviewWithZeroPercentageFee(): void
{
- $eventId = 1;
- $price = 100.0;
-
- $event = Mockery::mock(EventDomainObject::class);
- $event->shouldReceive('getCurrency')->andReturn('USD');
-
- $configuration = Mockery::mock(AccountConfigurationDomainObject::class);
+ $configuration = Mockery::mock(OrganizerConfigurationDomainObject::class);
$configuration->shouldReceive('getApplicationFeeCurrency')->andReturn('USD');
$configuration->shouldReceive('getFixedApplicationFee')->andReturn(0.50);
$configuration->shouldReceive('getPercentageApplicationFee')->andReturn(0.0);
- $account = Mockery::mock(AccountDomainObject::class);
- $account->shouldReceive('getConfiguration')->andReturn($configuration);
-
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->eventRepository->shouldReceive('findById')
- ->with($eventId)
- ->andReturn($event);
-
- $this->accountRepository->shouldReceive('loadRelation')
- ->andReturnSelf();
- $this->accountRepository->shouldReceive('findByEventId')
- ->with($eventId)
- ->andReturn($account);
+ ->with(1)
+ ->andReturn($this->mockEventWithConfiguration('USD', $configuration));
- $dto = new GetPlatformFeePreviewDTO(eventId: $eventId, price: $price);
- $result = $this->handler->handle($dto);
+ $result = $this->handler->handle(new GetPlatformFeePreviewDTO(eventId: 1, price: 100.0));
- // With 0% percentage, just the fixed fee
$this->assertEquals(0.50, $result->platformFee);
$this->assertEquals(100.50, $result->total);
}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerTest.php
index 4f9cccdedf..0270803692 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerTest.php
@@ -30,13 +30,21 @@
class SendMessageHandlerTest extends TestCase
{
private OrderRepositoryInterface $orderRepository;
+
private AttendeeRepositoryInterface $attendeeRepository;
+
private ProductRepositoryInterface $productRepository;
+
private MessageRepositoryInterface $messageRepository;
+
private AccountRepositoryInterface $accountRepository;
+
private HtmlPurifierService $purifier;
+
private Repository $config;
+
private MessagingEligibilityService $eligibilityService;
+
private EventRepositoryInterface $eventRepository;
private SendMessageHandler $handler;
@@ -68,7 +76,7 @@ protected function setUp(): void
);
}
- public function testThrowsIfAccountNotVerified(): void
+ public function test_throws_if_account_not_verified(): void
{
$dto = new SendMessageDTO(
account_id: 1,
@@ -95,7 +103,7 @@ public function testThrowsIfAccountNotVerified(): void
$this->handler->handle($dto);
}
- public function testThrowsIfSaasModeEnabledAndNotManuallyVerified(): void
+ public function test_throws_if_saas_mode_enabled_and_not_manually_verified(): void
{
$dto = new SendMessageDTO(
account_id: 1,
@@ -125,7 +133,7 @@ public function testThrowsIfSaasModeEnabledAndNotManuallyVerified(): void
$this->handler->handle($dto);
}
- public function testHandleCreatesMessageAndDispatchesJob(): void
+ public function test_handle_creates_message_and_dispatches_job(): void
{
$dto = new SendMessageDTO(
account_id: 1,
@@ -159,13 +167,13 @@ public function testHandleCreatesMessageAndDispatchesJob(): void
$this->purifier->shouldReceive('purify')->with('Test
')->andReturn('Test
');
- $attendee = new AttendeeDomainObject();
+ $attendee = new AttendeeDomainObject;
$attendee->setId(10);
- $product = new ProductDomainObject();
+ $product = new ProductDomainObject;
$product->setId(20);
- $order = new OrderDomainObject();
+ $order = new OrderDomainObject;
$order->setId(5);
$this->attendeeRepository->shouldReceive('findWhereIn')->andReturn(collect([$attendee]));
@@ -189,4 +197,75 @@ public function testHandleCreatesMessageAndDispatchesJob(): void
Bus::assertDispatched(SendMessagesJob::class);
}
+
+ public function test_handle_estimates_recipients_for_multi_occurrence_targeting(): void
+ {
+ // event_occurrence_ids (array) should produce an IN-style query against
+ // attendees, and the dispatched job DTO must carry the array forward.
+ $dto = new SendMessageDTO(
+ account_id: 1,
+ event_id: 101,
+ subject: 'Hi',
+ message: 'Body
',
+ type: MessageTypeEnum::ALL_ATTENDEES,
+ is_test: false,
+ send_copy_to_current_user: false,
+ sent_by_user_id: 99,
+ event_occurrence_ids: [201, 202, 203],
+ );
+
+ $event = m::mock(EventDomainObject::class);
+ $event->shouldReceive('getTimezone')->andReturn('UTC');
+ $this->eventRepository->shouldReceive('findById')->with(101)->andReturn($event);
+
+ $account = m::mock(AccountDomainObject::class);
+ $account->shouldReceive('getAccountVerifiedAt')->andReturn(Carbon::now());
+ $account->shouldReceive('getIsManuallyVerified')->andReturn(true);
+ $this->accountRepository->shouldReceive('findById')->with(1)->andReturn($account);
+
+ $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturn(false);
+ $this->eligibilityService->shouldReceive('checkTierLimits')->andReturn(null);
+ $this->eligibilityService->shouldReceive('checkEligibility')->andReturn(null);
+ $this->purifier->shouldReceive('purify')->andReturn('Body
');
+
+ // Assert the estimate path uses whereIn against the array rather than
+ // an equality check on a single id.
+ $this->attendeeRepository
+ ->shouldReceive('countWhere')
+ ->once()
+ ->with(m::on(fn (array $where) => isset($where[0])
+ && $where[0][0] === 'event_occurrence_id'
+ && $where[0][1] === 'in'
+ && $where[0][2] === [201, 202, 203]))
+ ->andReturn(42);
+
+ // Stub the rest of the handler path.
+ $this->attendeeRepository->shouldReceive('findWhereIn')->andReturn(collect());
+ $this->productRepository->shouldReceive('findWhereIn')->andReturn(collect());
+ $this->orderRepository->shouldReceive('findFirstWhere')->andReturn(null);
+
+ $message = m::mock(MessageDomainObject::class);
+ $message->shouldReceive('getId')->andReturn(1);
+ $message->shouldReceive('getOrderId')->andReturn(null);
+ $message->shouldReceive('getAttendeeIds')->andReturn([]);
+ $message->shouldReceive('getProductIds')->andReturn([]);
+ $this->messageRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with(m::on(function (array $attrs) {
+ // Array is not stored in the dedicated event_occurrence_id column —
+ // it's persisted in send_data for audit + job replay.
+ return $attrs['event_occurrence_id'] === null
+ && ($attrs['send_data']['event_occurrence_ids'] ?? null) === [201, 202, 203];
+ }))
+ ->andReturn($message);
+
+ Bus::fake();
+ $this->handler->handle($dto);
+
+ Bus::assertDispatched(SendMessagesJob::class, function (SendMessagesJob $job) {
+ return $job->messageData->event_occurrence_ids === [201, 202, 203]
+ && $job->messageData->event_occurrence_id === null;
+ });
+ }
}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php
index 4d820ff70c..19524202d7 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php
@@ -6,13 +6,16 @@
use Exception;
use HiEvents\DomainObjects\AttendeeDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
+use HiEvents\DomainObjects\Status\EventOccurrenceStatus;
use HiEvents\DomainObjects\Status\OrderStatus;
use HiEvents\Exceptions\ResourceConflictException;
use HiEvents\Repository\Interfaces\AffiliateRepositoryInterface;
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface;
@@ -50,6 +53,7 @@ class CompleteOrderHandlerTest extends TestCase
private AffiliateRepositoryInterface|MockInterface $affiliateRepository;
private EventSettingsRepositoryInterface $eventSettingsRepository;
private CheckoutSessionManagementService|MockInterface $sessionManagementService;
+ private EventOccurrenceRepositoryInterface|MockInterface $occurrenceRepository;
protected function setUp(): void
{
@@ -69,6 +73,15 @@ protected function setUp(): void
$this->eventSettingsRepository = Mockery::mock(EventSettingsRepositoryInterface::class);
$this->sessionManagementService = Mockery::mock(CheckoutSessionManagementService::class);
$this->sessionManagementService->shouldReceive('verifySession')->andReturn(true)->byDefault();
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->occurrenceRepository->shouldReceive('findWhereIn')->andReturn(
+ collect([
+ (new EventOccurrenceDomainObject())
+ ->setId(1)
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name)
+ ->setStartDate(Carbon::now()->addDay()->toDateTimeString()),
+ ])
+ )->byDefault();
$this->completeOrderHandler = new CompleteOrderHandler(
$this->orderRepository,
@@ -80,6 +93,7 @@ protected function setUp(): void
$this->domainEventDispatcherService,
$this->eventSettingsRepository,
$this->sessionManagementService,
+ $this->occurrenceRepository,
);
}
@@ -274,6 +288,55 @@ public function testExceptionIsThrowWhenAttendeeCountDoesNotMatchOrderItemsCount
$this->completeOrderHandler->handle($orderShortId, $orderData);
}
+ public function testHandleThrowsResourceConflictExceptionWhenOccurrenceIsCancelled(): void
+ {
+ $this->expectException(ResourceConflictException::class);
+ $this->expectExceptionMessage('This event date has been cancelled');
+
+ $orderShortId = 'ABC123';
+ $orderData = $this->createMockCompleteOrderDTO();
+ $order = $this->createMockOrder();
+
+ $this->eventSettingsRepository->shouldReceive('findFirstWhere')->andReturn($this->createMockEventSetting());
+ $this->orderRepository->shouldReceive('findByShortId')->with($orderShortId)->andReturn($order);
+ $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf();
+
+ $this->occurrenceRepository->shouldReceive('findWhereIn')->andReturn(
+ collect([(new EventOccurrenceDomainObject())->setId(1)->setStatus(EventOccurrenceStatus::CANCELLED->name)])
+ );
+
+ $this->completeOrderHandler->handle($orderShortId, $orderData);
+ }
+
+ public function testHandleThrowsResourceConflictExceptionWhenOccurrenceHasEnded(): void
+ {
+ // Reservation could have been created before the occurrence ended
+ // (especially with long reservation windows). Re-checking on completion
+ // prevents a reserved order from aging into a valid purchase for a
+ // session that has since passed.
+ $this->expectException(ResourceConflictException::class);
+ $this->expectExceptionMessage('This event date has already ended');
+
+ $orderShortId = 'ABC123';
+ $orderData = $this->createMockCompleteOrderDTO();
+ $order = $this->createMockOrder();
+
+ $this->eventSettingsRepository->shouldReceive('findFirstWhere')->andReturn($this->createMockEventSetting());
+ $this->orderRepository->shouldReceive('findByShortId')->with($orderShortId)->andReturn($order);
+ $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf();
+
+ $this->occurrenceRepository->shouldReceive('findWhereIn')->andReturn(
+ collect([
+ (new EventOccurrenceDomainObject())
+ ->setId(1)
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name)
+ ->setStartDate(Carbon::now()->subDay()->toDateTimeString()),
+ ])
+ );
+
+ $this->completeOrderHandler->handle($orderShortId, $orderData);
+ }
+
private function createMockCompleteOrderDTO(): CompleteOrderDTO
{
$orderDTO = new CompleteOrderOrderDTO(
@@ -321,7 +384,8 @@ private function createMockOrderItem(): OrderItemDomainObject|MockInterface
->setQuantity(1)
->setPrice(10)
->setTotalGross(10)
- ->setProductPriceId(1);
+ ->setProductPriceId(1)
+ ->setEventOccurrenceId(1);
}
private function createMockProductPrice(): ProductPriceDomainObject|MockInterface
diff --git a/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerTest.php
index 21a06aeb09..a9b5da5d9d 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerTest.php
@@ -93,7 +93,7 @@ public function testThrowsWhenProductQuantityExceedsAvailability(): void
$this->orderManagementService->shouldReceive('deleteExistingOrders');
$this->availabilityService->shouldReceive('getAvailableProductQuantities')
- ->with($eventId, true)
+ ->with($eventId, true, Mockery::any())
->andReturn(new AvailableProductQuantitiesResponseDTO(
productQuantities: collect([
AvailableProductQuantitiesDTO::fromArray([
@@ -149,6 +149,7 @@ private function createOrderDTO(int $productId = 10, int $priceId = 100, int $qu
'products' => collect([
ProductOrderDetailsDTO::fromArray([
'product_id' => $productId,
+ 'event_occurrence_id' => 1,
'quantities' => collect([
OrderProductPriceDTO::fromArray([
'price_id' => $priceId,
@@ -186,7 +187,7 @@ private function setupSuccessfulOrderCreation(
$this->orderManagementService->shouldReceive('deleteExistingOrders');
$this->availabilityService->shouldReceive('getAvailableProductQuantities')
- ->with($eventId, true)
+ ->with($eventId, true, Mockery::any())
->andReturn(new AvailableProductQuantitiesResponseDTO(
productQuantities: collect([
AvailableProductQuantitiesDTO::fromArray([
diff --git a/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandlerTest.php
new file mode 100644
index 0000000000..0c8b9ee8f3
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandlerTest.php
@@ -0,0 +1,189 @@
+organizerRepository = m::mock(OrganizerRepositoryInterface::class);
+ $this->organizerStripePlatformRepository = m::mock(OrganizerStripePlatformRepositoryInterface::class);
+ $this->stripeAccountSyncService = m::mock(StripeAccountSyncService::class);
+ $this->stripeAccountSyncService->shouldReceive('seedVatSettingForOrganizerIfMissing')->byDefault();
+ $this->databaseManager = m::mock(DatabaseManager::class);
+ $this->config = m::mock(Repository::class);
+ }
+
+ protected function tearDown(): void
+ {
+ m::close();
+ parent::tearDown();
+ }
+
+ public function testCopiesConnectionWhenSaasModeEnabledAndSourceComplete(): void
+ {
+ $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturnTrue();
+ $this->databaseManager
+ ->shouldReceive('transaction')
+ ->once()
+ ->andReturnUsing(fn(Closure $closure) => $closure());
+
+ $sourcePlatform = (new OrganizerStripePlatformDomainObject())
+ ->setId(11)
+ ->setOrganizerId(2)
+ ->setStripeAccountId('acct_source')
+ ->setStripeConnectAccountType('standard')
+ ->setStripeConnectPlatform('ca')
+ ->setStripeSetupCompletedAt('2026-01-01 00:00:00')
+ ->setStripeAccountDetails(['country' => 'CA']);
+
+ $source = (new OrganizerDomainObject())
+ ->setId(2)
+ ->setAccountId(99)
+ ->setName('Source');
+ $source->setOrganizerStripePlatforms(collect([$sourcePlatform]));
+
+ $target = (new OrganizerDomainObject())
+ ->setId(1)
+ ->setAccountId(99)
+ ->setName('Target');
+ $target->setOrganizerStripePlatforms(collect());
+
+ $this->organizerRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->organizerRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['id' => 1, 'account_id' => 99])
+ ->andReturn($target);
+ $this->organizerRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['id' => 2, 'account_id' => 99])
+ ->andReturn($source);
+
+ $this->organizerStripePlatformRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with(m::on(function (array $attrs) {
+ return $attrs[OrganizerStripePlatformDomainObjectAbstract::ORGANIZER_ID] === 1
+ && $attrs[OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID] === 'acct_source'
+ && $attrs[OrganizerStripePlatformDomainObjectAbstract::STRIPE_CONNECT_PLATFORM] === 'ca';
+ }))
+ ->andReturn(m::mock(OrganizerStripePlatformDomainObject::class));
+
+ $handler = $this->makeHandler();
+
+ $response = $handler->handle(new CopyStripeConnectAccountDTO(
+ targetOrganizerId: 1,
+ sourceOrganizerId: 2,
+ accountId: 99,
+ ));
+
+ $this->assertSame('acct_source', $response->stripeAccountId);
+ $this->assertTrue($response->isConnectSetupComplete);
+ }
+
+ public function testThrowsWhenSaasModeDisabled(): void
+ {
+ $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturnFalse();
+
+ $this->expectException(SaasModeEnabledException::class);
+
+ $this->makeHandler()->handle(new CopyStripeConnectAccountDTO(
+ targetOrganizerId: 1,
+ sourceOrganizerId: 2,
+ accountId: 99,
+ ));
+ }
+
+ public function testThrowsWhenSourceHasNoCompletedSetup(): void
+ {
+ $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturnTrue();
+ $this->databaseManager
+ ->shouldReceive('transaction')
+ ->once()
+ ->andReturnUsing(fn(Closure $closure) => $closure());
+
+ $source = (new OrganizerDomainObject())->setId(2)->setAccountId(99)->setName('Source');
+ $source->setOrganizerStripePlatforms(new Collection());
+
+ $target = (new OrganizerDomainObject())->setId(1)->setAccountId(99)->setName('Target');
+ $target->setOrganizerStripePlatforms(new Collection());
+
+ $this->organizerRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->organizerRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['id' => 1, 'account_id' => 99])
+ ->andReturn($target);
+ $this->organizerRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['id' => 2, 'account_id' => 99])
+ ->andReturn($source);
+
+ $this->expectException(ResourceConflictException::class);
+
+ $this->makeHandler()->handle(new CopyStripeConnectAccountDTO(
+ targetOrganizerId: 1,
+ sourceOrganizerId: 2,
+ accountId: 99,
+ ));
+ }
+
+ public function testThrowsWhenTargetOrganizerNotFound(): void
+ {
+ $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturnTrue();
+ $this->databaseManager
+ ->shouldReceive('transaction')
+ ->once()
+ ->andReturnUsing(fn(Closure $closure) => $closure());
+
+ $this->organizerRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->organizerRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['id' => 1, 'account_id' => 99])
+ ->andReturnNull();
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->makeHandler()->handle(new CopyStripeConnectAccountDTO(
+ targetOrganizerId: 1,
+ sourceOrganizerId: 2,
+ accountId: 99,
+ ));
+ }
+
+ private function makeHandler(): CopyStripeConnectAccountHandler
+ {
+ return new CopyStripeConnectAccountHandler(
+ $this->organizerRepository,
+ $this->organizerStripePlatformRepository,
+ $this->stripeAccountSyncService,
+ $this->databaseManager,
+ $this->config,
+ );
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/CreateStripeConnectAccountHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/CreateStripeConnectAccountHandlerTest.php
new file mode 100644
index 0000000000..63c91d199f
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/CreateStripeConnectAccountHandlerTest.php
@@ -0,0 +1,100 @@
+organizerRepository = m::mock(OrganizerRepositoryInterface::class);
+ $this->organizerStripePlatformRepository = m::mock(OrganizerStripePlatformRepositoryInterface::class);
+ $this->databaseManager = m::mock(DatabaseManager::class);
+ $this->logger = m::mock(LoggerInterface::class);
+ $this->config = m::mock(Repository::class);
+ $this->stripeClientFactory = m::mock(StripeClientFactory::class);
+ $this->stripeConfigurationService = m::mock(StripeConfigurationService::class);
+ $this->stripeAccountSyncService = m::mock(StripeAccountSyncService::class);
+ }
+
+ protected function tearDown(): void
+ {
+ m::close();
+ parent::tearDown();
+ }
+
+ public function testThrowsWhenSaasModeDisabled(): void
+ {
+ $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturnFalse();
+
+ $this->expectException(SaasModeEnabledException::class);
+
+ $this->makeHandler()->handle(new CreateStripeConnectAccountDTO(
+ organizerId: 1,
+ accountId: 99,
+ ));
+ }
+
+ public function testThrowsResourceNotFoundWhenOrganizerMissing(): void
+ {
+ $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturnTrue();
+ $this->databaseManager
+ ->shouldReceive('transaction')
+ ->once()
+ ->andReturnUsing(fn(Closure $closure) => $closure());
+
+ $this->organizerRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->organizerRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['id' => 1, 'account_id' => 99])
+ ->andReturnNull();
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->makeHandler()->handle(new CreateStripeConnectAccountDTO(
+ organizerId: 1,
+ accountId: 99,
+ ));
+ }
+
+ private function makeHandler(): CreateStripeConnectAccountHandler
+ {
+ return new CreateStripeConnectAccountHandler(
+ $this->organizerRepository,
+ $this->organizerStripePlatformRepository,
+ $this->databaseManager,
+ $this->logger,
+ $this->config,
+ $this->stripeClientFactory,
+ $this->stripeConfigurationService,
+ $this->stripeAccountSyncService,
+ );
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/CheckInList/CheckInListDataServiceTest.php b/backend/tests/Unit/Services/Domain/CheckInList/CheckInListDataServiceTest.php
new file mode 100644
index 0000000000..e09b5a6fba
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/CheckInList/CheckInListDataServiceTest.php
@@ -0,0 +1,129 @@
+checkInListRepository = Mockery::mock(CheckInListRepositoryInterface::class);
+ $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class);
+
+ $this->service = new CheckInListDataService(
+ $this->checkInListRepository,
+ $this->attendeeRepository,
+ );
+ }
+
+ public function testVerifyAttendeeBelongsToCheckInListPassesWhenProductMatches(): void
+ {
+ $product = Mockery::mock(ProductDomainObject::class);
+ $product->shouldReceive('getId')->andReturn(1);
+
+ $checkInList = Mockery::mock(CheckInListDomainObject::class);
+ $checkInList->shouldReceive('getProducts')->andReturn(new Collection([$product]));
+ $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(null);
+
+ $attendee = Mockery::mock(AttendeeDomainObject::class);
+ $attendee->shouldReceive('getProductId')->andReturn(1);
+
+ $this->service->verifyAttendeeBelongsToCheckInList($checkInList, $attendee);
+
+ $this->assertTrue(true);
+ }
+
+ public function testVerifyPassesAcrossOccurrencesWhenListHasNoOccurrence(): void
+ {
+ $product = Mockery::mock(ProductDomainObject::class);
+ $product->shouldReceive('getId')->andReturn(1);
+
+ $checkInList = Mockery::mock(CheckInListDomainObject::class);
+ $checkInList->shouldReceive('getProducts')->andReturn(new Collection([$product]));
+ $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(null);
+
+ $attendee = Mockery::mock(AttendeeDomainObject::class);
+ $attendee->shouldReceive('getProductId')->andReturn(1);
+
+ $this->service->verifyAttendeeBelongsToCheckInList($checkInList, $attendee);
+
+ $this->assertTrue(true);
+ }
+
+ public function testVerifyPassesWhenOccurrenceMatches(): void
+ {
+ $product = Mockery::mock(ProductDomainObject::class);
+ $product->shouldReceive('getId')->andReturn(1);
+
+ $checkInList = Mockery::mock(CheckInListDomainObject::class);
+ $checkInList->shouldReceive('getProducts')->andReturn(new Collection([$product]));
+ $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(5);
+
+ $attendee = Mockery::mock(AttendeeDomainObject::class);
+ $attendee->shouldReceive('getProductId')->andReturn(1);
+ $attendee->shouldReceive('getEventOccurrenceId')->andReturn(5);
+
+ $this->service->verifyAttendeeBelongsToCheckInList($checkInList, $attendee);
+
+ $this->assertTrue(true);
+ }
+
+ public function testVerifyThrowsWhenOccurrenceMismatch(): void
+ {
+ $product = Mockery::mock(ProductDomainObject::class);
+ $product->shouldReceive('getId')->andReturn(1);
+
+ $checkInList = Mockery::mock(CheckInListDomainObject::class);
+ $checkInList->shouldReceive('getProducts')->andReturn(new Collection([$product]));
+ $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(5);
+
+ $attendee = Mockery::mock(AttendeeDomainObject::class);
+ $attendee->shouldReceive('getProductId')->andReturn(1);
+ $attendee->shouldReceive('getEventOccurrenceId')->andReturn(10);
+ $attendee->shouldReceive('getFullName')->andReturn('John Doe');
+
+ $this->expectException(CannotCheckInException::class);
+
+ $this->service->verifyAttendeeBelongsToCheckInList($checkInList, $attendee);
+ }
+
+ public function testVerifyThrowsWhenProductMismatch(): void
+ {
+ $product = Mockery::mock(ProductDomainObject::class);
+ $product->shouldReceive('getId')->andReturn(1);
+
+ $checkInList = Mockery::mock(CheckInListDomainObject::class);
+ $checkInList->shouldReceive('getProducts')->andReturn(new Collection([$product]));
+
+ $attendee = Mockery::mock(AttendeeDomainObject::class);
+ $attendee->shouldReceive('getProductId')->andReturn(99);
+ $attendee->shouldReceive('getFullName')->andReturn('Jane Doe');
+
+ $this->expectException(CannotCheckInException::class);
+
+ $this->service->verifyAttendeeBelongsToCheckInList($checkInList, $attendee);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php b/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php
index 37ff777ca2..94ee72dd9b 100644
--- a/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php
+++ b/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php
@@ -155,6 +155,51 @@ public function test_whitelists_only_allowed_tokens_for_attendee_ticket(): void
$this->assertArrayHasKey('title', $context['event']);
}
+ public function test_occurrence_dates_override_event_dates(): void
+ {
+ $order = $this->createMockOrder();
+ $event = $this->createMockEvent();
+ $organizer = $this->createMockOrganizer();
+ $eventSettings = $this->createMockEventSettings();
+ $occurrence = $this->createMockOccurrence();
+
+ $context = $this->contextBuilder->buildOrderConfirmationContext(
+ $order, $event, $organizer, $eventSettings, $occurrence
+ );
+
+ $this->assertArrayHasKey('occurrence', $context);
+ $this->assertNotEmpty($context['occurrence']['start_date']);
+ $this->assertNotEmpty($context['occurrence']['start_time']);
+ $this->assertNotEmpty($context['occurrence']['end_date']);
+ $this->assertNotEmpty($context['occurrence']['end_time']);
+ $this->assertEquals('Afternoon Show', $context['occurrence']['label']);
+ $this->assertStringContainsString('Afternoon Show', $context['event']['title']);
+ }
+
+ public function test_occurrence_tokens_empty_when_no_occurrence(): void
+ {
+ $order = $this->createMockOrder();
+ $event = $this->createMockEvent();
+ $organizer = $this->createMockOrganizer();
+ $eventSettings = $this->createMockEventSettings();
+
+ $context = $this->contextBuilder->buildOrderConfirmationContext(
+ $order, $event, $organizer, $eventSettings
+ );
+
+ $this->assertArrayHasKey('occurrence', $context);
+ $this->assertEquals('', $context['occurrence']['label']);
+ }
+
+ private function createMockOccurrence(): Mockery\MockInterface
+ {
+ return Mockery::mock(\HiEvents\DomainObjects\EventOccurrenceDomainObject::class, [
+ 'getStartDate' => '2024-07-20 14:00:00',
+ 'getEndDate' => '2024-07-20 18:00:00',
+ 'getLabel' => 'Afternoon Show',
+ ]);
+ }
+
private function createMockOrder(): OrderDomainObject
{
$orderItem = Mockery::mock(OrderItemDomainObject::class, [
diff --git a/backend/tests/Unit/Services/Domain/Event/CreateEventServiceTest.php b/backend/tests/Unit/Services/Domain/Event/CreateEventServiceTest.php
index f05c7ae6f1..091c975fa7 100644
--- a/backend/tests/Unit/Services/Domain/Event/CreateEventServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Event/CreateEventServiceTest.php
@@ -2,6 +2,7 @@
namespace Tests\Unit\Services\Domain\Event;
+use HiEvents\DomainObjects\Enums\AttendeeDetailsCollectionMethod;
use HiEvents\DomainObjects\Enums\HomepageBackgroundType;
use HiEvents\DomainObjects\Enums\ImageType;
use HiEvents\DomainObjects\EventDomainObject;
@@ -9,15 +10,16 @@
use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\DomainObjects\OrganizerSettingDomainObject;
use HiEvents\Exceptions\OrganizerNotFoundException;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface;
use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface;
+use HiEvents\Repository\Interfaces\ImageRepositoryInterface;
use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface;
use HiEvents\Services\Domain\Event\CreateEventService;
use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService;
-use Illuminate\Database\DatabaseManager;
-use HiEvents\Repository\Interfaces\ImageRepositoryInterface;
use Illuminate\Config\Repository;
+use Illuminate\Database\DatabaseManager;
use Illuminate\Filesystem\FilesystemManager;
use Mockery;
use Tests\TestCase;
@@ -25,16 +27,29 @@
class CreateEventServiceTest extends TestCase
{
private CreateEventService $createEventService;
+
private EventRepositoryInterface $eventRepository;
+
private EventSettingsRepositoryInterface $eventSettingsRepository;
+
private OrganizerRepositoryInterface $organizerRepository;
+
private DatabaseManager $databaseManager;
+
private EventStatisticRepositoryInterface $eventStatisticsRepository;
+
private HtmlPurifierService $purifier;
+
private ImageRepositoryInterface $imageRepository;
+
private Repository $config;
+
private FilesystemManager $filesystemManager;
+ private EventOccurrenceRepositoryInterface $occurrenceRepository;
+
+ private \HiEvents\Repository\Interfaces\CheckInListRepositoryInterface $checkInListRepository;
+
protected function setUp(): void
{
parent::setUp();
@@ -48,6 +63,8 @@ protected function setUp(): void
$this->imageRepository = Mockery::mock(ImageRepositoryInterface::class);
$this->config = Mockery::mock(Repository::class);
$this->filesystemManager = Mockery::mock(FilesystemManager::class);
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->checkInListRepository = Mockery::mock(\HiEvents\Repository\Interfaces\CheckInListRepositoryInterface::class);
$this->createEventService = new CreateEventService(
$this->eventRepository,
@@ -59,6 +76,8 @@ protected function setUp(): void
$this->imageRepository,
$this->config,
$this->filesystemManager,
+ $this->occurrenceRepository,
+ $this->checkInListRepository,
);
}
@@ -68,7 +87,7 @@ protected function tearDown(): void
parent::tearDown();
}
- public function testCreateEventSuccess(): void
+ public function test_create_event_success(): void
{
$eventData = $this->createMockEventDomainObject();
$eventSettings = $this->createMockEventSettingDomainObject();
@@ -112,6 +131,11 @@ public function testCreateEventSuccess(): void
$arg['sales_total_gross'] === 0;
}));
+ // Every event now gets a system-default check-in list on creation.
+ $this->checkInListRepository->shouldReceive('create')->once()
+ ->with(Mockery::on(fn ($arg) => ($arg['is_system_default'] ?? false) === true
+ && ($arg['event_id'] ?? null) === $eventData->getId()));
+
// Mock event cover creation
$this->config->shouldReceive('get')
->with('filesystems.public')
@@ -122,32 +146,34 @@ public function testCreateEventSuccess(): void
$this->config->shouldReceive('get')
->with('filesystems.default')
->andReturn('local');
-
+
$mockDisk = Mockery::mock();
$mockDisk->shouldReceive('exists')
->with('event-covers/CONFERENCE.jpg')
->andReturn(true);
-
+
$this->filesystemManager->shouldReceive('disk')
->with('public')
->andReturn($mockDisk);
-
+
$this->imageRepository->shouldReceive('create')
->once();
$this->purifier->shouldReceive('purify')->andReturn('Test Description');
- $result = $this->createEventService->createEvent($eventData, $eventSettings);
+ $this->occurrenceRepository->shouldReceive('create')->once();
+
+ $result = $this->createEventService->createEvent($eventData, '2023-01-01 00:00:00', '2023-01-02 00:00:00', $eventSettings);
$this->assertEquals($eventData->getId(), $result->getId());
}
- public function testCreateEventWithoutEventSettings(): void
+ public function test_create_event_without_event_settings(): void
{
$eventData = $this->createMockEventDomainObject();
$organizer = $this->createMockOrganizerDomainObject()
->shouldReceive('getOrganizerSettings')
- ->andReturn(new OrganizerSettingDomainObject())
+ ->andReturn(new OrganizerSettingDomainObject)
->getMock();
$this->databaseManager->shouldReceive('transaction')->once()->andReturnUsing(function ($callback) {
@@ -175,6 +201,7 @@ public function testCreateEventWithoutEventSettings(): void
}));
$this->eventStatisticsRepository->shouldReceive('create');
+ $this->checkInListRepository->shouldReceive('create');
// Mock event cover creation
$this->config->shouldReceive('get')
@@ -186,12 +213,12 @@ public function testCreateEventWithoutEventSettings(): void
$this->config->shouldReceive('get')
->with('filesystems.default')
->andReturn('local');
-
+
$mockDisk = Mockery::mock();
$mockDisk->shouldReceive('exists')
->with('event-covers/CONFERENCE.jpg')
->andReturn(false); // No cover exists for this test
-
+
$this->filesystemManager->shouldReceive('disk')
->with('public')
->andReturn($mockDisk);
@@ -201,7 +228,58 @@ public function testCreateEventWithoutEventSettings(): void
$this->assertTrue(true);
}
- public function testCreateEventThrowsOrganizerNotFoundException(): void
+ public function test_create_recurring_event_defaults_collection_method_to_per_order(): void
+ {
+ $eventData = Mockery::mock(EventDomainObject::class, static function ($mock) {
+ $mock->shouldReceive('getId')->andReturn(1);
+ $mock->shouldReceive('getTitle')->andReturn('Weekly Yoga');
+ $mock->shouldReceive('getOrganizerId')->andReturn(1);
+ $mock->shouldReceive('getAccountId')->andReturn(1);
+ $mock->shouldReceive('getTimezone')->andReturn('UTC');
+ $mock->shouldReceive('getCurrency')->andReturn('USD');
+ $mock->shouldReceive('getDescription')->andReturn('Desc');
+ $mock->shouldReceive('getLocationDetails')->andReturn(null);
+ $mock->shouldReceive('getUserId')->andReturn(1);
+ $mock->shouldReceive('getStatus')->andReturn('DRAFT');
+ $mock->shouldReceive('getCategory')->andReturn('WELLNESS');
+ $mock->shouldReceive('getAttributes')->andReturn([]);
+ $mock->shouldReceive('getType')->andReturn('RECURRING');
+ $mock->shouldReceive('getRecurrenceRule')->andReturn(null);
+ });
+
+ $organizer = $this->createMockOrganizerDomainObject()
+ ->shouldReceive('getOrganizerSettings')
+ ->andReturn(new OrganizerSettingDomainObject)
+ ->getMock();
+
+ $this->databaseManager->shouldReceive('transaction')->once()->andReturnUsing(fn ($cb) => $cb());
+
+ $this->organizerRepository
+ ->shouldReceive('loadRelation')->with(OrganizerSettingDomainObject::class)->once()->andReturnSelf()
+ ->getMock()
+ ->shouldReceive('findFirstWhere')->andReturn($organizer);
+
+ $this->eventRepository->shouldReceive('create')->andReturn($eventData);
+ $this->purifier->shouldReceive('purify')->andReturn('Desc');
+
+ $this->config->shouldReceive('get')->with('filesystems.public')->andReturn('public');
+ $this->config->shouldReceive('get')->with('app.event_categories_cover_images_path')->andReturn('event-covers');
+ $mockDisk = Mockery::mock();
+ $mockDisk->shouldReceive('exists')->andReturn(false);
+ $this->filesystemManager->shouldReceive('disk')->with('public')->andReturn($mockDisk);
+
+ $this->eventSettingsRepository->shouldReceive('create')
+ ->once()
+ ->with(Mockery::on(fn ($arg) => $arg['attendee_details_collection_method'] === AttendeeDetailsCollectionMethod::PER_ORDER->value));
+
+ $this->eventStatisticsRepository->shouldReceive('create');
+ $this->checkInListRepository->shouldReceive('create');
+
+ $this->createEventService->createEvent($eventData);
+ $this->assertTrue(true);
+ }
+
+ public function test_create_event_throws_organizer_not_found_exception(): void
{
$eventData = $this->createMockEventDomainObject();
@@ -223,12 +301,12 @@ public function testCreateEventThrowsOrganizerNotFoundException(): void
$this->createEventService->createEvent($eventData);
}
- public function testCreateEventWithEventCoverCreatesImageRecord(): void
+ public function test_create_event_with_event_cover_creates_image_record(): void
{
$eventData = $this->createMockEventDomainObject();
$organizer = $this->createMockOrganizerDomainObject()
->shouldReceive('getOrganizerSettings')
- ->andReturn(new OrganizerSettingDomainObject())
+ ->andReturn(new OrganizerSettingDomainObject)
->getMock();
$this->databaseManager->shouldReceive('transaction')->once()->andReturnUsing(function ($callback) {
@@ -256,16 +334,16 @@ public function testCreateEventWithEventCoverCreatesImageRecord(): void
$this->config->shouldReceive('get')
->with('filesystems.default')
->andReturn('local');
-
+
$mockDisk = Mockery::mock();
$mockDisk->shouldReceive('exists')
->with('event-covers/CONFERENCE.jpg')
->andReturn(true);
-
+
$this->filesystemManager->shouldReceive('disk')
->with('public')
->andReturn($mockDisk);
-
+
// Verify image record is created with correct data
$this->imageRepository->shouldReceive('create')
->once()
@@ -285,6 +363,7 @@ public function testCreateEventWithEventCoverCreatesImageRecord(): void
}));
$this->eventStatisticsRepository->shouldReceive('create');
+ $this->checkInListRepository->shouldReceive('create');
$this->purifier->shouldReceive('purify')->andReturn('Test Description');
@@ -293,12 +372,12 @@ public function testCreateEventWithEventCoverCreatesImageRecord(): void
$this->assertTrue(true);
}
- public function testCreateEventWithoutEventCoverDoesNotCreateImageRecord(): void
+ public function test_create_event_without_event_cover_does_not_create_image_record(): void
{
$eventData = $this->createMockEventDomainObjectWithCategory('MUSIC');
$organizer = $this->createMockOrganizerDomainObject()
->shouldReceive('getOrganizerSettings')
- ->andReturn(new OrganizerSettingDomainObject())
+ ->andReturn(new OrganizerSettingDomainObject)
->getMock();
$this->databaseManager->shouldReceive('transaction')->once()->andReturnUsing(function ($callback) {
@@ -323,16 +402,16 @@ public function testCreateEventWithoutEventCoverDoesNotCreateImageRecord(): void
$this->config->shouldReceive('get')
->with('app.event_categories_cover_images_path')
->andReturn('event-covers');
-
+
$mockDisk = Mockery::mock();
$mockDisk->shouldReceive('exists')
->with('event-covers/MUSIC.jpg')
->andReturn(false);
-
+
$this->filesystemManager->shouldReceive('disk')
->with('public')
->andReturn($mockDisk);
-
+
// Image repository should NOT be called
$this->imageRepository->shouldNotReceive('create');
@@ -343,6 +422,7 @@ public function testCreateEventWithoutEventCoverDoesNotCreateImageRecord(): void
}));
$this->eventStatisticsRepository->shouldReceive('create');
+ $this->checkInListRepository->shouldReceive('create');
$this->purifier->shouldReceive('purify')->andReturn('Test Description');
@@ -368,6 +448,8 @@ private function createMockEventDomainObject(): EventDomainObject
$mock->shouldReceive('getStatus')->andReturn('active');
$mock->shouldReceive('getCategory')->andReturn('CONFERENCE');
$mock->shouldReceive('getAttributes')->andReturn([]);
+ $mock->shouldReceive('getType')->andReturn('SINGLE');
+ $mock->shouldReceive('getRecurrenceRule')->andReturn(null);
});
}
@@ -407,6 +489,8 @@ private function createMockEventDomainObjectWithCategory(string $category): Even
$mock->shouldReceive('getStatus')->andReturn('active');
$mock->shouldReceive('getCategory')->andReturn($category);
$mock->shouldReceive('getAttributes')->andReturn([]);
+ $mock->shouldReceive('getType')->andReturn('SINGLE');
+ $mock->shouldReceive('getRecurrenceRule')->andReturn(null);
});
}
}
diff --git a/backend/tests/Unit/Services/Domain/Event/EventOccurrenceGeneratorServiceTest.php b/backend/tests/Unit/Services/Domain/Event/EventOccurrenceGeneratorServiceTest.php
new file mode 100644
index 0000000000..e151ab91d8
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Event/EventOccurrenceGeneratorServiceTest.php
@@ -0,0 +1,936 @@
+ruleParser = Mockery::mock(RecurrenceRuleParserService::class);
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->waitlistEntryRepository = Mockery::mock(WaitlistEntryRepositoryInterface::class);
+ // Most tests don't exercise stale-deletion; default to a no-op so the
+ // few that do can override with explicit expectations.
+ $this->waitlistEntryRepository->shouldReceive('updateWhere')->byDefault();
+
+ $this->service = new EventOccurrenceGeneratorService(
+ $this->ruleParser,
+ $this->occurrenceRepository,
+ $this->waitlistEntryRepository,
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ /**
+ * Mocks the two batch lookups the generator runs to decide which existing
+ * occurrences are "in use" and therefore protected from soft-deletion:
+ * occurrences pointed at by an active order_item OR an active attendee.
+ */
+ private function mockDbBatchQuery(
+ array $occurrenceIdsWithOrders = [],
+ array $occurrenceIdsWithAttendees = [],
+ ): void {
+ $orderItemsBuilder = Mockery::mock(\Illuminate\Database\Query\Builder::class);
+ $orderItemsBuilder->shouldReceive('whereIn')->andReturnSelf();
+ $orderItemsBuilder->shouldReceive('whereNull')->andReturnSelf();
+ $orderItemsBuilder->shouldReceive('distinct')->andReturnSelf();
+ $orderItemsBuilder->shouldReceive('pluck')->andReturn(collect($occurrenceIdsWithOrders));
+
+ $attendeesBuilder = Mockery::mock(\Illuminate\Database\Query\Builder::class);
+ $attendeesBuilder->shouldReceive('whereIn')->andReturnSelf();
+ $attendeesBuilder->shouldReceive('whereNull')->andReturnSelf();
+ $attendeesBuilder->shouldReceive('distinct')->andReturnSelf();
+ $attendeesBuilder->shouldReceive('pluck')->andReturn(collect($occurrenceIdsWithAttendees));
+
+ DB::shouldReceive('table')
+ ->with('order_items')
+ ->andReturn($orderItemsBuilder);
+ DB::shouldReceive('table')
+ ->with('attendees')
+ ->andReturn($attendeesBuilder);
+ }
+
+ public function testNewOccurrencesAreCreatedWhenNoneExist(): void
+ {
+ $event = $this->createMockEvent();
+ $recurrenceRule = ['frequency' => 'daily'];
+
+ $candidateStart = CarbonImmutable::parse('2025-03-01 10:00:00');
+ $candidateEnd = CarbonImmutable::parse('2025-03-01 11:00:00');
+
+ $this->ruleParser
+ ->shouldReceive('parse')
+ ->with($recurrenceRule, 'UTC')
+ ->once()
+ ->andReturn(collect([
+ ['start' => $candidateStart, 'end' => $candidateEnd, 'capacity' => 100],
+ ]));
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1])
+ ->once()
+ ->andReturn(collect());
+
+ $createdOccurrence = $this->createOccurrenceDomainObject(
+ id: 10,
+ startDate: '2025-03-01 10:00:00',
+ endDate: '2025-03-01 11:00:00',
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('create')
+ ->with(Mockery::on(function ($arg) {
+ return $arg[EventOccurrenceDomainObjectAbstract::EVENT_ID] === 1
+ && $arg[EventOccurrenceDomainObjectAbstract::START_DATE] === '2025-03-01 10:00:00'
+ && $arg[EventOccurrenceDomainObjectAbstract::END_DATE] === '2025-03-01 11:00:00'
+ && $arg[EventOccurrenceDomainObjectAbstract::STATUS] === EventOccurrenceStatus::ACTIVE->name
+ && $arg[EventOccurrenceDomainObjectAbstract::CAPACITY] === 100
+ && $arg[EventOccurrenceDomainObjectAbstract::USED_CAPACITY] === 0
+ && $arg[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === false;
+ }))
+ ->once()
+ ->andReturn($createdOccurrence);
+
+ $this->occurrenceRepository->shouldNotReceive('deleteWhere');
+
+ $result = $this->service->generate($event, $recurrenceRule);
+
+ $this->assertCount(1, $result);
+ $this->assertEquals(10, $result->first()->getId());
+ }
+
+ public function testMultipleNewOccurrencesCreated(): void
+ {
+ $event = $this->createMockEvent();
+ $recurrenceRule = ['frequency' => 'daily'];
+
+ $candidates = collect([
+ [
+ 'start' => CarbonImmutable::parse('2025-03-01 10:00:00'),
+ 'end' => CarbonImmutable::parse('2025-03-01 11:00:00'),
+ 'capacity' => 50,
+ ],
+ [
+ 'start' => CarbonImmutable::parse('2025-03-02 10:00:00'),
+ 'end' => CarbonImmutable::parse('2025-03-02 11:00:00'),
+ 'capacity' => 50,
+ ],
+ ]);
+
+ $this->ruleParser
+ ->shouldReceive('parse')
+ ->with($recurrenceRule, 'UTC')
+ ->once()
+ ->andReturn($candidates);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1])
+ ->once()
+ ->andReturn(collect());
+
+ $occ1 = $this->createOccurrenceDomainObject(id: 10, startDate: '2025-03-01 10:00:00');
+ $occ2 = $this->createOccurrenceDomainObject(id: 11, startDate: '2025-03-02 10:00:00');
+
+ $this->occurrenceRepository
+ ->shouldReceive('create')
+ ->twice()
+ ->andReturn($occ1, $occ2);
+
+ $result = $this->service->generate($event, $recurrenceRule);
+
+ $this->assertCount(2, $result);
+ }
+
+ public function testExistingOccurrenceWithoutOrdersAndNotOverriddenIsUpdatedInPlace(): void
+ {
+ $event = $this->createMockEvent();
+ $recurrenceRule = ['frequency' => 'daily'];
+
+ $candidateStart = CarbonImmutable::parse('2025-03-01 10:00:00');
+ $candidateEnd = CarbonImmutable::parse('2025-03-01 12:00:00');
+
+ $this->ruleParser
+ ->shouldReceive('parse')
+ ->with($recurrenceRule, 'UTC')
+ ->once()
+ ->andReturn(collect([
+ ['start' => $candidateStart, 'end' => $candidateEnd, 'capacity' => 200],
+ ]));
+
+ $existingOccurrence = $this->createOccurrenceDomainObject(
+ id: 5,
+ startDate: '2025-03-01 10:00:00',
+ endDate: '2025-03-01 11:00:00',
+ isOverridden: false,
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1])
+ ->once()
+ ->andReturn(collect([$existingOccurrence]));
+
+ $this->mockDbBatchQuery([]);
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->with(
+ Mockery::on(function ($attributes) {
+ return $attributes[EventOccurrenceDomainObjectAbstract::START_DATE] === '2025-03-01 10:00:00'
+ && $attributes[EventOccurrenceDomainObjectAbstract::END_DATE] === '2025-03-01 12:00:00'
+ && $attributes[EventOccurrenceDomainObjectAbstract::CAPACITY] === 200;
+ }),
+ [EventOccurrenceDomainObjectAbstract::ID => 5]
+ )
+ ->once();
+
+ $updatedOccurrence = $this->createOccurrenceDomainObject(
+ id: 5,
+ startDate: '2025-03-01 10:00:00',
+ endDate: '2025-03-01 12:00:00',
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('findById')
+ ->with(5)
+ ->once()
+ ->andReturn($updatedOccurrence);
+
+ $this->occurrenceRepository->shouldNotReceive('create');
+ $this->occurrenceRepository->shouldNotReceive('deleteWhere');
+
+ $result = $this->service->generate($event, $recurrenceRule);
+
+ $this->assertCount(1, $result);
+ $this->assertEquals(5, $result->first()->getId());
+ $this->assertEquals('2025-03-01 12:00:00', $result->first()->getEndDate());
+ }
+
+ public function testExistingOccurrenceWithOrdersIsNotModified(): void
+ {
+ $event = $this->createMockEvent();
+ $recurrenceRule = ['frequency' => 'daily'];
+
+ $candidateStart = CarbonImmutable::parse('2025-03-01 10:00:00');
+ $candidateEnd = CarbonImmutable::parse('2025-03-01 12:00:00');
+
+ $this->ruleParser
+ ->shouldReceive('parse')
+ ->with($recurrenceRule, 'UTC')
+ ->once()
+ ->andReturn(collect([
+ ['start' => $candidateStart, 'end' => $candidateEnd, 'capacity' => 200],
+ ]));
+
+ $existingOccurrence = $this->createOccurrenceDomainObject(
+ id: 5,
+ startDate: '2025-03-01 10:00:00',
+ endDate: '2025-03-01 11:00:00',
+ isOverridden: false,
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1])
+ ->once()
+ ->andReturn(collect([$existingOccurrence]));
+
+ $this->mockDbBatchQuery([5]);
+
+ $this->occurrenceRepository->shouldNotReceive('updateWhere');
+ $this->occurrenceRepository->shouldNotReceive('findById');
+ $this->occurrenceRepository->shouldNotReceive('create');
+ $this->occurrenceRepository->shouldNotReceive('deleteWhere');
+
+ $result = $this->service->generate($event, $recurrenceRule);
+
+ $this->assertCount(1, $result);
+ $this->assertEquals(5, $result->first()->getId());
+ $this->assertEquals('2025-03-01 11:00:00', $result->first()->getEndDate());
+ }
+
+ public function testExistingOverriddenOccurrenceIsNotModified(): void
+ {
+ $event = $this->createMockEvent();
+ $recurrenceRule = ['frequency' => 'daily'];
+
+ $candidateStart = CarbonImmutable::parse('2025-03-01 10:00:00');
+ $candidateEnd = CarbonImmutable::parse('2025-03-01 12:00:00');
+
+ $this->ruleParser
+ ->shouldReceive('parse')
+ ->with($recurrenceRule, 'UTC')
+ ->once()
+ ->andReturn(collect([
+ ['start' => $candidateStart, 'end' => $candidateEnd, 'capacity' => 200],
+ ]));
+
+ $existingOccurrence = $this->createOccurrenceDomainObject(
+ id: 5,
+ startDate: '2025-03-01 10:00:00',
+ endDate: '2025-03-01 11:00:00',
+ isOverridden: true,
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1])
+ ->once()
+ ->andReturn(collect([$existingOccurrence]));
+
+ $this->mockDbBatchQuery([]);
+
+ $this->occurrenceRepository->shouldNotReceive('updateWhere');
+ $this->occurrenceRepository->shouldNotReceive('findById');
+ $this->occurrenceRepository->shouldNotReceive('create');
+ $this->occurrenceRepository->shouldNotReceive('deleteWhere');
+
+ $result = $this->service->generate($event, $recurrenceRule);
+
+ $this->assertCount(1, $result);
+ $this->assertEquals(5, $result->first()->getId());
+ $this->assertEquals('2025-03-01 11:00:00', $result->first()->getEndDate());
+ }
+
+ public function testStaleOccurrenceWithNoOrdersAndNotOverriddenIsSoftDeleted(): void
+ {
+ $event = $this->createMockEvent();
+ $recurrenceRule = ['frequency' => 'daily'];
+
+ $this->ruleParser
+ ->shouldReceive('parse')
+ ->with($recurrenceRule, 'UTC')
+ ->once()
+ ->andReturn(collect([
+ [
+ 'start' => CarbonImmutable::parse('2025-03-02 10:00:00'),
+ 'end' => CarbonImmutable::parse('2025-03-02 11:00:00'),
+ 'capacity' => 100,
+ ],
+ ]));
+
+ $staleOccurrence = $this->createOccurrenceDomainObject(
+ id: 5,
+ startDate: '2025-03-01 10:00:00',
+ endDate: '2025-03-01 11:00:00',
+ isOverridden: false,
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1])
+ ->once()
+ ->andReturn(collect([$staleOccurrence]));
+
+ $this->mockDbBatchQuery([]);
+
+ $newOccurrence = $this->createOccurrenceDomainObject(
+ id: 10,
+ startDate: '2025-03-02 10:00:00',
+ endDate: '2025-03-02 11:00:00',
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('create')
+ ->once()
+ ->andReturn($newOccurrence);
+
+ $this->occurrenceRepository
+ ->shouldReceive('deleteWhere')
+ ->with([[EventOccurrenceDomainObjectAbstract::ID, 'in', [5]]])
+ ->once();
+
+ $result = $this->service->generate($event, $recurrenceRule);
+
+ $this->assertCount(1, $result);
+ $this->assertEquals(10, $result->first()->getId());
+ }
+
+ public function testStaleOccurrenceWithOrdersIsMarkedOverriddenAndNotDeleted(): void
+ {
+ $event = $this->createMockEvent();
+ $recurrenceRule = ['frequency' => 'daily'];
+
+ $this->ruleParser
+ ->shouldReceive('parse')
+ ->with($recurrenceRule, 'UTC')
+ ->once()
+ ->andReturn(collect([
+ [
+ 'start' => CarbonImmutable::parse('2025-03-02 10:00:00'),
+ 'end' => CarbonImmutable::parse('2025-03-02 11:00:00'),
+ 'capacity' => 100,
+ ],
+ ]));
+
+ $staleWithOrders = $this->createOccurrenceDomainObject(
+ id: 5,
+ startDate: '2025-03-01 10:00:00',
+ endDate: '2025-03-01 11:00:00',
+ isOverridden: false,
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1])
+ ->once()
+ ->andReturn(collect([$staleWithOrders]));
+
+ $this->mockDbBatchQuery([5]);
+
+ $newOccurrence = $this->createOccurrenceDomainObject(
+ id: 10,
+ startDate: '2025-03-02 10:00:00',
+ endDate: '2025-03-02 11:00:00',
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('create')
+ ->once()
+ ->andReturn($newOccurrence);
+
+ $this->occurrenceRepository->shouldNotReceive('deleteWhere');
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ [EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => true],
+ [EventOccurrenceDomainObjectAbstract::ID => 5],
+ );
+
+ $result = $this->service->generate($event, $recurrenceRule);
+
+ $this->assertCount(1, $result);
+ $this->assertEquals(10, $result->first()->getId());
+ }
+
+ public function testStaleOccurrenceWithAttendeesButNoOrderItemsIsMarkedOverriddenAndNotDeleted(): void
+ {
+ // Regression: matches the single/bulk delete handlers, which both
+ // refuse to delete an occurrence that has any attendees pointing at
+ // it — even if no order_item row does. Regeneration was previously
+ // only checking order_items, so changing the recurrence rule could
+ // soft-delete an attendee-bearing occurrence in import / partial-
+ // restore scenarios where the two tables disagree.
+ $event = $this->createMockEvent();
+ $recurrenceRule = ['frequency' => 'daily'];
+
+ $this->ruleParser
+ ->shouldReceive('parse')
+ ->with($recurrenceRule, 'UTC')
+ ->once()
+ ->andReturn(collect([
+ [
+ 'start' => CarbonImmutable::parse('2025-03-02 10:00:00'),
+ 'end' => CarbonImmutable::parse('2025-03-02 11:00:00'),
+ 'capacity' => 100,
+ ],
+ ]));
+
+ $staleWithAttendees = $this->createOccurrenceDomainObject(
+ id: 5,
+ startDate: '2025-03-01 10:00:00',
+ endDate: '2025-03-01 11:00:00',
+ isOverridden: false,
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1])
+ ->once()
+ ->andReturn(collect([$staleWithAttendees]));
+
+ // No order_item rows pointing at occ 5, but the attendees query does
+ // return it — must still be protected.
+ $this->mockDbBatchQuery(occurrenceIdsWithOrders: [], occurrenceIdsWithAttendees: [5]);
+
+ $newOccurrence = $this->createOccurrenceDomainObject(
+ id: 10,
+ startDate: '2025-03-02 10:00:00',
+ endDate: '2025-03-02 11:00:00',
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('create')
+ ->once()
+ ->andReturn($newOccurrence);
+
+ $this->occurrenceRepository->shouldNotReceive('deleteWhere');
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ [EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => true],
+ [EventOccurrenceDomainObjectAbstract::ID => 5],
+ );
+
+ $result = $this->service->generate($event, $recurrenceRule);
+
+ $this->assertCount(1, $result);
+ $this->assertEquals(10, $result->first()->getId());
+ }
+
+ public function testStaleOverriddenOccurrenceIsNotDeleted(): void
+ {
+ $event = $this->createMockEvent();
+ $recurrenceRule = ['frequency' => 'daily'];
+
+ $this->ruleParser
+ ->shouldReceive('parse')
+ ->with($recurrenceRule, 'UTC')
+ ->once()
+ ->andReturn(collect([
+ [
+ 'start' => CarbonImmutable::parse('2025-03-02 10:00:00'),
+ 'end' => CarbonImmutable::parse('2025-03-02 11:00:00'),
+ 'capacity' => 100,
+ ],
+ ]));
+
+ $staleOverridden = $this->createOccurrenceDomainObject(
+ id: 5,
+ startDate: '2025-03-01 10:00:00',
+ endDate: '2025-03-01 11:00:00',
+ isOverridden: true,
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1])
+ ->once()
+ ->andReturn(collect([$staleOverridden]));
+
+ $this->mockDbBatchQuery([]);
+
+ $newOccurrence = $this->createOccurrenceDomainObject(
+ id: 10,
+ startDate: '2025-03-02 10:00:00',
+ endDate: '2025-03-02 11:00:00',
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('create')
+ ->once()
+ ->andReturn($newOccurrence);
+
+ $this->occurrenceRepository->shouldNotReceive('deleteWhere');
+
+ $result = $this->service->generate($event, $recurrenceRule);
+
+ $this->assertCount(1, $result);
+ }
+
+ public function testMixedScenarioWithNewUpdatedSkippedAndStaleOccurrences(): void
+ {
+ $event = $this->createMockEvent();
+ $recurrenceRule = ['frequency' => 'daily'];
+
+ $candidates = collect([
+ [
+ 'start' => CarbonImmutable::parse('2025-03-01 10:00:00'),
+ 'end' => CarbonImmutable::parse('2025-03-01 11:00:00'),
+ 'capacity' => 100,
+ ],
+ [
+ 'start' => CarbonImmutable::parse('2025-03-02 10:00:00'),
+ 'end' => CarbonImmutable::parse('2025-03-02 11:00:00'),
+ 'capacity' => 100,
+ ],
+ [
+ 'start' => CarbonImmutable::parse('2025-03-03 10:00:00'),
+ 'end' => CarbonImmutable::parse('2025-03-03 11:00:00'),
+ 'capacity' => 100,
+ ],
+ [
+ 'start' => CarbonImmutable::parse('2025-03-05 10:00:00'),
+ 'end' => CarbonImmutable::parse('2025-03-05 11:00:00'),
+ 'capacity' => 100,
+ ],
+ ]);
+
+ $this->ruleParser
+ ->shouldReceive('parse')
+ ->with($recurrenceRule, 'UTC')
+ ->once()
+ ->andReturn($candidates);
+
+ $existingUpdatable = $this->createOccurrenceDomainObject(
+ id: 1, startDate: '2025-03-01 10:00:00', endDate: '2025-03-01 10:30:00', isOverridden: false,
+ );
+ $existingWithOrders = $this->createOccurrenceDomainObject(
+ id: 2, startDate: '2025-03-02 10:00:00', endDate: '2025-03-02 10:30:00', isOverridden: false,
+ );
+ $existingOverridden = $this->createOccurrenceDomainObject(
+ id: 3, startDate: '2025-03-03 10:00:00', endDate: '2025-03-03 10:30:00', isOverridden: true,
+ );
+ $existingStale = $this->createOccurrenceDomainObject(
+ id: 4, startDate: '2025-03-04 10:00:00', endDate: '2025-03-04 10:30:00', isOverridden: false,
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1])
+ ->once()
+ ->andReturn(collect([$existingUpdatable, $existingWithOrders, $existingOverridden, $existingStale]));
+
+ $this->mockDbBatchQuery([2]);
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->with(
+ Mockery::on(function ($attributes) {
+ return $attributes[EventOccurrenceDomainObjectAbstract::START_DATE] === '2025-03-01 10:00:00'
+ && $attributes[EventOccurrenceDomainObjectAbstract::END_DATE] === '2025-03-01 11:00:00'
+ && $attributes[EventOccurrenceDomainObjectAbstract::CAPACITY] === 100;
+ }),
+ [EventOccurrenceDomainObjectAbstract::ID => 1]
+ )
+ ->once();
+
+ $updatedOcc1 = $this->createOccurrenceDomainObject(
+ id: 1, startDate: '2025-03-01 10:00:00', endDate: '2025-03-01 11:00:00',
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('findById')
+ ->with(1)
+ ->once()
+ ->andReturn($updatedOcc1);
+
+ $newOcc = $this->createOccurrenceDomainObject(
+ id: 20, startDate: '2025-03-05 10:00:00', endDate: '2025-03-05 11:00:00',
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('create')
+ ->once()
+ ->andReturn($newOcc);
+
+ $this->occurrenceRepository
+ ->shouldReceive('deleteWhere')
+ ->with([[EventOccurrenceDomainObjectAbstract::ID, 'in', [4]]])
+ ->once();
+
+ $result = $this->service->generate($event, $recurrenceRule);
+
+ $this->assertCount(4, $result);
+
+ $ids = $result->map(fn ($occ) => $occ->getId())->toArray();
+ $this->assertContains(1, $ids);
+ $this->assertContains(2, $ids);
+ $this->assertContains(3, $ids);
+ $this->assertContains(20, $ids);
+ $this->assertNotContains(4, $ids);
+ }
+
+ public function testEventTimezoneIsPassedToParser(): void
+ {
+ $event = $this->createMockEvent(timezone: 'America/New_York');
+ $recurrenceRule = ['frequency' => 'daily'];
+
+ $this->ruleParser
+ ->shouldReceive('parse')
+ ->with($recurrenceRule, 'America/New_York')
+ ->once()
+ ->andReturn(collect());
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(collect());
+
+ $result = $this->service->generate($event, $recurrenceRule);
+
+ $this->assertCount(0, $result);
+ }
+
+ public function testNullTimezoneDefaultsToUtc(): void
+ {
+ $event = $this->createMockEvent(timezone: null);
+ $recurrenceRule = ['frequency' => 'daily'];
+
+ $this->ruleParser
+ ->shouldReceive('parse')
+ ->with($recurrenceRule, 'UTC')
+ ->once()
+ ->andReturn(collect());
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(collect());
+
+ $result = $this->service->generate($event, $recurrenceRule);
+
+ $this->assertCount(0, $result);
+ }
+
+ public function testNewOccurrenceWithNullEndDate(): void
+ {
+ $event = $this->createMockEvent();
+ $recurrenceRule = ['frequency' => 'daily'];
+
+ $candidateStart = CarbonImmutable::parse('2025-03-01 10:00:00');
+
+ $this->ruleParser
+ ->shouldReceive('parse')
+ ->with($recurrenceRule, 'UTC')
+ ->once()
+ ->andReturn(collect([
+ ['start' => $candidateStart, 'end' => null, 'capacity' => null],
+ ]));
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(collect());
+
+ $createdOccurrence = $this->createOccurrenceDomainObject(
+ id: 10,
+ startDate: '2025-03-01 10:00:00',
+ endDate: null,
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('create')
+ ->with(Mockery::on(function ($arg) {
+ return $arg[EventOccurrenceDomainObjectAbstract::END_DATE] === null
+ && $arg[EventOccurrenceDomainObjectAbstract::CAPACITY] === null;
+ }))
+ ->once()
+ ->andReturn($createdOccurrence);
+
+ $result = $this->service->generate($event, $recurrenceRule);
+
+ $this->assertCount(1, $result);
+ $this->assertNull($result->first()->getEndDate());
+ }
+
+ public function testEmptyCandidatesWithExistingOccurrencesDeletesStale(): void
+ {
+ $event = $this->createMockEvent();
+ $recurrenceRule = ['frequency' => 'daily'];
+
+ $this->ruleParser
+ ->shouldReceive('parse')
+ ->with($recurrenceRule, 'UTC')
+ ->once()
+ ->andReturn(collect());
+
+ $staleOccurrence = $this->createOccurrenceDomainObject(
+ id: 5,
+ startDate: '2025-03-01 10:00:00',
+ isOverridden: false,
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(collect([$staleOccurrence]));
+
+ $this->mockDbBatchQuery([]);
+
+ $this->occurrenceRepository
+ ->shouldReceive('deleteWhere')
+ ->with([[EventOccurrenceDomainObjectAbstract::ID, 'in', [5]]])
+ ->once();
+
+ $result = $this->service->generate($event, $recurrenceRule);
+
+ $this->assertCount(0, $result);
+ }
+
+ public function testStaleOccurrenceWaitlistEntriesAreCancelledBeforeDeletion(): void
+ {
+ // Regression for the regenerate-strands-waitlist bug: removeStaleOccurrences
+ // soft-deletes orphaned occurrences. The FK is nullOnDelete which only
+ // fires on hard deletes, so without explicit waitlist cleanup, WAITING/
+ // OFFERED entries are left pointing at soft-deleted rows and crash
+ // ProcessWaitlistService on the next CapacityChangedEvent.
+ $event = $this->createMockEvent();
+ $recurrenceRule = ['frequency' => 'daily'];
+
+ $this->ruleParser
+ ->shouldReceive('parse')
+ ->with($recurrenceRule, 'UTC')
+ ->once()
+ ->andReturn(collect());
+
+ $stale = $this->createOccurrenceDomainObject(
+ id: 5,
+ startDate: '2025-03-01 10:00:00',
+ isOverridden: false,
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(collect([$stale]));
+
+ $this->mockDbBatchQuery([]);
+
+ // Override the default no-op mock with a strict expectation: the
+ // waitlist cancel must run with the right scope before the delete.
+ $this->waitlistEntryRepository = Mockery::mock(WaitlistEntryRepositoryInterface::class);
+ $this->waitlistEntryRepository
+ ->shouldReceive('updateWhere')
+ ->with(
+ ['status' => WaitlistEntryStatus::CANCELLED->name],
+ [
+ ['event_id', 'in', [1]],
+ ['event_occurrence_id', 'in', [5]],
+ ['status', 'in', [
+ WaitlistEntryStatus::WAITING->name,
+ WaitlistEntryStatus::OFFERED->name,
+ ]],
+ ],
+ )
+ ->once();
+
+ $this->service = new EventOccurrenceGeneratorService(
+ $this->ruleParser,
+ $this->occurrenceRepository,
+ $this->waitlistEntryRepository,
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('deleteWhere')
+ ->with([[EventOccurrenceDomainObjectAbstract::ID, 'in', [5]]])
+ ->once();
+
+ $result = $this->service->generate($event, $recurrenceRule);
+
+ $this->assertCount(0, $result);
+ }
+
+ public function testEmptyCandidatesWithOverriddenExistingOccurrenceKeepsIt(): void
+ {
+ $event = $this->createMockEvent();
+ $recurrenceRule = ['frequency' => 'daily'];
+
+ $this->ruleParser
+ ->shouldReceive('parse')
+ ->with($recurrenceRule, 'UTC')
+ ->once()
+ ->andReturn(collect());
+
+ $overriddenOccurrence = $this->createOccurrenceDomainObject(
+ id: 5,
+ startDate: '2025-03-01 10:00:00',
+ isOverridden: true,
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(collect([$overriddenOccurrence]));
+
+ $this->mockDbBatchQuery([]);
+
+ $this->occurrenceRepository->shouldNotReceive('deleteWhere');
+
+ $result = $this->service->generate($event, $recurrenceRule);
+
+ $this->assertCount(0, $result);
+ }
+
+ public function testExistingOccurrenceWithOrdersAndOverriddenIsSkipped(): void
+ {
+ $event = $this->createMockEvent();
+ $recurrenceRule = ['frequency' => 'daily'];
+
+ $candidateStart = CarbonImmutable::parse('2025-03-01 10:00:00');
+ $candidateEnd = CarbonImmutable::parse('2025-03-01 12:00:00');
+
+ $this->ruleParser
+ ->shouldReceive('parse')
+ ->with($recurrenceRule, 'UTC')
+ ->once()
+ ->andReturn(collect([
+ ['start' => $candidateStart, 'end' => $candidateEnd, 'capacity' => 200],
+ ]));
+
+ $existingOccurrence = $this->createOccurrenceDomainObject(
+ id: 5,
+ startDate: '2025-03-01 10:00:00',
+ endDate: '2025-03-01 11:00:00',
+ isOverridden: true,
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(collect([$existingOccurrence]));
+
+ $this->mockDbBatchQuery([5]);
+
+ $this->occurrenceRepository->shouldNotReceive('updateWhere');
+ $this->occurrenceRepository->shouldNotReceive('findById');
+
+ $result = $this->service->generate($event, $recurrenceRule);
+
+ $this->assertCount(1, $result);
+ $this->assertSame($existingOccurrence, $result->first());
+ }
+
+ private function createMockEvent(int $id = 1, ?string $timezone = 'UTC'): EventDomainObject
+ {
+ $mock = Mockery::mock(EventDomainObject::class);
+ $mock->shouldReceive('getId')->andReturn($id);
+ $mock->shouldReceive('getTimezone')->andReturn($timezone);
+
+ return $mock;
+ }
+
+ private function createOccurrenceDomainObject(
+ int $id,
+ string $startDate,
+ ?string $endDate = null,
+ bool $isOverridden = false,
+ ?int $capacity = null,
+ int $eventId = 1,
+ ): EventOccurrenceDomainObject {
+ $occ = new EventOccurrenceDomainObject();
+ $occ->setId($id);
+ $occ->setEventId($eventId);
+ $occ->setShortId('oc_test' . $id);
+ $occ->setStartDate($startDate);
+ $occ->setEndDate($endDate);
+ $occ->setIsOverridden($isOverridden);
+ $occ->setCapacity($capacity);
+
+ return $occ;
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/Event/EventStatsFetchServiceTest.php b/backend/tests/Unit/Services/Domain/Event/EventStatsFetchServiceTest.php
new file mode 100644
index 0000000000..bc86f48db6
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Event/EventStatsFetchServiceTest.php
@@ -0,0 +1,84 @@
+db = Mockery::mock(DatabaseManager::class);
+ $this->eventRepository = Mockery::mock(EventRepositoryInterface::class);
+ $this->service = new EventStatsFetchService($this->db, $this->eventRepository);
+ }
+
+ public function test_occurrence_scoped_stats_bind_event_id_with_occurrence_id(): void
+ {
+ $this->db
+ ->shouldReceive('selectOne')
+ ->once()
+ ->with(
+ Mockery::on(static fn (string $sql): bool => str_contains($sql, 'eos.event_occurrence_id = :occurrenceId')
+ && str_contains($sql, 'eos.event_id = :eventId')),
+ [
+ 'occurrenceId' => 200,
+ 'eventId' => 10,
+ ],
+ )
+ ->andReturn((object) [
+ 'total_products_sold' => 0,
+ 'total_orders' => 0,
+ 'total_gross_sales' => 0,
+ 'total_tax' => 0,
+ 'total_fees' => 0,
+ 'total_views' => 0,
+ 'total_refunded' => 0,
+ 'attendees_registered' => 0,
+ ]);
+
+ $this->db
+ ->shouldReceive('select')
+ ->once()
+ ->with(
+ Mockery::on(static fn (string $sql): bool => str_contains($sql, 'eods.event_occurrence_id = :occurrenceId')
+ && str_contains($sql, 'eods.event_id = :eventId')),
+ [
+ 'startDate' => '2026-01-01',
+ 'endDate' => '2026-01-31',
+ 'occurrenceId' => 200,
+ 'eventId' => 10,
+ ],
+ )
+ ->andReturn([]);
+
+ $response = $this->service->getEventStats(new EventStatsRequestDTO(
+ event_id: 10,
+ start_date: '2026-01-01',
+ end_date: '2026-01-31',
+ occurrence_id: 200,
+ ));
+
+ $this->assertSame(0, $response->total_orders);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/Event/RecurrenceRuleExclusionServiceTest.php b/backend/tests/Unit/Services/Domain/Event/RecurrenceRuleExclusionServiceTest.php
new file mode 100644
index 0000000000..19425574ac
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Event/RecurrenceRuleExclusionServiceTest.php
@@ -0,0 +1,233 @@
+eventRepository = Mockery::mock(EventRepositoryInterface::class);
+ $this->service = new RecurrenceRuleExclusionService($this->eventRepository);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ public function test_add_exclusions_appends_datetime_in_event_timezone(): void
+ {
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getType')->andReturn(EventType::RECURRING->name);
+ $event->shouldReceive('getTimezone')->andReturn('UTC');
+ $event->shouldReceive('getRecurrenceRule')->andReturn([
+ 'frequency' => 'weekly',
+ 'excluded_occurrences' => [],
+ ]);
+
+ $this->eventRepository->shouldReceive('findByIdLocked')->once()->with(1)->andReturn($event);
+ $this->eventRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(1, [
+ EventDomainObjectAbstract::RECURRENCE_RULE => [
+ 'frequency' => 'weekly',
+ 'excluded_occurrences' => ['2026-06-15 10:00'],
+ ],
+ ]);
+
+ $this->service->addExclusions(1, ['2026-06-15 10:00:00']);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_add_exclusions_does_nothing_when_event_is_not_recurring(): void
+ {
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getType')->andReturn(EventType::SINGLE->name);
+
+ $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event);
+ $this->eventRepository->shouldNotReceive('updateFromArray');
+
+ $this->service->addExclusions(1, ['2026-06-15 10:00:00']);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_add_exclusions_skips_duplicates_and_does_not_write_when_no_changes(): void
+ {
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getType')->andReturn(EventType::RECURRING->name);
+ $event->shouldReceive('getTimezone')->andReturn('UTC');
+ $event->shouldReceive('getRecurrenceRule')->andReturn([
+ 'excluded_occurrences' => ['2026-06-15 10:00'],
+ ]);
+
+ $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event);
+ $this->eventRepository->shouldNotReceive('updateFromArray');
+
+ $this->service->addExclusions(1, ['2026-06-15 10:00:00']);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_add_exclusions_appends_to_existing_list(): void
+ {
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getType')->andReturn(EventType::RECURRING->name);
+ $event->shouldReceive('getTimezone')->andReturn('UTC');
+ $event->shouldReceive('getRecurrenceRule')->andReturn([
+ 'excluded_occurrences' => ['2026-06-15 10:00'],
+ ]);
+
+ $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event);
+ $this->eventRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(1, Mockery::on(fn ($attrs) => $attrs[EventDomainObjectAbstract::RECURRENCE_RULE]['excluded_occurrences']
+ === ['2026-06-15 10:00', '2026-07-20 14:00']));
+
+ $this->service->addExclusions(1, ['2026-07-20 14:00:00']);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_add_exclusions_handles_recurrence_rule_as_json_string(): void
+ {
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getType')->andReturn(EventType::RECURRING->name);
+ $event->shouldReceive('getTimezone')->andReturn('UTC');
+ $event->shouldReceive('getRecurrenceRule')->andReturn(
+ json_encode(['frequency' => 'daily', 'excluded_occurrences' => []])
+ );
+
+ $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event);
+ $this->eventRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(1, Mockery::on(fn ($attrs) => $attrs[EventDomainObjectAbstract::RECURRENCE_RULE]['excluded_occurrences']
+ === ['2026-08-01 09:00']));
+
+ $this->service->addExclusions(1, ['2026-08-01 09:00:00']);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_add_exclusions_deduplicates_input(): void
+ {
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getType')->andReturn(EventType::RECURRING->name);
+ $event->shouldReceive('getTimezone')->andReturn('UTC');
+ $event->shouldReceive('getRecurrenceRule')->andReturn([
+ 'excluded_occurrences' => [],
+ ]);
+
+ $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event);
+ $this->eventRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(1, Mockery::on(fn ($attrs) => $attrs[EventDomainObjectAbstract::RECURRENCE_RULE]['excluded_occurrences']
+ === ['2026-06-15 10:00']));
+
+ $this->service->addExclusions(1, ['2026-06-15 10:00:00', '2026-06-15 10:00:00']);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_remove_exclusion_removes_from_excluded_dates(): void
+ {
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getType')->andReturn(EventType::RECURRING->name);
+ $event->shouldReceive('getTimezone')->andReturn('UTC');
+ $event->shouldReceive('getRecurrenceRule')->andReturn([
+ 'excluded_dates' => ['2026-05-15', '2026-06-01', '2026-07-04'],
+ ]);
+
+ $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event);
+ $this->eventRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(1, Mockery::on(static function (array $attrs): bool {
+ $rule = $attrs[EventDomainObjectAbstract::RECURRENCE_RULE] ?? null;
+
+ return is_array($rule)
+ && $rule['excluded_dates'] === ['2026-05-15', '2026-07-04'];
+ }));
+
+ $this->service->removeExclusion(1, '2026-06-01 10:00:00');
+
+ $this->assertTrue(true);
+ }
+
+ public function test_remove_exclusion_removes_from_excluded_occurrences(): void
+ {
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getType')->andReturn(EventType::RECURRING->name);
+ $event->shouldReceive('getTimezone')->andReturn('UTC');
+ $event->shouldReceive('getRecurrenceRule')->andReturn([
+ 'excluded_occurrences' => ['2026-06-01 10:00', '2026-07-20 14:00'],
+ ]);
+
+ $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event);
+ $this->eventRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(1, Mockery::on(static function (array $attrs): bool {
+ $rule = $attrs[EventDomainObjectAbstract::RECURRENCE_RULE] ?? null;
+
+ return is_array($rule)
+ && $rule['excluded_occurrences'] === ['2026-07-20 14:00'];
+ }));
+
+ $this->service->removeExclusion(1, '2026-06-01 10:00:00');
+
+ $this->assertTrue(true);
+ }
+
+ public function test_remove_exclusion_does_nothing_when_date_is_not_excluded(): void
+ {
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getType')->andReturn(EventType::RECURRING->name);
+ $event->shouldReceive('getTimezone')->andReturn('UTC');
+ $event->shouldReceive('getRecurrenceRule')->andReturn([
+ 'excluded_dates' => ['2026-05-15'],
+ 'excluded_occurrences' => ['2026-07-20 14:00'],
+ ]);
+
+ $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event);
+ $this->eventRepository->shouldNotReceive('updateFromArray');
+
+ $this->service->removeExclusion(1, '2026-06-01 10:00:00');
+
+ $this->assertTrue(true);
+ }
+
+ public function test_remove_exclusion_does_nothing_when_event_is_not_recurring(): void
+ {
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getType')->andReturn(EventType::SINGLE->name);
+
+ $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event);
+ $this->eventRepository->shouldNotReceive('updateFromArray');
+
+ $this->service->removeExclusion(1, '2026-06-01 10:00:00');
+
+ $this->assertTrue(true);
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/Event/RecurrenceRuleParserServiceTest.php b/backend/tests/Unit/Services/Domain/Event/RecurrenceRuleParserServiceTest.php
new file mode 100644
index 0000000000..c43842a8da
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Event/RecurrenceRuleParserServiceTest.php
@@ -0,0 +1,1472 @@
+service = new RecurrenceRuleParserService;
+ }
+
+ // ─── Daily Frequency ───────────────────────────────────────────────
+
+ public function test_daily_frequency_generates_correct_dates(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 5,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(5, $result);
+ $this->assertEquals('2025-03-01', $result[0]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-03-02', $result[1]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-03-03', $result[2]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-03-04', $result[3]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-03-05', $result[4]['start']->format('Y-m-d'));
+ }
+
+ public function test_daily_frequency_respects_interval(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 3,
+ 'times_of_day' => ['09:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 4,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(4, $result);
+ $this->assertEquals('2025-03-01', $result[0]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-03-04', $result[1]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-03-07', $result[2]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-03-10', $result[3]['start']->format('Y-m-d'));
+ }
+
+ // ─── Weekly Frequency ──────────────────────────────────────────────
+
+ public function test_weekly_frequency_generates_correct_dates(): void
+ {
+ $rule = [
+ 'frequency' => 'weekly',
+ 'interval' => 1,
+ 'days_of_week' => ['monday', 'wednesday', 'friday'],
+ 'times_of_day' => ['18:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 6,
+ 'start' => '2025-03-03', // Monday
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(6, $result);
+ $this->assertEquals('2025-03-03', $result[0]['start']->format('Y-m-d')); // Mon
+ $this->assertEquals('2025-03-05', $result[1]['start']->format('Y-m-d')); // Wed
+ $this->assertEquals('2025-03-07', $result[2]['start']->format('Y-m-d')); // Fri
+ $this->assertEquals('2025-03-10', $result[3]['start']->format('Y-m-d')); // Mon
+ $this->assertEquals('2025-03-12', $result[4]['start']->format('Y-m-d')); // Wed
+ $this->assertEquals('2025-03-14', $result[5]['start']->format('Y-m-d')); // Fri
+ }
+
+ public function test_weekly_frequency_with_specific_days_of_week(): void
+ {
+ $rule = [
+ 'frequency' => 'weekly',
+ 'interval' => 1,
+ 'days_of_week' => ['tuesday', 'thursday'],
+ 'times_of_day' => ['12:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 4,
+ 'start' => '2025-03-04', // Tuesday
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(4, $result);
+ $this->assertEquals('Tuesday', $result[0]['start']->format('l'));
+ $this->assertEquals('Thursday', $result[1]['start']->format('l'));
+ $this->assertEquals('Tuesday', $result[2]['start']->format('l'));
+ $this->assertEquals('Thursday', $result[3]['start']->format('l'));
+ }
+
+ public function test_weekly_frequency_every_two_weeks(): void
+ {
+ $rule = [
+ 'frequency' => 'weekly',
+ 'interval' => 2,
+ 'days_of_week' => ['monday'],
+ 'times_of_day' => ['10:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 3,
+ 'start' => '2025-03-03', // Monday
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(3, $result);
+ $this->assertEquals('2025-03-03', $result[0]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-03-17', $result[1]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-03-31', $result[2]['start']->format('Y-m-d'));
+ }
+
+ public function test_weekly_frequency_with_empty_days_of_week_returns_empty(): void
+ {
+ $rule = [
+ 'frequency' => 'weekly',
+ 'interval' => 1,
+ 'days_of_week' => [],
+ 'times_of_day' => ['10:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 5,
+ 'start' => '2025-03-03',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(0, $result);
+ }
+
+ // ─── Monthly by Day of Month ───────────────────────────────────────
+
+ public function test_monthly_by_day_of_month_generates_correct_dates(): void
+ {
+ $rule = [
+ 'frequency' => 'monthly',
+ 'interval' => 1,
+ 'monthly_pattern' => 'by_day_of_month',
+ 'days_of_month' => [15],
+ 'times_of_day' => ['14:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 4,
+ 'start' => '2025-01-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(4, $result);
+ $this->assertEquals('2025-01-15', $result[0]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-02-15', $result[1]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-03-15', $result[2]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-04-15', $result[3]['start']->format('Y-m-d'));
+ }
+
+ public function test_monthly_by_day_of_month_skips_days_that_dont_exist(): void
+ {
+ $rule = [
+ 'frequency' => 'monthly',
+ 'interval' => 1,
+ 'monthly_pattern' => 'by_day_of_month',
+ 'days_of_month' => [31],
+ 'times_of_day' => ['10:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 5,
+ 'start' => '2025-01-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $dates = $result->pluck('start')->map(fn ($d) => $d->format('Y-m-d'))->toArray();
+
+ $this->assertContains('2025-01-31', $dates);
+ $this->assertContains('2025-03-31', $dates);
+ // February has no 31st, so it should be skipped
+ $this->assertNotContains('2025-02-31', $dates);
+ }
+
+ public function test_monthly_by_day_of_month_with_multiple_days(): void
+ {
+ $rule = [
+ 'frequency' => 'monthly',
+ 'interval' => 1,
+ 'monthly_pattern' => 'by_day_of_month',
+ 'days_of_month' => [1, 15],
+ 'times_of_day' => ['10:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 4,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(4, $result);
+ $this->assertEquals('2025-03-01', $result[0]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-03-15', $result[1]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-04-01', $result[2]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-04-15', $result[3]['start']->format('Y-m-d'));
+ }
+
+ // ─── Monthly by Day of Week with Week Position ─────────────────────
+
+ public function test_monthly_by_day_of_week_first_monday(): void
+ {
+ $rule = [
+ 'frequency' => 'monthly',
+ 'interval' => 1,
+ 'monthly_pattern' => 'by_day_of_week',
+ 'day_of_week' => 'monday',
+ 'week_position' => 1,
+ 'times_of_day' => ['19:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 3,
+ 'start' => '2025-01-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(3, $result);
+ // First Monday of Jan 2025 = Jan 6
+ $this->assertEquals('2025-01-06', $result[0]['start']->format('Y-m-d'));
+ // First Monday of Feb 2025 = Feb 3
+ $this->assertEquals('2025-02-03', $result[1]['start']->format('Y-m-d'));
+ // First Monday of Mar 2025 = Mar 3
+ $this->assertEquals('2025-03-03', $result[2]['start']->format('Y-m-d'));
+
+ foreach ($result as $occurrence) {
+ $this->assertEquals('Monday', $occurrence['start']->format('l'));
+ }
+ }
+
+ public function test_monthly_by_day_of_week_last_friday(): void
+ {
+ $rule = [
+ 'frequency' => 'monthly',
+ 'interval' => 1,
+ 'monthly_pattern' => 'by_day_of_week',
+ 'day_of_week' => 'friday',
+ 'week_position' => -1,
+ 'times_of_day' => ['17:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 3,
+ 'start' => '2025-01-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(3, $result);
+ // Last Friday of Jan 2025 = Jan 31
+ $this->assertEquals('2025-01-31', $result[0]['start']->format('Y-m-d'));
+ // Last Friday of Feb 2025 = Feb 28
+ $this->assertEquals('2025-02-28', $result[1]['start']->format('Y-m-d'));
+ // Last Friday of Mar 2025 = Mar 28
+ $this->assertEquals('2025-03-28', $result[2]['start']->format('Y-m-d'));
+
+ foreach ($result as $occurrence) {
+ $this->assertEquals('Friday', $occurrence['start']->format('l'));
+ }
+ }
+
+ public function test_monthly_by_day_of_week_third_wednesday(): void
+ {
+ $rule = [
+ 'frequency' => 'monthly',
+ 'interval' => 1,
+ 'monthly_pattern' => 'by_day_of_week',
+ 'day_of_week' => 'wednesday',
+ 'week_position' => 3,
+ 'times_of_day' => ['10:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 3,
+ 'start' => '2025-01-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(3, $result);
+ // Third Wednesday of Jan 2025 = Jan 15
+ $this->assertEquals('2025-01-15', $result[0]['start']->format('Y-m-d'));
+ // Third Wednesday of Feb 2025 = Feb 19
+ $this->assertEquals('2025-02-19', $result[1]['start']->format('Y-m-d'));
+ // Third Wednesday of Mar 2025 = Mar 19
+ $this->assertEquals('2025-03-19', $result[2]['start']->format('Y-m-d'));
+
+ foreach ($result as $occurrence) {
+ $this->assertEquals('Wednesday', $occurrence['start']->format('l'));
+ }
+ }
+
+ // ─── Yearly Frequency ──────────────────────────────────────────────
+
+ public function test_yearly_frequency_generates_correct_dates(): void
+ {
+ $rule = [
+ 'frequency' => 'yearly',
+ 'interval' => 1,
+ 'times_of_day' => ['12:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 4,
+ 'start' => '2025-06-15',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(4, $result);
+ $this->assertEquals('2025-06-15', $result[0]['start']->format('Y-m-d'));
+ $this->assertEquals('2026-06-15', $result[1]['start']->format('Y-m-d'));
+ $this->assertEquals('2027-06-15', $result[2]['start']->format('Y-m-d'));
+ $this->assertEquals('2028-06-15', $result[3]['start']->format('Y-m-d'));
+ }
+
+ public function test_yearly_frequency_every_two_years(): void
+ {
+ $rule = [
+ 'frequency' => 'yearly',
+ 'interval' => 2,
+ 'times_of_day' => ['08:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 3,
+ 'start' => '2025-01-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(3, $result);
+ $this->assertEquals('2025-01-01', $result[0]['start']->format('Y-m-d'));
+ $this->assertEquals('2027-01-01', $result[1]['start']->format('Y-m-d'));
+ $this->assertEquals('2029-01-01', $result[2]['start']->format('Y-m-d'));
+ }
+
+ // ─── Interval ──────────────────────────────────────────────────────
+
+ public function test_every_three_months_interval(): void
+ {
+ $rule = [
+ 'frequency' => 'monthly',
+ 'interval' => 3,
+ 'monthly_pattern' => 'by_day_of_month',
+ 'days_of_month' => [1],
+ 'times_of_day' => ['09:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 4,
+ 'start' => '2025-01-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(4, $result);
+ $this->assertEquals('2025-01-01', $result[0]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-04-01', $result[1]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-07-01', $result[2]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-10-01', $result[3]['start']->format('Y-m-d'));
+ }
+
+ // ─── Times of Day ──────────────────────────────────────────────────
+
+ public function test_multiple_times_of_day_generates_multiple_occurrences(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['09:00', '14:00', '19:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 2,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ // 2 calendar dates * 3 times of day = 6 occurrences
+ $this->assertCount(6, $result);
+
+ // Day 1: three times
+ $this->assertEquals('2025-03-01 09:00', $result[0]['start']->format('Y-m-d H:i'));
+ $this->assertEquals('2025-03-01 14:00', $result[1]['start']->format('Y-m-d H:i'));
+ $this->assertEquals('2025-03-01 19:00', $result[2]['start']->format('Y-m-d H:i'));
+
+ // Day 2: three times
+ $this->assertEquals('2025-03-02 09:00', $result[3]['start']->format('Y-m-d H:i'));
+ $this->assertEquals('2025-03-02 14:00', $result[4]['start']->format('Y-m-d H:i'));
+ $this->assertEquals('2025-03-02 19:00', $result[5]['start']->format('Y-m-d H:i'));
+ }
+
+ public function test_times_of_day_defaults_to_midnight(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 2,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(2, $result);
+ $this->assertEquals('00:00', $result[0]['start']->format('H:i'));
+ $this->assertEquals('00:00', $result[1]['start']->format('H:i'));
+ }
+
+ // ─── Duration Minutes ──────────────────────────────────────────────
+
+ public function test_duration_minutes_sets_end_date(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00'],
+ 'duration_minutes' => 90,
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 2,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(2, $result);
+ $this->assertEquals('2025-03-01 10:00', $result[0]['start']->format('Y-m-d H:i'));
+ $this->assertEquals('2025-03-01 11:30', $result[0]['end']->format('Y-m-d H:i'));
+ $this->assertEquals('2025-03-02 10:00', $result[1]['start']->format('Y-m-d H:i'));
+ $this->assertEquals('2025-03-02 11:30', $result[1]['end']->format('Y-m-d H:i'));
+ }
+
+ public function test_no_duration_minutes_leaves_end_date_null(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 1,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(1, $result);
+ $this->assertNull($result[0]['end']);
+ }
+
+ // ─── Count Limit ───────────────────────────────────────────────────
+
+ public function test_count_limit_stops_after_n_occurrences(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 7,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(7, $result);
+ }
+
+ public function test_count_limit_with_multiple_times_per_day(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['09:00', '18:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 5,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ // range.count is the number of calendar dates. With 2 times/day, that yields 5 * 2 = 10 occurrences.
+ $this->assertCount(10, $result);
+ $this->assertEquals('2025-03-01 09:00', $result[0]['start']->format('Y-m-d H:i'));
+ $this->assertEquals('2025-03-01 18:00', $result[1]['start']->format('Y-m-d H:i'));
+ $this->assertEquals('2025-03-05 09:00', $result[8]['start']->format('Y-m-d H:i'));
+ $this->assertEquals('2025-03-05 18:00', $result[9]['start']->format('Y-m-d H:i'));
+ }
+
+ public function test_count_limit_with_multiple_times_per_day_weekly(): void
+ {
+ $rule = [
+ 'frequency' => 'weekly',
+ 'interval' => 1,
+ 'days_of_week' => ['monday', 'wednesday', 'friday'],
+ 'times_of_day' => ['09:00', '18:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 4,
+ 'start' => '2025-03-03', // Monday
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ // 4 calendar dates * 2 sessions per day = 8 occurrences total.
+ $this->assertCount(8, $result);
+ }
+
+ public function test_count_limit_with_multiple_times_per_day_monthly(): void
+ {
+ $rule = [
+ 'frequency' => 'monthly',
+ 'interval' => 1,
+ 'monthly_pattern' => 'by_day_of_month',
+ 'days_of_month' => [1, 15],
+ 'times_of_day' => ['09:00', '18:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 4,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ // 4 calendar dates * 2 sessions per day = 8 occurrences total.
+ $this->assertCount(8, $result);
+ }
+
+ public function test_default_count_is_ten_when_not_specified(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(10, $result);
+ }
+
+ // ─── Until Date ────────────────────────────────────────────────────
+
+ public function test_until_date_stops_at_specified_date(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00'],
+ 'range' => [
+ 'type' => 'until',
+ 'until' => '2025-03-05',
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(5, $result);
+ $this->assertEquals('2025-03-01', $result[0]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-03-05', $result[4]['start']->format('Y-m-d'));
+ }
+
+ public function test_until_date_with_weekly_frequency(): void
+ {
+ $rule = [
+ 'frequency' => 'weekly',
+ 'interval' => 1,
+ 'days_of_week' => ['wednesday'],
+ 'times_of_day' => ['15:00'],
+ 'range' => [
+ 'type' => 'until',
+ 'until' => '2025-03-20',
+ 'start' => '2025-03-05', // Wednesday
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(3, $result);
+ $this->assertEquals('2025-03-05', $result[0]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-03-12', $result[1]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-03-19', $result[2]['start']->format('Y-m-d'));
+ }
+
+ // ─── Excluded Dates ────────────────────────────────────────────────
+
+ public function test_excluded_dates_are_skipped(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00'],
+ 'excluded_dates' => ['2025-03-03', '2025-03-05'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 7,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $dates = $result->pluck('start')->map(fn ($d) => $d->format('Y-m-d'))->toArray();
+
+ $this->assertNotContains('2025-03-03', $dates);
+ $this->assertNotContains('2025-03-05', $dates);
+ $this->assertContains('2025-03-01', $dates);
+ $this->assertContains('2025-03-02', $dates);
+ $this->assertContains('2025-03-04', $dates);
+ }
+
+ public function test_excluded_dates_with_multiple_times_per_day(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['09:00', '18:00'],
+ 'excluded_dates' => ['2025-03-02'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 6,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $dates = $result->pluck('start')->map(fn ($d) => $d->format('Y-m-d'))->toArray();
+
+ $this->assertNotContains('2025-03-02', $dates);
+ }
+
+ public function test_excluded_occurrences_skip_only_matching_time_slot(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['09:00', '18:00'],
+ 'excluded_occurrences' => ['2025-03-02 09:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 6,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $starts = $result->pluck('start')->map(fn ($d) => $d->format('Y-m-d H:i'))->toArray();
+
+ $this->assertNotContains('2025-03-02 09:00', $starts);
+ $this->assertContains('2025-03-02 18:00', $starts);
+ }
+
+ // ─── Additional Dates ──────────────────────────────────────────────
+
+ public function test_additional_dates_are_included(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00'],
+ 'additional_dates' => [
+ ['date' => '2025-04-01', 'time' => '20:00'],
+ ['date' => '2025-05-15', 'time' => '11:00'],
+ ],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 2,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ // 2 from daily + 2 additional = 4
+ $this->assertCount(4, $result);
+
+ $dates = $result->pluck('start')->map(fn ($d) => $d->format('Y-m-d'))->toArray();
+ $this->assertContains('2025-04-01', $dates);
+ $this->assertContains('2025-05-15', $dates);
+ }
+
+ public function test_additional_dates_are_sorted_with_regular_dates(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00'],
+ 'additional_dates' => [
+ ['date' => '2025-03-02', 'time' => '08:00'],
+ ],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 3,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ // Result should be sorted by start time
+ for ($i = 1; $i < $result->count(); $i++) {
+ $this->assertTrue(
+ $result[$i]['start']->greaterThanOrEqualTo($result[$i - 1]['start']),
+ 'Results should be sorted by start date'
+ );
+ }
+ }
+
+ public function test_additional_dates_default_time_to_midnight(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00'],
+ 'additional_dates' => [
+ ['date' => '2025-06-01'],
+ ],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 1,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $additionalOccurrence = $result->first(fn ($o) => $o['start']->format('Y-m-d') === '2025-06-01');
+ $this->assertNotNull($additionalOccurrence);
+ $this->assertEquals('00:00', $additionalOccurrence['start']->format('H:i'));
+ }
+
+ // ─── DST Transition Handling ───────────────────────────────────────
+
+ public function test_dst_spring_forward_transition(): void
+ {
+ // 2025 DST spring forward in America/New_York: March 9 at 2:00 AM
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00'],
+ 'duration_minutes' => 60,
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 3,
+ 'start' => '2025-03-08',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'America/New_York');
+
+ $this->assertCount(3, $result);
+
+ // All start times should be at 10:00 local time, converted to UTC
+ // Before DST (EST = UTC-5): Mar 8 10:00 EST = 15:00 UTC
+ $this->assertEquals('15:00', $result[0]['start']->format('H:i'));
+
+ // After DST (EDT = UTC-4): Mar 9 10:00 EDT = 14:00 UTC
+ $this->assertEquals('14:00', $result[1]['start']->format('H:i'));
+
+ // After DST (EDT = UTC-4): Mar 10 10:00 EDT = 14:00 UTC
+ $this->assertEquals('14:00', $result[2]['start']->format('H:i'));
+ }
+
+ public function test_weekly_rule_keeps_local_time_across_dst_spring_forward(): void
+ {
+ // A weekly class at 10:00 on Wednesdays in America/New_York, spanning the
+ // 2025 spring-forward (March 9). The attendee should always see 10:00 local.
+ $rule = [
+ 'frequency' => 'weekly',
+ 'interval' => 1,
+ 'days_of_week' => ['wednesday'],
+ 'times_of_day' => ['10:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 3,
+ 'start' => '2025-03-05',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'America/New_York');
+
+ $this->assertCount(3, $result);
+
+ // Wed Mar 5 (EST, UTC-5): 10:00 local → 15:00 UTC
+ $this->assertSame('2025-03-05 15:00', $result[0]['start']->format('Y-m-d H:i'));
+ // Wed Mar 12 (EDT, UTC-4): 10:00 local → 14:00 UTC
+ $this->assertSame('2025-03-12 14:00', $result[1]['start']->format('Y-m-d H:i'));
+ // Wed Mar 19 (EDT, UTC-4): 10:00 local → 14:00 UTC
+ $this->assertSame('2025-03-19 14:00', $result[2]['start']->format('Y-m-d H:i'));
+
+ foreach ($result as $slot) {
+ $this->assertSame('10:00', $slot['start']->setTimezone('America/New_York')->format('H:i'));
+ }
+ }
+
+ public function test_monthly_rule_keeps_local_time_across_dst_fall_back(): void
+ {
+ // Monthly on the 1st at 09:00 America/New_York: spans fall-back (Nov 2 2025).
+ $rule = [
+ 'frequency' => 'monthly',
+ 'interval' => 1,
+ 'monthly_pattern' => 'by_day_of_month',
+ 'days_of_month' => [1],
+ 'times_of_day' => ['09:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 3,
+ 'start' => '2025-10-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'America/New_York');
+
+ $this->assertCount(3, $result);
+
+ foreach ($result as $slot) {
+ $this->assertSame('09:00', $slot['start']->setTimezone('America/New_York')->format('H:i'));
+ }
+
+ // Oct 1 before fall-back (EDT = UTC-4): 09:00 local → 13:00 UTC
+ $this->assertSame('13:00', $result[0]['start']->format('H:i'));
+ // Nov 1 still EDT (fall-back happens on Nov 2): 09:00 local → 13:00 UTC
+ $this->assertSame('13:00', $result[1]['start']->format('H:i'));
+ // Dec 1 after fall-back (EST = UTC-5): 09:00 local → 14:00 UTC
+ $this->assertSame('14:00', $result[2]['start']->format('H:i'));
+ }
+
+ public function test_daily_rule_at_skipped_hour_during_spring_forward_does_not_produce_invalid_time(): void
+ {
+ // In America/New_York on Mar 9 2025, 02:30 local time does not exist (clocks
+ // jump from 02:00 EST straight to 03:00 EDT). Parser should not emit an
+ // occurrence whose local time still reads 02:30 on the skipped day — Carbon
+ // rolls it forward to 03:30 EDT = 07:30 UTC.
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['02:30'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 3,
+ 'start' => '2025-03-08',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'America/New_York');
+
+ $this->assertCount(3, $result);
+
+ // Mar 8 (EST = UTC-5): 02:30 local → 07:30 UTC
+ $this->assertSame('2025-03-08 07:30', $result[0]['start']->format('Y-m-d H:i'));
+ // Mar 9 skipped hour: Carbon rolls the non-existent 02:30 forward to 03:30 EDT
+ // (the first valid minute after the clock jumps), which maps to 07:30 UTC. The
+ // concrete assertion catches regressions that would leave it at 02:30 EDT
+ // (06:30 UTC) or 02:30 EST (07:30 UTC but with wrong local reading).
+ $this->assertSame('2025-03-09 07:30', $result[1]['start']->format('Y-m-d H:i'));
+ $this->assertSame('03:30', $result[1]['start']->setTimezone('America/New_York')->format('H:i'));
+ // Mar 10 (EDT = UTC-4): 02:30 local → 06:30 UTC
+ $this->assertSame('2025-03-10 06:30', $result[2]['start']->format('Y-m-d H:i'));
+ }
+
+ public function test_dst_fall_back_transition(): void
+ {
+ // 2025 DST fall back in America/New_York: November 2 at 2:00 AM
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 3,
+ 'start' => '2025-11-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'America/New_York');
+
+ $this->assertCount(3, $result);
+
+ // Before DST ends (EDT = UTC-4): Nov 1 10:00 EDT = 14:00 UTC
+ $this->assertEquals('14:00', $result[0]['start']->format('H:i'));
+
+ // After DST ends (EST = UTC-5): Nov 2 10:00 EST = 15:00 UTC
+ $this->assertEquals('15:00', $result[1]['start']->format('H:i'));
+
+ // After DST ends (EST = UTC-5): Nov 3 10:00 EST = 15:00 UTC
+ $this->assertEquals('15:00', $result[2]['start']->format('H:i'));
+ }
+
+ // ─── Timezone Conversion ───────────────────────────────────────────
+
+ public function test_timezone_conversion_to_utc(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['20:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 1,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'Europe/Berlin');
+
+ // Europe/Berlin is UTC+1 in winter (CET)
+ // 20:00 CET = 19:00 UTC
+ $this->assertEquals('19:00', $result[0]['start']->format('H:i'));
+ $this->assertEquals('UTC', $result[0]['start']->timezone->getName());
+ }
+
+ // ─── Cap at 1200 Occurrences ───────────────────────────────────────
+
+ public function test_cap_at1200_occurrences(): void
+ {
+ // Parser is allowed to push one candidate beyond MAX so the handler's
+ // `count > MAX` overflow check can fire. The user-visible ceiling is
+ // still MAX — this just lets the overflow be detectable instead of
+ // silently truncated.
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00'],
+ 'range' => [
+ 'type' => 'until',
+ 'until' => '2030-01-01',
+ 'start' => '2025-01-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertLessThanOrEqual(\HiEvents\Services\Domain\Event\RecurrenceRuleParserService::MAX_OCCURRENCES + 1, $result->count());
+ }
+
+ public function test_overflow_pushes_one_extra_candidate_for_handler_detection(): void
+ {
+ // Daily 4 times/day across ~5 years = 7305 candidates. Parser must
+ // surface this as MAX+1 rather than silently capping at MAX so the
+ // handler can return a 422.
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['08:00', '12:00', '16:00', '20:00'],
+ 'range' => [
+ 'type' => 'until',
+ 'until' => '2030-01-01',
+ 'start' => '2025-01-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertSame(
+ \HiEvents\Services\Domain\Event\RecurrenceRuleParserService::MAX_OCCURRENCES + 1,
+ $result->count(),
+ );
+ }
+
+ // ─── Default Capacity ──────────────────────────────────────────────
+
+ public function test_default_capacity_is_included_in_results(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00'],
+ 'default_capacity' => 100,
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 2,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(2, $result);
+ $this->assertEquals(100, $result[0]['capacity']);
+ $this->assertEquals(100, $result[1]['capacity']);
+ }
+
+ public function test_default_capacity_is_null_when_not_specified(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 1,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertNull($result[0]['capacity']);
+ }
+
+ // ─── Unknown Frequency ─────────────────────────────────────────────
+
+ public function test_unknown_frequency_throws(): void
+ {
+ $rule = [
+ 'frequency' => 'unknown',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 5,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $this->expectException(\HiEvents\Exceptions\InvalidRecurrenceRuleException::class);
+ $this->expectExceptionMessage('Unsupported recurrence frequency');
+
+ $this->service->parse($rule, 'UTC');
+ }
+
+ // ─── Result Structure ──────────────────────────────────────────────
+
+ public function test_result_contains_expected_keys(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00'],
+ 'duration_minutes' => 60,
+ 'default_capacity' => 50,
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 1,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertArrayHasKey('start', $result[0]);
+ $this->assertArrayHasKey('end', $result[0]);
+ $this->assertArrayHasKey('capacity', $result[0]);
+ $this->assertInstanceOf(CarbonImmutable::class, $result[0]['start']);
+ $this->assertInstanceOf(CarbonImmutable::class, $result[0]['end']);
+ }
+
+ public function test_results_are_returned_as_collection(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 3,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result);
+ }
+
+ // ─── Edge Cases ────────────────────────────────────────────────────
+
+ public function test_additional_dates_respect_duration_minutes(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00'],
+ 'duration_minutes' => 120,
+ 'additional_dates' => [
+ ['date' => '2025-06-01', 'time' => '14:00'],
+ ],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 1,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $additionalOccurrence = $result->first(fn ($o) => $o['start']->format('Y-m-d') === '2025-06-01');
+ $this->assertNotNull($additionalOccurrence);
+ $this->assertEquals('14:00', $additionalOccurrence['start']->format('H:i'));
+ $this->assertEquals('16:00', $additionalOccurrence['end']->format('H:i'));
+ }
+
+ public function test_additional_dates_cap_at1200_total(): void
+ {
+ $additionalDates = [];
+ for ($i = 0; $i < 1500; $i++) {
+ $date = CarbonImmutable::parse('2030-01-01')->addDays($i);
+ $additionalDates[] = ['date' => $date->format('Y-m-d'), 'time' => '10:00'];
+ }
+
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00'],
+ 'additional_dates' => $additionalDates,
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 100,
+ 'start' => '2025-01-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertLessThanOrEqual(\HiEvents\Services\Domain\Event\RecurrenceRuleParserService::MAX_OCCURRENCES + 1, $result->count());
+ }
+
+ public function test_monthly_by_day_of_week_every_two_months(): void
+ {
+ $rule = [
+ 'frequency' => 'monthly',
+ 'interval' => 2,
+ 'monthly_pattern' => 'by_day_of_week',
+ 'day_of_week' => 'tuesday',
+ 'week_position' => 2,
+ 'times_of_day' => ['10:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 3,
+ 'start' => '2025-01-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(3, $result);
+ // Second Tuesday of Jan 2025 = Jan 14
+ $this->assertEquals('2025-01-14', $result[0]['start']->format('Y-m-d'));
+ // Second Tuesday of Mar 2025 = Mar 11
+ $this->assertEquals('2025-03-11', $result[1]['start']->format('Y-m-d'));
+ // Second Tuesday of May 2025 = May 13
+ $this->assertEquals('2025-05-13', $result[2]['start']->format('Y-m-d'));
+
+ foreach ($result as $occurrence) {
+ $this->assertEquals('Tuesday', $occurrence['start']->format('l'));
+ }
+ }
+
+ public function test_daily_with_excluded_dates_still_produces_correct_count(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00'],
+ 'excluded_dates' => ['2025-03-02', '2025-03-04'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 5,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ // The count controls how many dates are generated, not the final count after exclusions
+ // 5 dates generated (Mar 1-5), 2 excluded (Mar 2, 4), so 3 remain
+ $this->assertCount(3, $result);
+ $dates = $result->pluck('start')->map(fn ($d) => $d->format('Y-m-d'))->toArray();
+ $this->assertEquals(['2025-03-01', '2025-03-03', '2025-03-05'], $dates);
+ }
+
+ public function test_weekly_with_start_date_mid_week(): void
+ {
+ // Start date is a Thursday, but we want Monday and Friday events
+ $rule = [
+ 'frequency' => 'weekly',
+ 'interval' => 1,
+ 'days_of_week' => ['monday', 'friday'],
+ 'times_of_day' => ['10:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 4,
+ 'start' => '2025-03-06', // Thursday
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(4, $result);
+ // First occurrence should be Friday Mar 7 (first matching day on/after start)
+ $this->assertEquals('2025-03-07', $result[0]['start']->format('Y-m-d'));
+ $this->assertEquals('Friday', $result[0]['start']->format('l'));
+ }
+
+ // ─── Deduplication ─────────────────────────────────────────────────
+
+ public function test_duplicate_additional_date_is_deduped(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00'],
+ 'additional_dates' => [
+ // Same start time as the rule's first occurrence
+ ['date' => '2025-03-01', 'time' => '10:00'],
+ ],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 3,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(3, $result);
+ $startTimes = $result->pluck('start')
+ ->map(fn ($d) => $d->toDateTimeString())
+ ->toArray();
+ $this->assertEquals(count($startTimes), count(array_unique($startTimes)));
+ }
+
+ public function test_duplicate_time_slots_are_deduped(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00', '10:00', '14:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 2,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ // 2 dates * 2 unique times = 4 occurrences (the duplicate 10:00 is deduped)
+ $this->assertCount(4, $result);
+ $startTimes = $result->pluck('start')
+ ->map(fn ($d) => $d->toDateTimeString())
+ ->toArray();
+ $this->assertEquals(count($startTimes), count(array_unique($startTimes)));
+ }
+
+ // ─── Validation ────────────────────────────────────────────────────
+
+ public function test_invalid_time_of_day_string_throws(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['25:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 2,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $this->expectException(\HiEvents\Exceptions\InvalidRecurrenceRuleException::class);
+ $this->service->parse($rule, 'UTC');
+ }
+
+ public function test_invalid_time_of_day_object_throws(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => [['time' => '99:99']],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 1,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $this->expectException(\HiEvents\Exceptions\InvalidRecurrenceRuleException::class);
+ $this->service->parse($rule, 'UTC');
+ }
+
+ public function test_invalid_additional_date_time_throws(): void
+ {
+ $rule = [
+ 'frequency' => 'daily',
+ 'interval' => 1,
+ 'times_of_day' => ['10:00'],
+ 'additional_dates' => [
+ ['date' => '2025-04-01', 'time' => 'abc'],
+ ],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 1,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $this->expectException(\HiEvents\Exceptions\InvalidRecurrenceRuleException::class);
+ $this->service->parse($rule, 'UTC');
+ }
+
+ public function test_weekly_rule_with_sunday_produces_sunday_dates(): void
+ {
+ // Carbon::SUNDAY is 0; the previous bare ->filter() dropped that, so a
+ // weekly Sunday-only rule silently emitted zero occurrences.
+ $rule = [
+ 'frequency' => 'weekly',
+ 'interval' => 1,
+ 'days_of_week' => ['sunday'],
+ 'times_of_day' => ['10:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 3,
+ 'start' => '2025-03-03', // Monday
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(3, $result);
+ // First Sunday on/after Mon Mar 3 is Sun Mar 9.
+ $this->assertEquals('2025-03-09', $result[0]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-03-16', $result[1]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-03-23', $result[2]['start']->format('Y-m-d'));
+ foreach ($result as $occ) {
+ $this->assertEquals('Sunday', $occ['start']->format('l'));
+ }
+ }
+
+ public function test_weekly_rule_with_sunday_mixed_with_other_days(): void
+ {
+ // Sunday + Wednesday: ensure both are emitted and ordered correctly
+ // after the final sort.
+ $rule = [
+ 'frequency' => 'weekly',
+ 'interval' => 1,
+ 'days_of_week' => ['sunday', 'wednesday'],
+ 'times_of_day' => ['10:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 4,
+ 'start' => '2025-03-03', // Monday
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(4, $result);
+ // Wed Mar 5, Sun Mar 9, Wed Mar 12, Sun Mar 16
+ $this->assertEquals('2025-03-05', $result[0]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-03-09', $result[1]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-03-12', $result[2]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-03-16', $result[3]['start']->format('Y-m-d'));
+ }
+
+ public function test_monthly_by_day_of_week_first_sunday(): void
+ {
+ // The previous code compared dayOfWeekIso (Sun = 7) to Carbon::SUNDAY
+ // (= 0), so the inner walk-the-days loop never matched on a Sunday and
+ // looped indefinitely. dayOfWeek (0..6) lines up with the constant.
+ $rule = [
+ 'frequency' => 'monthly',
+ 'interval' => 1,
+ 'monthly_pattern' => 'by_day_of_week',
+ 'day_of_week' => 'sunday',
+ 'week_position' => 1,
+ 'times_of_day' => ['10:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 3,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(3, $result);
+ // First Sundays of Mar/Apr/May 2025: Mar 2, Apr 6, May 4.
+ $this->assertEquals('2025-03-02', $result[0]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-04-06', $result[1]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-05-04', $result[2]['start']->format('Y-m-d'));
+ foreach ($result as $occ) {
+ $this->assertEquals('Sunday', $occ['start']->format('l'));
+ }
+ }
+
+ public function test_monthly_by_day_of_week_last_sunday(): void
+ {
+ // Last-Sunday rule exercised the -1 branch of getNthDayOfWeekInMonth,
+ // which had the same dayOfWeekIso bug walking backwards from end of
+ // month.
+ $rule = [
+ 'frequency' => 'monthly',
+ 'interval' => 1,
+ 'monthly_pattern' => 'by_day_of_week',
+ 'day_of_week' => 'sunday',
+ 'week_position' => -1,
+ 'times_of_day' => ['10:00'],
+ 'range' => [
+ 'type' => 'count',
+ 'count' => 3,
+ 'start' => '2025-03-01',
+ ],
+ ];
+
+ $result = $this->service->parse($rule, 'UTC');
+
+ $this->assertCount(3, $result);
+ // Last Sundays of Mar/Apr/May 2025: Mar 30, Apr 27, May 25.
+ $this->assertEquals('2025-03-30', $result[0]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-04-27', $result[1]['start']->format('Y-m-d'));
+ $this->assertEquals('2025-05-25', $result[2]['start']->format('Y-m-d'));
+ foreach ($result as $occ) {
+ $this->assertEquals('Sunday', $occ['start']->format('l'));
+ }
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/EventOccurrence/CancelOccurrenceAttendeesServiceTest.php b/backend/tests/Unit/Services/Domain/EventOccurrence/CancelOccurrenceAttendeesServiceTest.php
new file mode 100644
index 0000000000..b6f53b868b
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/EventOccurrence/CancelOccurrenceAttendeesServiceTest.php
@@ -0,0 +1,300 @@
+attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class);
+ $this->productQuantityService = Mockery::mock(ProductQuantityUpdateService::class);
+ $this->domainEventDispatcherService = Mockery::mock(DomainEventDispatcherService::class);
+ $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class);
+ $this->statisticsCancellationService = Mockery::mock(EventStatisticsCancellationService::class);
+ $this->logger = Mockery::mock(LoggerInterface::class);
+
+ // Default the logger to a permissive spy — only the failure path test
+ // overrides it with a strict expectation.
+ $this->logger->shouldReceive('error')->zeroOrMoreTimes()->byDefault();
+
+ // Default the stats service / order repo to no-op (orderRepository
+ // returns no orders → no decrement calls). Tests that exercise the
+ // happy path provide explicit expectations that take precedence.
+ $this->orderRepository
+ ->shouldReceive('findWhereIn')
+ ->zeroOrMoreTimes()
+ ->andReturn(new Collection)
+ ->byDefault();
+ $this->statisticsCancellationService
+ ->shouldReceive('decrementForCancelledAttendee')
+ ->zeroOrMoreTimes()
+ ->byDefault();
+
+ $this->service = new CancelOccurrenceAttendeesService(
+ $this->attendeeRepository,
+ $this->productQuantityService,
+ $this->domainEventDispatcherService,
+ $this->orderRepository,
+ $this->statisticsCancellationService,
+ $this->logger,
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ public function test_cancels_active_attendees_and_decrements_quantities(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $attendeeA = $this->makeAttendee(id: 101, productId: 7, productPriceId: 70);
+ $attendeeB = $this->makeAttendee(id: 102, productId: 7, productPriceId: 70);
+ $attendeeC = $this->makeAttendee(id: 103, productId: 8, productPriceId: 80);
+
+ $this->attendeeRepository
+ ->shouldReceive('findWhere')
+ ->with([
+ AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrenceId,
+ [AttendeeDomainObjectAbstract::STATUS, 'in', [AttendeeStatus::ACTIVE->name, AttendeeStatus::AWAITING_PAYMENT->name]],
+ ])
+ ->andReturn(new Collection([$attendeeA, $attendeeB, $attendeeC]));
+
+ $this->attendeeRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ [AttendeeDomainObjectAbstract::STATUS => AttendeeStatus::CANCELLED->name],
+ [
+ AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrenceId,
+ [AttendeeDomainObjectAbstract::STATUS, 'in', [AttendeeStatus::ACTIVE->name, AttendeeStatus::AWAITING_PAYMENT->name]],
+ ],
+ );
+
+ // Price 70 × 2 + price 80 × 1 = two grouped decrement calls.
+ $this->productQuantityService
+ ->shouldReceive('decreaseQuantitySold')->once()->with(70, 2, $occurrenceId);
+ $this->productQuantityService
+ ->shouldReceive('decreaseQuantitySold')->once()->with(80, 1, $occurrenceId);
+
+ $this->domainEventDispatcherService
+ ->shouldReceive('dispatch')
+ ->times(3)
+ ->with(Mockery::on(fn (AttendeeEvent $e) => $e->type === DomainEventType::ATTENDEE_CANCELLED
+ && in_array($e->attendeeId, [101, 102, 103], true)));
+
+ $this->service->cancelForOccurrence($eventId, $occurrenceId);
+
+ Event::assertDispatched(
+ CapacityChangedEvent::class,
+ fn (CapacityChangedEvent $e) => $e->productId === 7 && $e->direction === CapacityChangeDirection::INCREASED,
+ );
+ Event::assertDispatched(
+ CapacityChangedEvent::class,
+ fn (CapacityChangedEvent $e) => $e->productId === 8 && $e->direction === CapacityChangeDirection::INCREASED,
+ );
+ }
+
+ public function test_skips_everything_when_no_cancellable_attendees(): void
+ {
+ $this->attendeeRepository
+ ->shouldReceive('findWhere')
+ ->andReturn(new Collection);
+
+ $this->attendeeRepository->shouldNotReceive('updateWhere');
+ $this->productQuantityService->shouldNotReceive('decreaseQuantitySold');
+ $this->domainEventDispatcherService->shouldNotReceive('dispatch');
+
+ $this->service->cancelForOccurrence(1, 10);
+
+ Event::assertNotDispatched(CapacityChangedEvent::class);
+ }
+
+ public function test_also_cancels_awaiting_payment_attendees(): void
+ {
+ // Offline-payment attendees occupy a seat but have status AWAITING_PAYMENT.
+ // They must be cancelled too when the occurrence is cancelled.
+ $attendee = $this->makeAttendee(id: 201, productId: 5, productPriceId: 50);
+
+ $this->attendeeRepository
+ ->shouldReceive('findWhere')
+ ->with(Mockery::on(function (array $where) {
+ $statusClause = $where[0] ?? null;
+
+ return is_array($statusClause)
+ && $statusClause[0] === AttendeeDomainObjectAbstract::STATUS
+ && $statusClause[1] === 'in'
+ && in_array(AttendeeStatus::AWAITING_PAYMENT->name, $statusClause[2], true);
+ }))
+ ->andReturn(new Collection([$attendee]));
+
+ $this->attendeeRepository->shouldReceive('updateWhere')->once();
+ $this->productQuantityService->shouldReceive('decreaseQuantitySold')->once();
+ $this->domainEventDispatcherService->shouldReceive('dispatch')->once();
+
+ $this->service->cancelForOccurrence(1, 10);
+
+ Event::assertDispatched(CapacityChangedEvent::class);
+ }
+
+ public function test_decrements_attendee_statistics_grouped_by_source_order(): void
+ {
+ // Regression: bulk occurrence cancel previously cancelled attendees +
+ // adjusted inventory but skipped the per-attendee statistics decrement
+ // PartialEditAttendeeHandler runs. The later refund flow's order-level
+ // decrement looked at currently-active attendees (zero) and decremented
+ // attendees_registered by zero — leaving stats inflated. We now group
+ // by source order and call the stats service once per order so daily
+ // statistics for the right order date are touched.
+ $eventId = 5;
+ $occurrenceId = 50;
+
+ // Two attendees on order 1000 (different products), one on order 1001.
+ $a1 = $this->makeAttendee(id: 301, productId: 1, productPriceId: 11, orderId: 1000);
+ $a2 = $this->makeAttendee(id: 302, productId: 2, productPriceId: 22, orderId: 1000);
+ $a3 = $this->makeAttendee(id: 303, productId: 1, productPriceId: 11, orderId: 1001);
+
+ $this->attendeeRepository
+ ->shouldReceive('findWhere')
+ ->andReturn(new Collection([$a1, $a2, $a3]));
+ $this->attendeeRepository->shouldReceive('updateWhere')->once();
+ $this->productQuantityService->shouldReceive('decreaseQuantitySold')->zeroOrMoreTimes();
+ $this->domainEventDispatcherService->shouldReceive('dispatch')->zeroOrMoreTimes();
+
+ $order1000 = $this->makeOrder(id: 1000, createdAt: '2026-01-15 09:00:00');
+ $order1001 = $this->makeOrder(id: 1001, createdAt: '2026-01-20 14:30:00');
+
+ // Order ids are unique-collected before the lookup so order doesn't
+ // matter — assert set membership.
+ $this->orderRepository
+ ->shouldReceive('findWhereIn')
+ ->once()
+ ->with('id', Mockery::on(function ($ids) {
+ if (! is_array($ids)) {
+ return false;
+ }
+ $sorted = $ids;
+ sort($sorted);
+
+ return $sorted === [1000, 1001];
+ }))
+ ->andReturn(new Collection([$order1000, $order1001]));
+
+ // Mockery's `with()` matches positionally — named-arg calls in the
+ // service translate to positional under the hood, so assert that way.
+ $this->statisticsCancellationService
+ ->shouldReceive('decrementForCancelledAttendee')
+ ->once()
+ ->with($eventId, '2026-01-15 09:00:00', 2, $occurrenceId);
+ $this->statisticsCancellationService
+ ->shouldReceive('decrementForCancelledAttendee')
+ ->once()
+ ->with($eventId, '2026-01-20 14:30:00', 1, $occurrenceId);
+
+ $this->service->cancelForOccurrence($eventId, $occurrenceId);
+
+ // Mockery::close() in tearDown verifies the call expectations above.
+ // Add a matching PHPUnit assertion so the test isn't marked risky.
+ $this->assertTrue(true);
+ }
+
+ public function test_logs_and_continues_when_statistics_decrement_throws(): void
+ {
+ // Stats reconciliation failures must not roll back the attendee cancel
+ // — attendees are already updated, inventory is already adjusted, and
+ // a stats discrepancy is recoverable. The service swallows the error
+ // and logs so on-call can spot drift.
+ $attendee = $this->makeAttendee(id: 401, productId: 1, productPriceId: 11, orderId: 5000);
+ $this->attendeeRepository
+ ->shouldReceive('findWhere')
+ ->andReturn(new Collection([$attendee]));
+ $this->attendeeRepository->shouldReceive('updateWhere')->once();
+ $this->productQuantityService->shouldReceive('decreaseQuantitySold')->once();
+ $this->domainEventDispatcherService->shouldReceive('dispatch')->once();
+
+ $order = $this->makeOrder(id: 5000);
+ $this->orderRepository
+ ->shouldReceive('findWhereIn')
+ ->andReturn(new Collection([$order]));
+
+ $this->statisticsCancellationService
+ ->shouldReceive('decrementForCancelledAttendee')
+ ->andThrow(new \RuntimeException('version mismatch'));
+
+ $this->logger
+ ->shouldReceive('error')
+ ->once()
+ ->with(
+ 'Failed to decrement attendee statistics during occurrence cancellation',
+ Mockery::on(fn (array $ctx) => ($ctx['order_id'] ?? null) === 5000),
+ );
+
+ $this->service->cancelForOccurrence(1, 10);
+
+ // Mockery verifies the logger + decrement expectations on close;
+ // pair with a PHPUnit assertion so the test isn't marked risky.
+ $this->assertTrue(true);
+ }
+
+ private function makeAttendee(int $id, int $productId, int $productPriceId, int $orderId = 1000): MockInterface
+ {
+ $attendee = Mockery::mock(AttendeeDomainObject::class);
+ $attendee->shouldReceive('getId')->andReturn($id);
+ $attendee->shouldReceive('getProductId')->andReturn($productId);
+ $attendee->shouldReceive('getProductPriceId')->andReturn($productPriceId);
+ $attendee->shouldReceive('getOrderId')->andReturn($orderId);
+
+ return $attendee;
+ }
+
+ private function makeOrder(int $id, string $createdAt = '2026-01-01 12:00:00'): MockInterface
+ {
+ $order = Mockery::mock(OrderDomainObject::class);
+ $order->shouldReceive('getId')->andReturn($id);
+ $order->shouldReceive('getCreatedAt')->andReturn($createdAt);
+
+ return $order;
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityServiceTest.php b/backend/tests/Unit/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityServiceTest.php
new file mode 100644
index 0000000000..be5778d7c8
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityServiceTest.php
@@ -0,0 +1,267 @@
+occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->orderItemRepository = Mockery::mock(OrderItemRepositoryInterface::class);
+ $this->visibilityRepository = Mockery::mock(ProductOccurrenceVisibilityRepositoryInterface::class);
+
+ $this->orderItemRepository
+ ->shouldReceive('getReservedQuantityForOccurrence')
+ ->byDefault()
+ ->andReturn(0);
+
+ $this->service = new OccurrencePurchaseEligibilityService(
+ $this->occurrenceRepository,
+ $this->orderItemRepository,
+ $this->visibilityRepository,
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ public function test_rejects_when_occurrence_not_found(): void
+ {
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn(null);
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('not found');
+
+ $this->service->assertOccurrencePurchasable(eventId: 1, occurrenceId: 99);
+ }
+
+ public function test_rejects_cancelled_occurrence(): void
+ {
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn($this->occurrence(EventOccurrenceStatus::CANCELLED->name));
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('cancelled');
+
+ $this->service->assertOccurrencePurchasable(eventId: 1, occurrenceId: 10);
+ }
+
+ public function test_rejects_past_occurrence(): void
+ {
+ // Public payload filters past occurrences out, but a stale client or a
+ // direct API caller could still post one — guard belongs at the
+ // eligibility chokepoint so public checkout, manual attendee creation
+ // and any future caller all inherit it.
+ $occurrence = $this->occurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
+ startDate: '2020-01-01 10:00:00',
+ );
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->andReturn($occurrence);
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('already ended');
+
+ $this->service->assertOccurrencePurchasable(eventId: 1, occurrenceId: 10);
+ }
+
+ public function test_rejects_past_occurrence_even_with_capacity_override(): void
+ {
+ // Organisers using the override flag to manually add an attendee should
+ // still not be able to issue tickets for a session that has ended —
+ // override only bypasses capacity, not time/status gates.
+ $occurrence = $this->occurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
+ startDate: '2020-01-01 10:00:00',
+ );
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->andReturn($occurrence);
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('already ended');
+
+ $this->service->assertOccurrencePurchasable(
+ eventId: 1,
+ occurrenceId: 10,
+ overrideCapacity: true,
+ );
+ }
+
+ public function test_rejects_sold_out_occurrence(): void
+ {
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn($this->occurrence(EventOccurrenceStatus::SOLD_OUT->name));
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('sold out');
+
+ $this->service->assertOccurrencePurchasable(eventId: 1, occurrenceId: 10);
+ }
+
+ public function test_rejects_when_capacity_exceeded(): void
+ {
+ // capacity 10, used 4, reserved 3 → available 3; request 5 → reject.
+ $occurrence = $this->occurrence(EventOccurrenceStatus::ACTIVE->name, capacity: 10, usedCapacity: 4);
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->andReturn($occurrence);
+ $this->orderItemRepository
+ ->shouldReceive('getReservedQuantityForOccurrence')
+ ->with(10)
+ ->andReturn(3);
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('capacity');
+
+ $this->service->assertOccurrencePurchasable(eventId: 1, occurrenceId: 10, additionalQuantity: 5);
+ }
+
+ public function test_allows_purchase_within_capacity(): void
+ {
+ $occurrence = $this->occurrence(EventOccurrenceStatus::ACTIVE->name, capacity: 10, usedCapacity: 4);
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->andReturn($occurrence);
+
+ $result = $this->service->assertOccurrencePurchasable(
+ eventId: 1,
+ occurrenceId: 10,
+ additionalQuantity: 3,
+ );
+
+ $this->assertSame($occurrence, $result);
+ }
+
+ public function test_override_capacity_bypasses_capacity_check_but_not_cancelled(): void
+ {
+ // Override means capacity is ignored, but cancelled still blocks — there's
+ // no point overriding into a cancelled occurrence.
+ $occurrence = $this->occurrence(EventOccurrenceStatus::CANCELLED->name, capacity: 1, usedCapacity: 0);
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->andReturn($occurrence);
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('cancelled');
+
+ $this->service->assertOccurrencePurchasable(
+ eventId: 1,
+ occurrenceId: 10,
+ additionalQuantity: 999,
+ overrideCapacity: true,
+ );
+ }
+
+ public function test_override_capacity_bypasses_sold_out_status(): void
+ {
+ // SOLD_OUT is a capacity-derived status — ProductQuantityUpdateService
+ // flips it once used_capacity hits capacity. The override flag is
+ // specifically for the "full occurrence, organiser still wants to add
+ // someone" case, so it has to bypass SOLD_OUT too.
+ $occurrence = $this->occurrence(EventOccurrenceStatus::SOLD_OUT->name, capacity: 10, usedCapacity: 10);
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->andReturn($occurrence);
+ $this->orderItemRepository->shouldNotReceive('getReservedQuantityForOccurrence');
+
+ $result = $this->service->assertOccurrencePurchasable(
+ eventId: 1,
+ occurrenceId: 10,
+ additionalQuantity: 1,
+ overrideCapacity: true,
+ );
+
+ $this->assertSame($occurrence, $result);
+ }
+
+ public function test_override_capacity_allows_exceeding_capacity_for_active_occurrence(): void
+ {
+ // capacity 1, request 50, with override: should pass.
+ $occurrence = $this->occurrence(EventOccurrenceStatus::ACTIVE->name, capacity: 1, usedCapacity: 5);
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->andReturn($occurrence);
+
+ // Capacity check is short-circuited so reserved-quantity lookup must
+ // never run — keeps the override path cheap.
+ $this->orderItemRepository->shouldNotReceive('getReservedQuantityForOccurrence');
+
+ $result = $this->service->assertOccurrencePurchasable(
+ eventId: 1,
+ occurrenceId: 10,
+ additionalQuantity: 50,
+ overrideCapacity: true,
+ );
+
+ $this->assertSame($occurrence, $result);
+ }
+
+ public function test_product_visibility_allows_all_when_no_rules_exist(): void
+ {
+ $this->visibilityRepository
+ ->shouldReceive('findWhereIn')
+ ->andReturn(collect());
+
+ // No exception means all products are allowed (default-visible).
+ $this->service->assertProductsVisibleOnOccurrence(occurrenceId: 10, productIds: [1, 2, 3]);
+ $this->assertTrue(true);
+ }
+
+ public function test_product_visibility_rejects_hidden_product(): void
+ {
+ $rule = (new ProductOccurrenceVisibilityDomainObject)
+ ->setEventOccurrenceId(10)
+ ->setProductId(1);
+
+ $this->visibilityRepository
+ ->shouldReceive('findWhereIn')
+ ->andReturn(collect([$rule]));
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('not available for this occurrence');
+
+ // Product 99 is not in the allow-list.
+ $this->service->assertProductsVisibleOnOccurrence(occurrenceId: 10, productIds: [1, 99]);
+ }
+
+ public function test_product_visibility_no_op_for_empty_product_list(): void
+ {
+ // Edge case: empty product list shouldn't even hit the repository.
+ $this->visibilityRepository->shouldNotReceive('findWhereIn');
+
+ $this->service->assertProductsVisibleOnOccurrence(occurrenceId: 10, productIds: []);
+ $this->assertTrue(true);
+ }
+
+ private function occurrence(
+ string $status,
+ ?int $capacity = null,
+ int $usedCapacity = 0,
+ string $startDate = '2099-06-15 10:00:00',
+ ): EventOccurrenceDomainObject {
+ return (new EventOccurrenceDomainObject)
+ ->setId(10)
+ ->setEventId(1)
+ ->setStatus($status)
+ ->setCapacity($capacity)
+ ->setUsedCapacity($usedCapacity)
+ ->setStartDate($startDate);
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsCancellationServiceTest.php b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsCancellationServiceTest.php
index 042b44af75..838e077d6e 100644
--- a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsCancellationServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsCancellationServiceTest.php
@@ -10,6 +10,8 @@
use HiEvents\DomainObjects\Status\AttendeeStatus;
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceDailyStatisticRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceStatisticRepositoryInterface;
use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Services\Domain\EventStatistics\EventStatisticsCancellationService;
@@ -38,6 +40,10 @@ protected function setUp(): void
$this->eventStatisticsRepository = Mockery::mock(EventStatisticRepositoryInterface::class);
$this->eventDailyStatisticRepository = Mockery::mock(EventDailyStatisticRepositoryInterface::class);
+ $eventOccurrenceStatisticRepository = Mockery::mock(EventOccurrenceStatisticRepositoryInterface::class);
+ $eventOccurrenceStatisticRepository->shouldReceive('findFirstWhere')->andReturnNull();
+ $eventOccurrenceDailyStatisticRepository = Mockery::mock(EventOccurrenceDailyStatisticRepositoryInterface::class);
+ $eventOccurrenceDailyStatisticRepository->shouldReceive('findFirstWhere')->andReturnNull();
$this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class);
$this->orderRepository = Mockery::mock(OrderRepositoryInterface::class);
$this->databaseManager = Mockery::mock(DatabaseManager::class);
@@ -47,6 +53,8 @@ protected function setUp(): void
$this->service = new EventStatisticsCancellationService(
$this->eventStatisticsRepository,
$this->eventDailyStatisticRepository,
+ $eventOccurrenceStatisticRepository,
+ $eventOccurrenceDailyStatisticRepository,
$this->attendeeRepository,
$this->orderRepository,
$this->logger,
@@ -64,9 +72,11 @@ public function testDecrementForCancelledOrderSuccess(): void
// Create mock order items
$ticketOrderItem1 = Mockery::mock(OrderItemDomainObject::class);
$ticketOrderItem1->shouldReceive('getQuantity')->andReturn(2);
+ $ticketOrderItem1->shouldReceive('getEventOccurrenceId')->andReturnNull();
$ticketOrderItem2 = Mockery::mock(OrderItemDomainObject::class);
$ticketOrderItem2->shouldReceive('getQuantity')->andReturn(1);
+ $ticketOrderItem2->shouldReceive('getEventOccurrenceId')->andReturnNull();
$orderItems = new Collection([$ticketOrderItem1, $ticketOrderItem2]);
$ticketOrderItems = new Collection([$ticketOrderItem1, $ticketOrderItem2]);
diff --git a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsIncrementServiceTest.php b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsIncrementServiceTest.php
index 107a1257e9..2255eb5fe1 100644
--- a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsIncrementServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsIncrementServiceTest.php
@@ -9,6 +9,8 @@
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceDailyStatisticRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceStatisticRepositoryInterface;
use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
@@ -42,6 +44,8 @@ protected function setUp(): void
$this->productRepository = Mockery::mock(ProductRepositoryInterface::class);
$this->eventStatisticsRepository = Mockery::mock(EventStatisticRepositoryInterface::class);
$this->eventDailyStatisticRepository = Mockery::mock(EventDailyStatisticRepositoryInterface::class);
+ $eventOccurrenceStatisticRepository = Mockery::mock(EventOccurrenceStatisticRepositoryInterface::class);
+ $eventOccurrenceDailyStatisticRepository = Mockery::mock(EventOccurrenceDailyStatisticRepositoryInterface::class);
$this->databaseManager = Mockery::mock(DatabaseManager::class);
$this->orderRepository = Mockery::mock(OrderRepositoryInterface::class);
$this->logger = Mockery::mock(LoggerInterface::class);
@@ -52,6 +56,8 @@ protected function setUp(): void
$this->productRepository,
$this->eventStatisticsRepository,
$this->eventDailyStatisticRepository,
+ $eventOccurrenceStatisticRepository,
+ $eventOccurrenceDailyStatisticRepository,
$this->databaseManager,
$this->orderRepository,
$this->logger,
@@ -71,11 +77,13 @@ public function testIncrementForOrderWithExistingStatistics(): void
$ticketOrderItem1->shouldReceive('getQuantity')->andReturn(2);
$ticketOrderItem1->shouldReceive('getProductId')->andReturn(1);
$ticketOrderItem1->shouldReceive('getTotalBeforeAdditions')->andReturn(100.00);
+ $ticketOrderItem1->shouldReceive('getEventOccurrenceId')->andReturnNull();
$ticketOrderItem2 = Mockery::mock(OrderItemDomainObject::class);
$ticketOrderItem2->shouldReceive('getQuantity')->andReturn(1);
$ticketOrderItem2->shouldReceive('getProductId')->andReturn(2);
$ticketOrderItem2->shouldReceive('getTotalBeforeAdditions')->andReturn(50.00);
+ $ticketOrderItem2->shouldReceive('getEventOccurrenceId')->andReturnNull();
$orderItems = new Collection([$ticketOrderItem1, $ticketOrderItem2]);
$ticketOrderItems = new Collection([$ticketOrderItem1, $ticketOrderItem2]);
@@ -241,6 +249,7 @@ public function testIncrementForOrderCreatesNewStatistics(): void
$orderItem->shouldReceive('getQuantity')->andReturn(2);
$orderItem->shouldReceive('getProductId')->andReturn(1);
$orderItem->shouldReceive('getTotalBeforeAdditions')->andReturn(100.00);
+ $orderItem->shouldReceive('getEventOccurrenceId')->andReturnNull();
$orderItems = new Collection([$orderItem]);
$ticketOrderItems = new Collection([$orderItem]);
diff --git a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsRefundServiceTest.php b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsRefundServiceTest.php
index 5e41a74713..361d9c6400 100644
--- a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsRefundServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsRefundServiceTest.php
@@ -5,10 +5,16 @@
use HiEvents\DomainObjects\EventDailyStatisticDomainObject;
use HiEvents\DomainObjects\EventStatisticDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
+use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceDailyStatisticRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceStatisticRepositoryInterface;
use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Services\Domain\EventStatistics\EventStatisticsRefundService;
use HiEvents\Values\MoneyValue;
+use Illuminate\Database\Query\Expression;
+use Illuminate\Support\Collection;
use Mockery;
use Mockery\MockInterface;
use Psr\Log\LoggerInterface;
@@ -20,6 +26,9 @@ class EventStatisticsRefundServiceTest extends TestCase
private EventStatisticsRefundService $service;
private MockInterface|EventStatisticRepositoryInterface $eventStatisticsRepository;
private MockInterface|EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository;
+ private MockInterface|EventOccurrenceStatisticRepositoryInterface $eventOccurrenceStatisticRepository;
+ private MockInterface|EventOccurrenceDailyStatisticRepositoryInterface $eventOccurrenceDailyStatisticRepository;
+ private MockInterface|OrderRepositoryInterface $orderRepository;
private MockInterface|LoggerInterface $logger;
protected function setUp(): void
@@ -28,15 +37,49 @@ protected function setUp(): void
$this->eventStatisticsRepository = Mockery::mock(EventStatisticRepositoryInterface::class);
$this->eventDailyStatisticRepository = Mockery::mock(EventDailyStatisticRepositoryInterface::class);
+ $this->eventOccurrenceStatisticRepository = Mockery::mock(EventOccurrenceStatisticRepositoryInterface::class);
+ $this->eventOccurrenceDailyStatisticRepository = Mockery::mock(EventOccurrenceDailyStatisticRepositoryInterface::class);
+ $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class);
$this->logger = Mockery::mock(LoggerInterface::class);
+ // Default: the order reload (eager-loading items for the occurrence path) returns
+ // an order with no occurrence items so the occurrence pass is skipped. Tests that
+ // exercise the occurrence path override this expectation.
+ $this->stubOrderReload(totalGross: 0.0, items: []);
+
$this->service = new EventStatisticsRefundService(
$this->eventStatisticsRepository,
$this->eventDailyStatisticRepository,
+ $this->eventOccurrenceStatisticRepository,
+ $this->eventOccurrenceDailyStatisticRepository,
+ $this->orderRepository,
$this->logger
);
}
+ /**
+ * Helper that stubs `orderRepository->loadRelation(...)->findById(...)` to return
+ * an OrderDomainObject pre-stocked with the given items + totalGross + createdAt.
+ *
+ * @param OrderItemDomainObject[] $items
+ */
+ private function stubOrderReload(
+ float $totalGross,
+ array $items,
+ string $createdAt = '2026-04-10 09:00:00',
+ ): MockInterface {
+ $reloaded = Mockery::mock(OrderDomainObject::class);
+ $reloaded->shouldReceive('getOrderItems')->andReturn(new Collection($items));
+ $reloaded->shouldReceive('getTotalGross')->andReturn($totalGross);
+ $reloaded->shouldReceive('getCreatedAt')->andReturn($createdAt);
+
+ $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->orderRepository->shouldReceive('findById')->andReturn($reloaded);
+
+ return $reloaded;
+ }
+
+
public function testUpdateForRefundFullAmount(): void
{
$eventId = 1;
@@ -44,7 +87,6 @@ public function testUpdateForRefundFullAmount(): void
$orderDate = '2024-01-15 10:30:00';
$currency = 'USD';
- // Create mock order
$order = Mockery::mock(OrderDomainObject::class);
$order->shouldReceive('getEventId')->andReturn($eventId);
$order->shouldReceive('getId')->andReturn($orderId);
@@ -54,44 +96,38 @@ public function testUpdateForRefundFullAmount(): void
$order->shouldReceive('getTotalTax')->andReturn(8.00);
$order->shouldReceive('getTotalFee')->andReturn(2.00);
- // Create refund amount (full refund)
$refundAmount = MoneyValue::fromFloat(100.00, $currency);
- // Mock aggregate event statistics
$eventStatistics = Mockery::mock(EventStatisticDomainObject::class);
$eventStatistics->shouldReceive('getSalesTotalGross')->andReturn(1000.00);
$eventStatistics->shouldReceive('getTotalRefunded')->andReturn(50.00);
$eventStatistics->shouldReceive('getTotalTax')->andReturn(80.00);
$eventStatistics->shouldReceive('getTotalFee')->andReturn(20.00);
- // Mock daily event statistics
$eventDailyStatistic = Mockery::mock(EventDailyStatisticDomainObject::class);
$eventDailyStatistic->shouldReceive('getSalesTotalGross')->andReturn(500.00);
$eventDailyStatistic->shouldReceive('getTotalRefunded')->andReturn(25.00);
$eventDailyStatistic->shouldReceive('getTotalTax')->andReturn(40.00);
$eventDailyStatistic->shouldReceive('getTotalFee')->andReturn(10.00);
- // Expect finding aggregate statistics
$this->eventStatisticsRepository
->shouldReceive('findFirstWhere')
->with(['event_id' => $eventId])
->andReturn($eventStatistics);
- // Expect updating aggregate statistics (full refund = 100% proportion)
$this->eventStatisticsRepository
->shouldReceive('updateWhere')
->with(
[
- 'sales_total_gross' => 900.00, // 1000 - 100
- 'total_refunded' => 150.00, // 50 + 100
- 'total_tax' => 72.00, // 80 - 8 (100% of order tax)
- 'total_fee' => 18.00, // 20 - 2 (100% of order fee)
+ 'sales_total_gross' => 900.00,
+ 'total_refunded' => 150.00,
+ 'total_tax' => 72.00,
+ 'total_fee' => 18.00,
],
['event_id' => $eventId]
)
->once();
- // Expect finding daily statistics
$this->eventDailyStatisticRepository
->shouldReceive('findFirstWhere')
->with([
@@ -100,15 +136,14 @@ public function testUpdateForRefundFullAmount(): void
])
->andReturn($eventDailyStatistic);
- // Expect updating daily statistics
$this->eventDailyStatisticRepository
->shouldReceive('updateWhere')
->with(
[
- 'sales_total_gross' => 400.00, // 500 - 100
- 'total_refunded' => 125.00, // 25 + 100
- 'total_tax' => 32.00, // 40 - 8
- 'total_fee' => 8.00, // 10 - 2
+ 'sales_total_gross' => 400.00,
+ 'total_refunded' => 125.00,
+ 'total_tax' => 32.00,
+ 'total_fee' => 8.00,
],
[
'event_id' => $eventId,
@@ -117,13 +152,16 @@ public function testUpdateForRefundFullAmount(): void
)
->once();
- // Expect logging
+ // Default setUp stubs the order reload to return totalGross=0 with no items, so
+ // the occurrence pass must be skipped entirely. Assert that — nothing here exercises
+ // the new B4 / B5 code paths.
+ $this->eventOccurrenceStatisticRepository->shouldNotReceive('updateWhere');
+ $this->eventOccurrenceDailyStatisticRepository->shouldNotReceive('updateWhere');
+
$this->logger->shouldReceive('info')->twice();
- // Execute
$this->service->updateForRefund($order, $refundAmount);
-
$this->assertTrue(true);
}
@@ -134,7 +172,6 @@ public function testUpdateForRefundPartialAmount(): void
$orderDate = '2024-01-15 10:30:00';
$currency = 'USD';
- // Create mock order
$order = Mockery::mock(OrderDomainObject::class);
$order->shouldReceive('getEventId')->andReturn($eventId);
$order->shouldReceive('getId')->andReturn($orderId);
@@ -144,44 +181,38 @@ public function testUpdateForRefundPartialAmount(): void
$order->shouldReceive('getTotalTax')->andReturn(8.00);
$order->shouldReceive('getTotalFee')->andReturn(2.00);
- // Create refund amount (50% partial refund)
$refundAmount = MoneyValue::fromFloat(50.00, $currency);
- // Mock aggregate event statistics
$eventStatistics = Mockery::mock(EventStatisticDomainObject::class);
$eventStatistics->shouldReceive('getSalesTotalGross')->andReturn(1000.00);
$eventStatistics->shouldReceive('getTotalRefunded')->andReturn(50.00);
$eventStatistics->shouldReceive('getTotalTax')->andReturn(80.00);
$eventStatistics->shouldReceive('getTotalFee')->andReturn(20.00);
- // Mock daily event statistics
$eventDailyStatistic = Mockery::mock(EventDailyStatisticDomainObject::class);
$eventDailyStatistic->shouldReceive('getSalesTotalGross')->andReturn(500.00);
$eventDailyStatistic->shouldReceive('getTotalRefunded')->andReturn(25.00);
$eventDailyStatistic->shouldReceive('getTotalTax')->andReturn(40.00);
$eventDailyStatistic->shouldReceive('getTotalFee')->andReturn(10.00);
- // Expect finding aggregate statistics
$this->eventStatisticsRepository
->shouldReceive('findFirstWhere')
->with(['event_id' => $eventId])
->andReturn($eventStatistics);
- // Expect updating aggregate statistics (50% refund = 0.5 proportion)
$this->eventStatisticsRepository
->shouldReceive('updateWhere')
->with(
[
- 'sales_total_gross' => 950.00, // 1000 - 50
- 'total_refunded' => 100.00, // 50 + 50
- 'total_tax' => 76.00, // 80 - 4 (50% of order tax)
- 'total_fee' => 19.00, // 20 - 1 (50% of order fee)
+ 'sales_total_gross' => 950.00,
+ 'total_refunded' => 100.00,
+ 'total_tax' => 76.00,
+ 'total_fee' => 19.00,
],
['event_id' => $eventId]
)
->once();
- // Expect finding daily statistics
$this->eventDailyStatisticRepository
->shouldReceive('findFirstWhere')
->with([
@@ -190,15 +221,14 @@ public function testUpdateForRefundPartialAmount(): void
])
->andReturn($eventDailyStatistic);
- // Expect updating daily statistics
$this->eventDailyStatisticRepository
->shouldReceive('updateWhere')
->with(
[
- 'sales_total_gross' => 450.00, // 500 - 50
- 'total_refunded' => 75.00, // 25 + 50
- 'total_tax' => 36.00, // 40 - 4
- 'total_fee' => 9.00, // 10 - 1
+ 'sales_total_gross' => 450.00,
+ 'total_refunded' => 75.00,
+ 'total_tax' => 36.00,
+ 'total_fee' => 9.00,
],
[
'event_id' => $eventId,
@@ -207,13 +237,13 @@ public function testUpdateForRefundPartialAmount(): void
)
->once();
- // Expect logging
+ $this->eventOccurrenceStatisticRepository->shouldNotReceive('updateWhere');
+ $this->eventOccurrenceDailyStatisticRepository->shouldNotReceive('updateWhere');
+
$this->logger->shouldReceive('info')->twice();
- // Execute
$this->service->updateForRefund($order, $refundAmount);
-
$this->assertTrue(true);
}
@@ -223,30 +253,22 @@ public function testThrowsExceptionWhenAggregateStatisticsNotFound(): void
$orderId = 123;
$currency = 'USD';
- // Create mock order
$order = Mockery::mock(OrderDomainObject::class);
$order->shouldReceive('getEventId')->andReturn($eventId);
$order->shouldReceive('getId')->andReturn($orderId);
$order->shouldReceive('getCurrency')->andReturn($currency);
- // Create refund amount
$refundAmount = MoneyValue::fromFloat(50.00, $currency);
- // Expect aggregate statistics not found
$this->eventStatisticsRepository
->shouldReceive('findFirstWhere')
->with(['event_id' => $eventId])
->andReturnNull();
- // Expect exception
$this->expectException(ResourceNotFoundException::class);
$this->expectExceptionMessage("Event statistics not found for event {$eventId}");
- // Execute
$this->service->updateForRefund($order, $refundAmount);
-
-
- $this->assertTrue(true);
}
public function testLogsWarningWhenDailyStatisticsNotFound(): void
@@ -256,7 +278,6 @@ public function testLogsWarningWhenDailyStatisticsNotFound(): void
$orderDate = '2024-01-15 10:30:00';
$currency = 'USD';
- // Create mock order
$order = Mockery::mock(OrderDomainObject::class);
$order->shouldReceive('getEventId')->andReturn($eventId);
$order->shouldReceive('getId')->andReturn($orderId);
@@ -266,28 +287,23 @@ public function testLogsWarningWhenDailyStatisticsNotFound(): void
$order->shouldReceive('getTotalTax')->andReturn(8.00);
$order->shouldReceive('getTotalFee')->andReturn(2.00);
- // Create refund amount
$refundAmount = MoneyValue::fromFloat(50.00, $currency);
- // Mock aggregate event statistics
$eventStatistics = Mockery::mock(EventStatisticDomainObject::class);
$eventStatistics->shouldReceive('getSalesTotalGross')->andReturn(1000.00);
$eventStatistics->shouldReceive('getTotalRefunded')->andReturn(50.00);
$eventStatistics->shouldReceive('getTotalTax')->andReturn(80.00);
$eventStatistics->shouldReceive('getTotalFee')->andReturn(20.00);
- // Expect finding aggregate statistics
$this->eventStatisticsRepository
->shouldReceive('findFirstWhere')
->with(['event_id' => $eventId])
->andReturn($eventStatistics);
- // Expect updating aggregate statistics
$this->eventStatisticsRepository
->shouldReceive('updateWhere')
->once();
- // Expect daily statistics not found
$this->eventDailyStatisticRepository
->shouldReceive('findFirstWhere')
->with([
@@ -296,7 +312,6 @@ public function testLogsWarningWhenDailyStatisticsNotFound(): void
])
->andReturnNull();
- // Expect warning log for missing daily statistics
$this->logger
->shouldReceive('warning')
->with(
@@ -309,19 +324,284 @@ public function testLogsWarningWhenDailyStatisticsNotFound(): void
)
->once();
- // Expect info log for aggregate update
$this->logger->shouldReceive('info')->once();
- // Should not attempt to update daily statistics
$this->eventDailyStatisticRepository->shouldNotReceive('updateWhere');
- // Execute
$this->service->updateForRefund($order, $refundAmount);
+ $this->assertTrue(true);
+ }
+
+ /**
+ * The order is loaded with order_items so the occurrence pass can run. Verify that:
+ * 1. The order reload happens exactly ONCE (perf fix — used to load twice).
+ * 2. updateWhere fires on both occurrence stats and occurrence daily stats.
+ * 3. The deltas are emitted as DB::raw atomic increments (not scalars).
+ * 4. The version column is bumped via raw SQL so optimistic readers see the change.
+ */
+ public function testUpdateForRefundUpdatesOccurrenceStatsForOrderWithItems(): void
+ {
+ $eventId = 1;
+ $orderId = 123;
+ $orderDate = '2024-01-15 10:30:00';
+ $currency = 'USD';
+
+ $order = $this->makeBaseOrderMock($eventId, $orderId, $orderDate, totalGross: 100.00);
+ $refundAmount = MoneyValue::fromFloat(100.00, $currency);
+
+ // Order reload returns one item on occurrence 50.
+ $item = $this->makeOrderItemMock(
+ occurrenceId: 50,
+ totalGross: 100.00,
+ totalTax: 8.00,
+ totalServiceFee: 2.00,
+ );
+
+ // Override the default no-items reload — and assert it happens exactly once
+ // across the whole flow (regression guard for the perf duplication fix).
+ $reloaded = Mockery::mock(OrderDomainObject::class);
+ $reloaded->shouldReceive('getOrderItems')->andReturn(new Collection([$item]));
+ $reloaded->shouldReceive('getTotalGross')->andReturn(100.00);
+ $reloaded->shouldReceive('getCreatedAt')->andReturn($orderDate);
+
+ $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class);
+ $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->orderRepository->shouldReceive('findById')->with($orderId)->once()->andReturn($reloaded);
+
+ $this->service = new EventStatisticsRefundService(
+ $this->eventStatisticsRepository,
+ $this->eventDailyStatisticRepository,
+ $this->eventOccurrenceStatisticRepository,
+ $this->eventOccurrenceDailyStatisticRepository,
+ $this->orderRepository,
+ $this->logger
+ );
+
+ $this->stubAggregateAndDailyPaths($eventId);
+
+ $this->eventOccurrenceStatisticRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ Mockery::on(fn(array $attrs) =>
+ $this->isRawIncrement($attrs['sales_total_gross'] ?? null, 'sales_total_gross', '-')
+ && $this->isRawIncrement($attrs['total_refunded'] ?? null, 'total_refunded', '+')
+ && $this->isRawIncrement($attrs['total_tax'] ?? null, 'total_tax', '-')
+ && $this->isRawIncrement($attrs['total_fee'] ?? null, 'total_fee', '-')
+ && $this->isVersionBump($attrs['version'] ?? null)
+ ),
+ ['event_occurrence_id' => 50]
+ );
+
+ $this->eventOccurrenceDailyStatisticRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ Mockery::on(fn(array $attrs) =>
+ $this->isRawIncrement($attrs['sales_total_gross'] ?? null, 'sales_total_gross', '-')
+ && $this->isRawIncrement($attrs['total_refunded'] ?? null, 'total_refunded', '+')
+ && $this->isVersionBump($attrs['version'] ?? null)
+ ),
+ ['event_occurrence_id' => 50, 'date' => '2024-01-15']
+ );
+
+ $this->logger->shouldReceive('info')->twice();
+
+ $this->service->updateForRefund($order, $refundAmount);
$this->assertTrue(true);
}
+ /**
+ * An order with items split across two different occurrences must produce one
+ * updateWhere call per occurrence on each stats repository (4 calls total).
+ */
+ public function testUpdateForRefundSplitsRefundAcrossMultipleOccurrences(): void
+ {
+ $eventId = 1;
+ $orderId = 200;
+ $orderDate = '2024-02-20 14:00:00';
+ $currency = 'USD';
+
+ $order = $this->makeBaseOrderMock($eventId, $orderId, $orderDate, totalGross: 200.00);
+ $refundAmount = MoneyValue::fromFloat(200.00, $currency);
+
+ // 60% of the order belongs to occurrence 100, 40% to occurrence 200.
+ $itemA = $this->makeOrderItemMock(occurrenceId: 100, totalGross: 120.00, totalTax: 10.00, totalServiceFee: 2.00);
+ $itemB = $this->makeOrderItemMock(occurrenceId: 200, totalGross: 80.00, totalTax: 6.00, totalServiceFee: 2.00);
+
+ $reloaded = Mockery::mock(OrderDomainObject::class);
+ $reloaded->shouldReceive('getOrderItems')->andReturn(new Collection([$itemA, $itemB]));
+ $reloaded->shouldReceive('getTotalGross')->andReturn(200.00);
+ $reloaded->shouldReceive('getCreatedAt')->andReturn($orderDate);
+
+ $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class);
+ $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->orderRepository->shouldReceive('findById')->with($orderId)->once()->andReturn($reloaded);
+
+ $this->service = new EventStatisticsRefundService(
+ $this->eventStatisticsRepository,
+ $this->eventDailyStatisticRepository,
+ $this->eventOccurrenceStatisticRepository,
+ $this->eventOccurrenceDailyStatisticRepository,
+ $this->orderRepository,
+ $this->logger
+ );
+
+ $this->stubAggregateAndDailyPaths($eventId);
+
+ // Expect one updateWhere per occurrence on each occurrence-stats repo. Order
+ // shouldn't matter (PHP foreach over the items map preserves insertion order).
+ $this->eventOccurrenceStatisticRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(Mockery::any(), ['event_occurrence_id' => 100]);
+
+ $this->eventOccurrenceStatisticRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(Mockery::any(), ['event_occurrence_id' => 200]);
+
+ $this->eventOccurrenceDailyStatisticRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(Mockery::any(), ['event_occurrence_id' => 100, 'date' => '2024-02-20']);
+
+ $this->eventOccurrenceDailyStatisticRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(Mockery::any(), ['event_occurrence_id' => 200, 'date' => '2024-02-20']);
+
+ $this->logger->shouldReceive('info')->twice();
+
+ $this->service->updateForRefund($order, $refundAmount);
+
+ $this->assertTrue(true);
+ }
+
+ /**
+ * Order items without an event_occurrence_id (legacy / non-recurring orders) must
+ * not trigger any occurrence-stats updates.
+ */
+ public function testUpdateForRefundSkipsOccurrencePathWhenNoItemsHaveOccurrenceId(): void
+ {
+ $eventId = 1;
+ $orderId = 300;
+ $orderDate = '2024-03-10 12:00:00';
+ $currency = 'USD';
+
+ $order = $this->makeBaseOrderMock($eventId, $orderId, $orderDate, totalGross: 50.00);
+ $refundAmount = MoneyValue::fromFloat(50.00, $currency);
+
+ $itemWithoutOccurrence = $this->makeOrderItemMock(
+ occurrenceId: null,
+ totalGross: 50.00,
+ totalTax: 4.00,
+ totalServiceFee: 1.00,
+ );
+
+ $reloaded = Mockery::mock(OrderDomainObject::class);
+ $reloaded->shouldReceive('getOrderItems')->andReturn(new Collection([$itemWithoutOccurrence]));
+ $reloaded->shouldReceive('getTotalGross')->andReturn(50.00);
+ $reloaded->shouldReceive('getCreatedAt')->andReturn($orderDate);
+
+ $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class);
+ $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->orderRepository->shouldReceive('findById')->with($orderId)->once()->andReturn($reloaded);
+
+ $this->service = new EventStatisticsRefundService(
+ $this->eventStatisticsRepository,
+ $this->eventDailyStatisticRepository,
+ $this->eventOccurrenceStatisticRepository,
+ $this->eventOccurrenceDailyStatisticRepository,
+ $this->orderRepository,
+ $this->logger
+ );
+
+ $this->stubAggregateAndDailyPaths($eventId);
+
+ $this->eventOccurrenceStatisticRepository->shouldNotReceive('updateWhere');
+ $this->eventOccurrenceDailyStatisticRepository->shouldNotReceive('updateWhere');
+
+ $this->logger->shouldReceive('info')->twice();
+
+ $this->service->updateForRefund($order, $refundAmount);
+
+ $this->assertTrue(true);
+ }
+
+ /**
+ * Stubs the aggregate + daily stats lookups + updateWhere calls so the test can
+ * focus on the occurrence path. Returns nothing — sets up Mockery expectations.
+ */
+ private function stubAggregateAndDailyPaths(int $eventId): void
+ {
+ $eventStatistics = Mockery::mock(EventStatisticDomainObject::class);
+ $eventStatistics->shouldReceive('getSalesTotalGross')->andReturn(1000.00);
+ $eventStatistics->shouldReceive('getTotalRefunded')->andReturn(0.0);
+ $eventStatistics->shouldReceive('getTotalTax')->andReturn(80.00);
+ $eventStatistics->shouldReceive('getTotalFee')->andReturn(20.00);
+
+ $eventDailyStatistic = Mockery::mock(EventDailyStatisticDomainObject::class);
+ $eventDailyStatistic->shouldReceive('getSalesTotalGross')->andReturn(500.00);
+ $eventDailyStatistic->shouldReceive('getTotalRefunded')->andReturn(0.0);
+ $eventDailyStatistic->shouldReceive('getTotalTax')->andReturn(40.00);
+ $eventDailyStatistic->shouldReceive('getTotalFee')->andReturn(10.00);
+
+ $this->eventStatisticsRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['event_id' => $eventId])
+ ->andReturn($eventStatistics);
+ $this->eventStatisticsRepository->shouldReceive('updateWhere')->once();
+
+ $this->eventDailyStatisticRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn($eventDailyStatistic);
+ $this->eventDailyStatisticRepository->shouldReceive('updateWhere')->once();
+ }
+
+ private function makeBaseOrderMock(int $eventId, int $orderId, string $orderDate, float $totalGross): MockInterface
+ {
+ $order = Mockery::mock(OrderDomainObject::class);
+ $order->shouldReceive('getEventId')->andReturn($eventId);
+ $order->shouldReceive('getId')->andReturn($orderId);
+ $order->shouldReceive('getCreatedAt')->andReturn($orderDate);
+ $order->shouldReceive('getCurrency')->andReturn('USD');
+ $order->shouldReceive('getTotalGross')->andReturn($totalGross);
+ $order->shouldReceive('getTotalTax')->andReturn(0.0);
+ $order->shouldReceive('getTotalFee')->andReturn(0.0);
+ return $order;
+ }
+
+ private function makeOrderItemMock(?int $occurrenceId, float $totalGross, float $totalTax, float $totalServiceFee): MockInterface
+ {
+ $item = Mockery::mock(OrderItemDomainObject::class);
+ $item->shouldReceive('getEventOccurrenceId')->andReturn($occurrenceId);
+ $item->shouldReceive('getTotalGross')->andReturn($totalGross);
+ $item->shouldReceive('getTotalTax')->andReturn($totalTax);
+ $item->shouldReceive('getTotalServiceFee')->andReturn($totalServiceFee);
+ return $item;
+ }
+
+ private function isRawIncrement(mixed $value, string $column, string $op): bool
+ {
+ if (!$value instanceof Expression) {
+ return false;
+ }
+ $sql = (string) $value->getValue(\DB::connection()->getQueryGrammar());
+ return str_contains($sql, $column) && str_contains($sql, $op);
+ }
+
+ private function isVersionBump(mixed $value): bool
+ {
+ if (!$value instanceof Expression) {
+ return false;
+ }
+ $sql = (string) $value->getValue(\DB::connection()->getQueryGrammar());
+ return $sql === 'version + 1';
+ }
+
protected function tearDown(): void
{
Mockery::close();
diff --git a/backend/tests/Unit/Services/Domain/Order/OrderApplicationFeeCalculationServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderApplicationFeeCalculationServiceTest.php
index 2566caebbb..ea24517227 100644
--- a/backend/tests/Unit/Services/Domain/Order/OrderApplicationFeeCalculationServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Order/OrderApplicationFeeCalculationServiceTest.php
@@ -2,7 +2,7 @@
namespace Tests\Unit\Services\Domain\Order;
-use HiEvents\DomainObjects\AccountConfigurationDomainObject;
+use HiEvents\DomainObjects\OrganizerConfigurationDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\Services\Domain\Order\OrderApplicationFeeCalculationService;
@@ -52,9 +52,9 @@ private function createItem(float $price, int $quantity): OrderItemDomainObject
return $item;
}
- private function createAccountConfig(float $fixedFee = 0, float $percentageFee = 0, string $currency = 'USD'): AccountConfigurationDomainObject
+ private function createAccountConfig(float $fixedFee = 0, float $percentageFee = 0, string $currency = 'USD'): OrganizerConfigurationDomainObject
{
- $config = $this->getMockBuilder(AccountConfigurationDomainObject::class)
+ $config = $this->getMockBuilder(OrganizerConfigurationDomainObject::class)
->disableOriginalConstructor()
->onlyMethods(['getFixedApplicationFee', 'getPercentageApplicationFee', 'getApplicationFeeCurrency'])
->getMock();
diff --git a/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php
index 61a44df095..f73de994cb 100644
--- a/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php
@@ -77,12 +77,14 @@ public function testCancelOrder(): void
$order->shouldReceive('getLocale')->andReturn('en');
$attendee1 = m::mock(AttendeeDomainObject::class);
- $attendee1->shouldReceive('getproductPriceId')->andReturn(1);
+ $attendee1->shouldReceive('getProductPriceId')->andReturn(1);
$attendee1->shouldReceive('getProductId')->andReturn(10);
+ $attendee1->shouldReceive('getEventOccurrenceId')->andReturn(1);
$attendee2 = m::mock(AttendeeDomainObject::class);
- $attendee2->shouldReceive('getproductPriceId')->andReturn(2);
+ $attendee2->shouldReceive('getProductPriceId')->andReturn(2);
$attendee2->shouldReceive('getProductId')->andReturn(20);
+ $attendee2->shouldReceive('getEventOccurrenceId')->andReturn(1);
$attendees = new Collection([$attendee1, $attendee2]);
@@ -168,12 +170,14 @@ public function testCancelOrderAwaitingOfflinePayment(): void
$order->shouldReceive('getLocale')->andReturn('en');
$attendee1 = m::mock(AttendeeDomainObject::class);
- $attendee1->shouldReceive('getproductPriceId')->andReturn(1);
+ $attendee1->shouldReceive('getProductPriceId')->andReturn(1);
$attendee1->shouldReceive('getProductId')->andReturn(10);
+ $attendee1->shouldReceive('getEventOccurrenceId')->andReturn(1);
$attendee2 = m::mock(AttendeeDomainObject::class);
- $attendee2->shouldReceive('getproductPriceId')->andReturn(2);
+ $attendee2->shouldReceive('getProductPriceId')->andReturn(2);
$attendee2->shouldReceive('getProductId')->andReturn(20);
+ $attendee2->shouldReceive('getEventOccurrenceId')->andReturn(1);
$attendees = new Collection([$attendee1, $attendee2]);
diff --git a/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php
index 75fe3c4306..91af0cac10 100644
--- a/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php
@@ -2,14 +2,19 @@
namespace Tests\Unit\Services\Domain\Order;
-use HiEvents\DomainObjects\Enums\ProductPriceType;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
+use HiEvents\DomainObjects\ProductOccurrenceVisibilityDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
-use HiEvents\DomainObjects\Status\EventStatus;
+use HiEvents\DomainObjects\Status\EventOccurrenceStatus;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
-use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrderItemRepositoryInterface;
+use HiEvents\Repository\Interfaces\ProductOccurrenceVisibilityRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
+use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface;
+use HiEvents\Services\Domain\EventOccurrence\OccurrencePurchaseEligibilityService;
use HiEvents\Services\Domain\Order\OrderCreateRequestValidationService;
use HiEvents\Services\Domain\Product\AvailableProductQuantitiesFetchService;
use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesDTO;
@@ -23,9 +28,19 @@
class OrderCreateRequestValidationServiceTest extends TestCase
{
private ProductRepositoryInterface|MockInterface $productRepository;
+
private PromoCodeRepositoryInterface|MockInterface $promoCodeRepository;
+
private EventRepositoryInterface|MockInterface $eventRepository;
+
private AvailableProductQuantitiesFetchService|MockInterface $availabilityService;
+
+ private EventOccurrenceRepositoryInterface|MockInterface $occurrenceRepository;
+
+ private ProductOccurrenceVisibilityRepositoryInterface|MockInterface $visibilityRepository;
+
+ private OrderItemRepositoryInterface|MockInterface $orderItemRepository;
+
private OrderCreateRequestValidationService $service;
protected function setUp(): void
@@ -36,175 +51,462 @@ protected function setUp(): void
$this->promoCodeRepository = Mockery::mock(PromoCodeRepositoryInterface::class);
$this->eventRepository = Mockery::mock(EventRepositoryInterface::class);
$this->availabilityService = Mockery::mock(AvailableProductQuantitiesFetchService::class);
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->visibilityRepository = Mockery::mock(ProductOccurrenceVisibilityRepositoryInterface::class);
+ $this->orderItemRepository = Mockery::mock(OrderItemRepositoryInterface::class);
+
+ // Default: no visibility rules → all products visible. Individual tests can
+ // override this expectation when they want to exercise the visibility check.
+ $this->visibilityRepository
+ ->shouldReceive('findWhereIn')
+ ->byDefault()
+ ->andReturn(collect());
+
+ // Default: no reserved orders. Tests that exercise capacity-vs-reservation
+ // logic can override this expectation.
+ $this->orderItemRepository
+ ->shouldReceive('getReservedQuantityForOccurrence')
+ ->byDefault()
+ ->andReturn(0);
+
+ // Build the real eligibility service from the same mocked repositories
+ // — keeps the existing tests as integration-style verification that the
+ // validator + eligibility service compose correctly without doubling up
+ // on Mockery setup.
+ $eligibilityService = new OccurrencePurchaseEligibilityService(
+ $this->occurrenceRepository,
+ $this->orderItemRepository,
+ $this->visibilityRepository,
+ );
$this->service = new OrderCreateRequestValidationService(
$this->productRepository,
$this->promoCodeRepository,
$this->eventRepository,
+ $this->occurrenceRepository,
$this->availabilityService,
+ $eligibilityService,
);
}
- protected function tearDown(): void
+ public function test_rejects_cancelled_occurrence(): void
{
- Mockery::close();
- parent::tearDown();
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('cancelled');
+
+ $occurrence = $this->createOccurrence(
+ status: EventOccurrenceStatus::CANCELLED->name,
+ );
+
+ $this->setupOccurrenceLookup(1, 10, $occurrence);
+ $this->setupEventLookup(1);
+
+ $this->service->validateRequestData(1, $this->createRequestData(10));
}
- public function testZeroQuantityTiersAreSkippedDuringValidation(): void
+ public function test_rejects_sold_out_occurrence(): void
{
- $eventId = 1;
- $productId = 10;
- $selectedPriceId = 101;
- $unselectedPriceId = 102;
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('sold out');
- $this->setupMocks(
- eventId: $eventId,
- productId: $productId,
- priceIds: [$selectedPriceId, $unselectedPriceId],
- priceLabels: ['Selected Tier', 'Unselected Tier'],
- availabilities: [
- ['price_id' => $selectedPriceId, 'quantity_available' => 5, 'quantity_reserved' => 0],
- ['price_id' => $unselectedPriceId, 'quantity_available' => 0, 'quantity_reserved' => 0],
- ],
+ $occurrence = $this->createOccurrence(
+ status: EventOccurrenceStatus::SOLD_OUT->name,
);
- $data = [
- 'products' => [
- [
- 'product_id' => $productId,
- 'quantities' => [
- ['price_id' => $selectedPriceId, 'quantity' => 1],
- ['price_id' => $unselectedPriceId, 'quantity' => 0],
- ],
- ],
- ],
- ];
+ $this->setupOccurrenceLookup(1, 10, $occurrence);
+ $this->setupEventLookup(1);
+
+ $this->service->validateRequestData(1, $this->createRequestData(10));
+ }
+
+ public function test_rejects_when_occurrence_capacity_exceeded(): void
+ {
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('capacity');
+
+ $occurrence = $this->createOccurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
+ capacity: 10,
+ usedCapacity: 8,
+ );
- $this->service->validateRequestData($eventId, $data);
+ $this->setupOccurrenceLookup(1, 10, $occurrence);
+ $this->setupEventLookup(1);
+
+ $data = $this->createRequestData(10, quantity: 5);
+
+ $this->service->validateRequestData(1, $data);
+ }
+
+ public function test_accepts_active_occurrence_with_sufficient_capacity(): void
+ {
+ $occurrence = $this->createOccurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
+ capacity: 100,
+ usedCapacity: 0,
+ );
+
+ $this->setupOccurrenceLookup(1, 10, $occurrence);
+ $this->setupEventLookup(1);
+ $this->setupAvailability(1);
+ $this->setupProducts(1, 10, 100);
+
+ $data = $this->createRequestData(10, quantity: 2);
+
+ $this->service->validateRequestData(1, $data);
$this->assertTrue(true);
}
- public function testZeroQuantityTierWithNegativeAvailabilityDoesNotThrow(): void
+ public function test_normalizes_missing_occurrence_id_for_single_event_checkout(): void
{
- $eventId = 1;
- $productId = 10;
- $healthyPriceId = 101;
- $brokenPriceId = 102;
+ $occurrence = $this->createOccurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
+ capacity: 100,
+ usedCapacity: 0,
+ );
- $this->setupMocks(
- eventId: $eventId,
- productId: $productId,
- priceIds: [$healthyPriceId, $brokenPriceId],
- priceLabels: ['Healthy Tier', 'Broken Tier'],
- availabilities: [
- ['price_id' => $healthyPriceId, 'quantity_available' => 10, 'quantity_reserved' => 0],
- ['price_id' => $brokenPriceId, 'quantity_available' => -5, 'quantity_reserved' => 0],
- ],
+ $this->setupEventLookup(1, isRecurring: false);
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(collect([$occurrence]));
+ $this->setupOccurrenceLookup(1, 10, $occurrence);
+ $this->setupAvailability(1);
+ $this->setupProducts(1, 10, 100);
+
+ $data = $this->createRequestData(10, quantity: 2);
+ unset($data['products'][0]['event_occurrence_id']);
+
+ $normalized = $this->service->validateRequestData(1, $data);
+
+ $this->assertSame(10, $normalized['products'][0]['event_occurrence_id']);
+ }
+
+ public function test_accepts_occurrence_with_unlimited_capacity(): void
+ {
+ $occurrence = $this->createOccurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
+ capacity: null,
+ usedCapacity: 0,
+ );
+
+ $this->setupOccurrenceLookup(1, 10, $occurrence);
+ $this->setupEventLookup(1);
+ $this->setupAvailability(1);
+ $this->setupProducts(1, 10, 100);
+
+ $data = $this->createRequestData(10, quantity: 5);
+
+ $this->service->validateRequestData(1, $data);
+ $this->assertTrue(true);
+ }
+
+ public function test_rejects_when_occurrence_not_found_for_event(): void
+ {
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('not found');
+
+ $this->setupOccurrenceLookup(1, 999, null);
+ $this->setupEventLookup(1);
+
+ $this->service->validateRequestData(1, $this->createRequestData(999));
+ }
+
+ public function test_skips_capacity_assignments_for_recurring_events(): void
+ {
+ $occurrence = $this->createOccurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
+ capacity: null,
);
+ $this->setupOccurrenceLookup(1, 10, $occurrence);
+ $this->setupEventLookup(1, isRecurring: true);
+ $this->setupAvailability(1, capacities: collect());
+ $this->setupProducts(1, 10, 100);
+
+ $data = $this->createRequestData(10, quantity: 2);
+
+ $this->service->validateRequestData(1, $data);
+ $this->assertTrue(true);
+ }
+
+ public function test_rejects_product_hidden_from_occurrence(): void
+ {
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('not available for this occurrence');
+
+ $occurrence = $this->createOccurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
+ capacity: 100,
+ usedCapacity: 0,
+ );
+
+ $this->setupOccurrenceLookup(1, 10, $occurrence);
+ $this->setupEventLookup(1);
+
+ // Visibility rules exist for occurrence 10 but product 10 is NOT in the visible set,
+ // so the order must be rejected even though all other validation would pass.
+ $visibilityRule = (new ProductOccurrenceVisibilityDomainObject)
+ ->setEventOccurrenceId(10)
+ ->setProductId(99);
+
+ $this->visibilityRepository
+ ->shouldReceive('findWhereIn')
+ ->with('event_occurrence_id', [10])
+ ->andReturn(collect([$visibilityRule]));
+
+ $this->service->validateRequestData(1, $this->createRequestData(10));
+ }
+
+ public function test_allows_product_explicitly_visible_on_occurrence(): void
+ {
+ $occurrence = $this->createOccurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
+ capacity: 100,
+ usedCapacity: 0,
+ );
+
+ $this->setupOccurrenceLookup(1, 10, $occurrence);
+ $this->setupEventLookup(1);
+ $this->setupAvailability(1);
+ $this->setupProducts(1, 10, 100);
+
+ $visibilityRule = (new ProductOccurrenceVisibilityDomainObject)
+ ->setEventOccurrenceId(10)
+ ->setProductId(10);
+
+ $this->visibilityRepository
+ ->shouldReceive('findWhereIn')
+ ->with('event_occurrence_id', [10])
+ ->andReturn(collect([$visibilityRule]));
+
+ $this->service->validateRequestData(1, $this->createRequestData(10));
+ $this->assertTrue(true);
+ }
+
+ /**
+ * Regression guard for the perf fix: an order that spans multiple occurrences must
+ * resolve all visibility rules in a single batched query (findWhereIn) instead of
+ * one query per occurrence (the original N+1 implementation).
+ */
+ public function test_enforces_per_occurrence_visibility_for_multi_occurrence_order(): void
+ {
+ // Cart contains product 10 on occurrence 10 and product 20 on occurrence 20.
+ // Visibility allows product 10 on occurrence 10 but blocks product 20 on
+ // occurrence 20 — processing reaches the second occurrence and throws.
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('not available for this occurrence');
+
+ $occurrence10 = $this->createOccurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
+ capacity: 100,
+ );
+
+ $occurrence20 = (new EventOccurrenceDomainObject)
+ ->setId(20)
+ ->setEventId(1)
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name)
+ ->setCapacity(100)
+ ->setUsedCapacity(0)
+ ->setStartDate('2026-07-15 10:00:00');
+
+ $this->setupOccurrenceLookup(1, 10, $occurrence10);
+ $this->setupOccurrenceLookup(1, 20, $occurrence20);
+ $this->setupEventLookup(1);
+
+ $rule10 = (new ProductOccurrenceVisibilityDomainObject)
+ ->setEventOccurrenceId(10)
+ ->setProductId(10);
+ $rule20 = (new ProductOccurrenceVisibilityDomainObject)
+ ->setEventOccurrenceId(20)
+ ->setProductId(99);
+
+ // OccurrencePurchaseEligibilityService asks for one occurrence at a time —
+ // simpler API at the cost of N visibility lookups. Acceptable trade-off
+ // for the manual-attendee path; revisit if multi-occurrence orders become
+ // a hot path.
+ $this->visibilityRepository
+ ->shouldReceive('findWhereIn')
+ ->with('event_occurrence_id', [10])
+ ->andReturn(collect([$rule10]));
+ $this->visibilityRepository
+ ->shouldReceive('findWhereIn')
+ ->with('event_occurrence_id', [20])
+ ->andReturn(collect([$rule20]));
+
$data = [
'products' => [
[
- 'product_id' => $productId,
- 'quantities' => [
- ['price_id' => $healthyPriceId, 'quantity' => 1],
- ['price_id' => $brokenPriceId, 'quantity' => 0],
- ],
+ 'product_id' => 10,
+ 'event_occurrence_id' => 10,
+ 'quantities' => [['price_id' => 100, 'quantity' => 1]],
+ ],
+ [
+ 'product_id' => 20,
+ 'event_occurrence_id' => 20,
+ 'quantities' => [['price_id' => 200, 'quantity' => 1]],
],
],
];
- $this->service->validateRequestData($eventId, $data);
- $this->assertTrue(true);
+ $this->service->validateRequestData(1, $data);
}
- public function testNonZeroQuantityStillValidatesAgainstAvailability(): void
+ public function test_allows_all_products_when_no_visibility_rules(): void
{
- $eventId = 1;
- $productId = 10;
- $priceId = 101;
-
- $this->setupMocks(
- eventId: $eventId,
- productId: $productId,
- priceIds: [$priceId],
- priceLabels: ['Test Tier'],
- availabilities: [
- ['price_id' => $priceId, 'quantity_available' => 2, 'quantity_reserved' => 0],
- ],
+ $occurrence10 = $this->createOccurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
+ capacity: 100,
);
+ $occurrence20 = (new EventOccurrenceDomainObject)
+ ->setId(20)
+ ->setEventId(1)
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name)
+ ->setCapacity(100)
+ ->setUsedCapacity(0)
+ ->setStartDate('2026-08-01 10:00:00');
+ $this->setupOccurrenceLookup(1, 10, $occurrence10);
+ $this->setupOccurrenceLookup(1, 20, $occurrence20);
+ $this->setupEventLookup(1);
+ $this->setupAvailability(1);
+ $this->setupProducts(1, 10, 100);
+
+ // No visibility rules for either occurrence → both products allowed.
+ $this->visibilityRepository
+ ->shouldReceive('findWhereIn')
+ ->with('event_occurrence_id', [10])
+ ->andReturn(collect());
+ $this->visibilityRepository
+ ->shouldReceive('findWhereIn')
+ ->with('event_occurrence_id', [20])
+ ->andReturn(collect());
+
+ // Product 10 sells on both occurrences in this scenario; product details are
+ // identical so the existing single-product setup is sufficient.
$data = [
'products' => [
[
- 'product_id' => $productId,
- 'quantities' => [
- ['price_id' => $priceId, 'quantity' => 5],
- ],
+ 'product_id' => 10,
+ 'event_occurrence_id' => 10,
+ 'quantities' => [['price_id' => 100, 'quantity' => 1]],
+ ],
+ [
+ 'product_id' => 10,
+ 'event_occurrence_id' => 20,
+ 'quantities' => [['price_id' => 100, 'quantity' => 1]],
],
],
];
- $this->expectException(ValidationException::class);
- $this->service->validateRequestData($eventId, $data);
+ $this->service->validateRequestData(1, $data);
+ $this->assertTrue(true);
+ }
+
+ private function createOccurrence(
+ string $status = 'ACTIVE',
+ ?int $capacity = null,
+ int $usedCapacity = 0,
+ ): EventOccurrenceDomainObject {
+ return (new EventOccurrenceDomainObject)
+ ->setId(10)
+ ->setEventId(1)
+ ->setStatus($status)
+ ->setCapacity($capacity)
+ ->setUsedCapacity($usedCapacity)
+ ->setStartDate('2026-06-15 10:00:00');
+ }
+
+ private function setupOccurrenceLookup(int $eventId, int $occurrenceId, ?EventOccurrenceDomainObject $occurrence): void
+ {
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->with([
+ 'id' => $occurrenceId,
+ 'event_id' => $eventId,
+ ])
+ ->andReturn($occurrence);
}
- private function setupMocks(
- int $eventId,
- int $productId,
- array $priceIds,
- array $priceLabels,
- array $availabilities,
- ): void
+ private function setupEventLookup(int $eventId, bool $isRecurring = false): void
{
$event = Mockery::mock(EventDomainObject::class);
$event->shouldReceive('getId')->andReturn($eventId);
- $event->shouldReceive('getStatus')->andReturn(EventStatus::LIVE->name);
- $event->shouldReceive('getCurrency')->andReturn('USD');
+ $event->shouldReceive('isRecurring')->andReturn($isRecurring);
- $this->eventRepository->shouldReceive('findById')->with($eventId)->andReturn($event);
+ $this->eventRepository
+ ->shouldReceive('findById')
+ ->with($eventId)
+ ->andReturn($event);
+ }
- $productPrices = new Collection();
- foreach ($priceIds as $i => $priceId) {
- $price = Mockery::mock(ProductPriceDomainObject::class);
- $price->shouldReceive('getId')->andReturn($priceId);
- $price->shouldReceive('getLabel')->andReturn($priceLabels[$i] ?? null);
- $productPrices->push($price);
- }
+ private function setupAvailability(int $eventId, ?Collection $capacities = null, int $available = 100): void
+ {
+ $this->availabilityService
+ ->shouldReceive('getAvailableProductQuantities')
+ ->andReturn(new AvailableProductQuantitiesResponseDTO(
+ productQuantities: collect([
+ AvailableProductQuantitiesDTO::fromArray([
+ 'product_id' => 10,
+ 'price_id' => 100,
+ 'product_title' => 'Test Product',
+ 'price_label' => null,
+ 'quantity_available' => $available,
+ 'quantity_reserved' => 0,
+ 'initial_quantity_available' => 100,
+ 'capacities' => new Collection,
+ ]),
+ ]),
+ capacities: $capacities ?? collect(),
+ ));
+ }
+
+ private function setupProducts(int $eventId, int $productId, int $priceId): void
+ {
+ $price = Mockery::mock(ProductPriceDomainObject::class);
+ $price->shouldReceive('getId')->andReturn($priceId);
$product = Mockery::mock(ProductDomainObject::class);
$product->shouldReceive('getId')->andReturn($productId);
$product->shouldReceive('getEventId')->andReturn($eventId);
$product->shouldReceive('getTitle')->andReturn('Test Product');
- $product->shouldReceive('getMaxPerOrder')->andReturn(100);
+ $product->shouldReceive('getMaxPerOrder')->andReturn(10);
$product->shouldReceive('getMinPerOrder')->andReturn(1);
+ $product->shouldReceive('getType')->andReturn('PAID');
+ $product->shouldReceive('getPrice')->andReturn(10.0);
$product->shouldReceive('isSoldOut')->andReturn(false);
- $product->shouldReceive('getType')->andReturn(ProductPriceType::TIERED->name);
- $product->shouldReceive('getProductPrices')->andReturn($productPrices);
-
- $this->productRepository->shouldReceive('loadRelation')->andReturnSelf();
- $this->productRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$product]));
-
- $quantityDTOs = collect();
- foreach ($availabilities as $avail) {
- $quantityDTOs->push(AvailableProductQuantitiesDTO::fromArray([
- 'product_id' => $productId,
- 'price_id' => $avail['price_id'],
- 'product_title' => 'Test Product',
- 'price_label' => null,
- 'quantity_available' => $avail['quantity_available'],
- 'quantity_reserved' => $avail['quantity_reserved'],
- 'initial_quantity_available' => 100,
- 'capacities' => collect(),
- ]));
- }
-
- $this->availabilityService->shouldReceive('getAvailableProductQuantities')
- ->with($eventId, Mockery::any())
- ->andReturn(new AvailableProductQuantitiesResponseDTO(
- productQuantities: $quantityDTOs,
- capacities: collect(),
- ));
+ $product->shouldReceive('getProductPrices')->andReturn(collect([$price]));
+ $product->shouldReceive('getProductType')->andReturn('TICKET');
+
+ $this->productRepository
+ ->shouldReceive('loadRelation')->andReturnSelf();
+
+ $this->productRepository
+ ->shouldReceive('findWhereIn')
+ ->andReturn(collect([$product]));
+ }
+
+ private function createRequestData(int $occurrenceId, int $productId = 10, int $priceId = 100, int $quantity = 1): array
+ {
+ return [
+ 'products' => [
+ [
+ 'product_id' => $productId,
+ 'event_occurrence_id' => $occurrenceId,
+ 'quantities' => [
+ [
+ 'price_id' => $priceId,
+ 'quantity' => $quantity,
+ ],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
}
}
diff --git a/backend/tests/Unit/Services/Domain/Order/OrderPlatformFeePassThroughServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderPlatformFeePassThroughServiceTest.php
index 202b6a7b52..c11c966ad9 100644
--- a/backend/tests/Unit/Services/Domain/Order/OrderPlatformFeePassThroughServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Order/OrderPlatformFeePassThroughServiceTest.php
@@ -3,7 +3,7 @@
namespace Tests\Unit\Services\Domain\Order;
use Brick\Money\Currency;
-use HiEvents\DomainObjects\AccountConfigurationDomainObject;
+use HiEvents\DomainObjects\OrganizerConfigurationDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\Services\Domain\Order\OrderPlatformFeePassThroughService;
use HiEvents\Services\Infrastructure\CurrencyConversion\CurrencyConversionClientInterface;
@@ -32,9 +32,9 @@ protected function setUp(): void
);
}
- private function createAccountConfig(float $fixedFee = 0.30, float $percentageFee = 2.9, string $currency = 'USD'): AccountConfigurationDomainObject
+ private function createAccountConfig(float $fixedFee = 0.30, float $percentageFee = 2.9, string $currency = 'USD'): OrganizerConfigurationDomainObject
{
- $mock = $this->createMock(AccountConfigurationDomainObject::class);
+ $mock = $this->createMock(OrganizerConfigurationDomainObject::class);
$mock->method('getFixedApplicationFee')->willReturn($fixedFee);
$mock->method('getPercentageApplicationFee')->willReturn($percentageFee);
$mock->method('getApplicationFeeCurrency')->willReturn($currency);
diff --git a/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeAccountSyncServiceTest.php b/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeAccountSyncServiceTest.php
index b31d1814c7..e0ca179d66 100644
--- a/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeAccountSyncServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeAccountSyncServiceTest.php
@@ -2,11 +2,11 @@
namespace Tests\Unit\Services\Domain\Payment\Stripe;
-use HiEvents\DomainObjects\AccountDomainObject;
-use HiEvents\DomainObjects\AccountStripePlatformDomainObject;
+use HiEvents\DomainObjects\Generated\OrganizerStripePlatformDomainObjectAbstract;
use HiEvents\Repository\Interfaces\AccountRepositoryInterface;
-use HiEvents\Repository\Interfaces\AccountStripePlatformRepositoryInterface;
-use HiEvents\Repository\Interfaces\AccountVatSettingRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrganizerVatSettingRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrganizerStripePlatformRepositoryInterface;
use HiEvents\Services\Domain\Payment\Stripe\StripeAccountSyncService;
use Illuminate\Config\Repository;
use Mockery as m;
@@ -19,8 +19,9 @@ class StripeAccountSyncServiceTest extends TestCase
private StripeAccountSyncService $service;
private LoggerInterface $logger;
private AccountRepositoryInterface $accountRepository;
- private AccountStripePlatformRepositoryInterface $accountStripePlatformRepository;
- private AccountVatSettingRepositoryInterface $vatSettingRepository;
+ private OrganizerRepositoryInterface $organizerRepository;
+ private OrganizerStripePlatformRepositoryInterface $organizerStripePlatformRepository;
+ private OrganizerVatSettingRepositoryInterface $vatSettingRepository;
private Repository $config;
protected function setUp(): void
@@ -29,14 +30,16 @@ protected function setUp(): void
$this->logger = m::mock(LoggerInterface::class);
$this->accountRepository = m::mock(AccountRepositoryInterface::class);
- $this->accountStripePlatformRepository = m::mock(AccountStripePlatformRepositoryInterface::class);
- $this->vatSettingRepository = m::mock(AccountVatSettingRepositoryInterface::class);
+ $this->organizerRepository = m::mock(OrganizerRepositoryInterface::class);
+ $this->organizerStripePlatformRepository = m::mock(OrganizerStripePlatformRepositoryInterface::class);
+ $this->vatSettingRepository = m::mock(OrganizerVatSettingRepositoryInterface::class);
$this->config = m::mock(Repository::class);
$this->service = new StripeAccountSyncService(
$this->logger,
$this->accountRepository,
- $this->accountStripePlatformRepository,
+ $this->organizerRepository,
+ $this->organizerStripePlatformRepository,
$this->vatSettingRepository,
$this->config,
);
@@ -48,42 +51,50 @@ public function testIsStripeAccountCompleteReturnsTrueWhenBothEnabled(): void
$stripeAccount->charges_enabled = true;
$stripeAccount->payouts_enabled = true;
- $result = $this->service->isStripeAccountComplete($stripeAccount);
-
- $this->assertTrue($result);
+ $this->assertTrue($this->service->isStripeAccountComplete($stripeAccount));
}
- public function testIsStripeAccountCompleteReturnsFalseWhenChargesDisabled(): void
+ public function testIsStripeAccountCompleteReturnsFalseWhenAnythingDisabled(): void
{
- $stripeAccount = new Account();
- $stripeAccount->charges_enabled = false;
- $stripeAccount->payouts_enabled = true;
-
- $result = $this->service->isStripeAccountComplete($stripeAccount);
-
- $this->assertFalse($result);
+ foreach ([[false, true], [true, false], [false, false]] as [$charges, $payouts]) {
+ $stripeAccount = new Account();
+ $stripeAccount->charges_enabled = $charges;
+ $stripeAccount->payouts_enabled = $payouts;
+ $this->assertFalse($this->service->isStripeAccountComplete($stripeAccount));
+ }
}
- public function testIsStripeAccountCompleteReturnsFalseWhenPayoutsDisabled(): void
+ public function testSyncByAccountIdUpdatesAllOrganizerRowsAndStopsIfIncomplete(): void
{
- $stripeAccount = new Account();
- $stripeAccount->charges_enabled = true;
- $stripeAccount->payouts_enabled = false;
-
- $result = $this->service->isStripeAccountComplete($stripeAccount);
-
- $this->assertFalse($result);
- }
-
- public function testIsStripeAccountCompleteReturnsFalseWhenBothDisabled(): void
- {
- $stripeAccount = new Account();
- $stripeAccount->charges_enabled = false;
- $stripeAccount->payouts_enabled = false;
-
- $result = $this->service->isStripeAccountComplete($stripeAccount);
-
- $this->assertFalse($result);
+ $stripeAccount = Account::constructFrom([
+ 'id' => 'acct_123',
+ 'charges_enabled' => false,
+ 'payouts_enabled' => false,
+ 'country' => 'US',
+ 'type' => 'standard',
+ 'business_type' => 'individual',
+ 'capabilities' => [],
+ 'requirements' => [
+ 'currently_due' => ['external_account'],
+ 'eventually_due' => [],
+ 'past_due' => [],
+ 'pending_verification' => [],
+ ],
+ ]);
+
+ $this->organizerStripePlatformRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ m::on(fn($attrs) => array_key_exists(OrganizerStripePlatformDomainObjectAbstract::STRIPE_SETUP_COMPLETED_AT, $attrs)
+ && $attrs[OrganizerStripePlatformDomainObjectAbstract::STRIPE_SETUP_COMPLETED_AT] === null),
+ [OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => 'acct_123'],
+ )
+ ->andReturn(2);
+
+ $this->service->syncStripeAccountStatusByAccountId($stripeAccount);
+
+ $this->addToAssertionCount(1);
}
protected function tearDown(): void
diff --git a/backend/tests/Unit/Services/Domain/Product/ProductPriceServiceTest.php b/backend/tests/Unit/Services/Domain/Product/ProductPriceServiceTest.php
new file mode 100644
index 0000000000..4b346e2d39
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Product/ProductPriceServiceTest.php
@@ -0,0 +1,139 @@
+priceOverrideRepository = Mockery::mock(ProductPriceOccurrenceOverrideRepositoryInterface::class);
+ $this->service = new ProductPriceService($this->priceOverrideRepository);
+ }
+
+ public function testGetPriceUsesOverrideWhenPresent(): void
+ {
+ $product = $this->createProduct(ProductPriceType::PAID->name, 50.00);
+ $orderDetail = new OrderProductPriceDTO(quantity: 1, price_id: 100);
+
+ $override = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+ $override->shouldReceive('getPrice')->andReturn('35.00');
+
+ $this->priceOverrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->with([
+ 'event_occurrence_id' => 5,
+ 'product_price_id' => 100,
+ ])
+ ->andReturn($override);
+
+ $result = $this->service->getPrice($product, $orderDetail, null, 5);
+
+ $this->assertEquals(35.00, $result->price);
+ }
+
+ public function testGetPriceFallsBackToBaseWhenNoOverride(): void
+ {
+ $product = $this->createProduct(ProductPriceType::PAID->name, 50.00);
+ $orderDetail = new OrderProductPriceDTO(quantity: 1, price_id: 100);
+
+ $this->priceOverrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->with([
+ 'event_occurrence_id' => 5,
+ 'product_price_id' => 100,
+ ])
+ ->andReturn(null);
+
+ $result = $this->service->getPrice($product, $orderDetail, null, 5);
+
+ $this->assertEquals(50.00, $result->price);
+ }
+
+ public function testGetPriceSkipsOverrideLookupWithoutOccurrence(): void
+ {
+ $product = $this->createProduct(ProductPriceType::PAID->name, 50.00);
+ $orderDetail = new OrderProductPriceDTO(quantity: 1, price_id: 100);
+
+ $this->priceOverrideRepository->shouldNotReceive('findFirstWhere');
+
+ $result = $this->service->getPrice($product, $orderDetail, null);
+
+ $this->assertEquals(50.00, $result->price);
+ }
+
+ public function testGetPriceAppliesPromoCodeAfterOverride(): void
+ {
+ $product = $this->createProduct(ProductPriceType::PAID->name, 50.00);
+ $orderDetail = new OrderProductPriceDTO(quantity: 1, price_id: 100);
+
+ $override = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+ $override->shouldReceive('getPrice')->andReturn('40.00');
+
+ $this->priceOverrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn($override);
+
+ $promoCode = Mockery::mock(PromoCodeDomainObject::class);
+ $promoCode->shouldReceive('appliesToProduct')->andReturn(true);
+ $promoCode->shouldReceive('getDiscountType')->andReturn(PromoCodeDiscountTypeEnum::PERCENTAGE->name);
+ $promoCode->shouldReceive('isFixedDiscount')->andReturn(false);
+ $promoCode->shouldReceive('isPercentageDiscount')->andReturn(true);
+ $promoCode->shouldReceive('getDiscount')->andReturn(10);
+
+ $result = $this->service->getPrice($product, $orderDetail, $promoCode, 5);
+
+ $this->assertEquals(36.00, $result->price);
+ $this->assertEquals(40.00, $result->price_before_discount);
+ }
+
+ public function testGetPriceReturnsFreeForFreeProduct(): void
+ {
+ $product = $this->createProduct(ProductPriceType::FREE->name, 0.0);
+ $orderDetail = new OrderProductPriceDTO(quantity: 1, price_id: 100);
+
+ $this->priceOverrideRepository->shouldReceive('findFirstWhere')->andReturn(null);
+
+ $result = $this->service->getPrice($product, $orderDetail, null, 5);
+
+ $this->assertEquals(0.00, $result->price);
+ }
+
+ private function createProduct(string $type, float $price): ProductDomainObject
+ {
+ $productPrice = Mockery::mock(ProductPriceDomainObject::class);
+ $productPrice->shouldReceive('getId')->andReturn(100);
+ $productPrice->shouldReceive('getPrice')->andReturn($price);
+
+ $product = Mockery::mock(ProductDomainObject::class);
+ $product->shouldReceive('getType')->andReturn($type);
+ $product->shouldReceive('getPrice')->andReturn($price);
+ $product->shouldReceive('getProductPrices')->andReturn(collect([$productPrice]));
+ $product->shouldReceive('getPriceById')->with(100)->andReturn($productPrice);
+
+ return $product;
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/Product/ProductQuantityUpdateServiceTest.php b/backend/tests/Unit/Services/Domain/Product/ProductQuantityUpdateServiceTest.php
new file mode 100644
index 0000000000..402407459a
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Product/ProductQuantityUpdateServiceTest.php
@@ -0,0 +1,481 @@
+productPriceRepository = Mockery::mock(ProductPriceRepositoryInterface::class);
+ $this->productRepository = Mockery::mock(ProductRepositoryInterface::class);
+ $this->capacityAssignmentRepository = Mockery::mock(CapacityAssignmentRepositoryInterface::class);
+ $this->databaseManager = Mockery::mock(DatabaseManager::class);
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+
+ $this->databaseManager->shouldReceive('transaction')
+ ->andReturnUsing(fn($callback) => $callback());
+
+ $this->service = new ProductQuantityUpdateService(
+ $this->productPriceRepository,
+ $this->productRepository,
+ $this->capacityAssignmentRepository,
+ $this->databaseManager,
+ $this->occurrenceRepository,
+ );
+ }
+
+ public function testIncreaseQuantitySoldIncrementsOccurrenceCapacity(): void
+ {
+ $priceId = 100;
+ $occurrenceId = 5;
+ $adjustment = 2;
+
+ $price = Mockery::mock(ProductPriceDomainObject::class);
+ $price->shouldReceive('getProductId')->andReturn(10);
+
+ $this->productPriceRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['id' => $priceId])
+ ->andReturn($price);
+
+ $this->productRepository
+ ->shouldReceive('getCapacityAssignmentsByProductId')
+ ->with(10)
+ ->andReturn(collect());
+
+ $this->productPriceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ Mockery::on(fn($data) => array_key_exists('quantity_sold', $data)),
+ ['id' => $priceId],
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ Mockery::on(fn($data) => array_key_exists('used_capacity', $data)),
+ ['id' => $occurrenceId],
+ );
+
+ $occurrence = (new EventOccurrenceDomainObject())
+ ->setId($occurrenceId)
+ ->setCapacity(null)
+ ->setUsedCapacity($adjustment)
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findById')
+ ->with($occurrenceId)
+ ->andReturn($occurrence);
+
+ $this->service->increaseQuantitySold($priceId, $adjustment, $occurrenceId);
+ }
+
+ public function testDecreaseQuantitySoldDecrementsOccurrenceCapacity(): void
+ {
+ $priceId = 100;
+ $occurrenceId = 5;
+ $adjustment = 1;
+
+ $price = Mockery::mock(ProductPriceDomainObject::class);
+ $price->shouldReceive('getProductId')->andReturn(10);
+
+ $this->productPriceRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['id' => $priceId])
+ ->andReturn($price);
+
+ $this->productRepository
+ ->shouldReceive('getCapacityAssignmentsByProductId')
+ ->with(10)
+ ->andReturn(collect());
+
+ $this->productPriceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ Mockery::on(fn($data) => array_key_exists('quantity_sold', $data)),
+ ['id' => $priceId],
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ Mockery::on(fn($data) => array_key_exists('used_capacity', $data)),
+ ['id' => $occurrenceId],
+ );
+
+ $occurrence = (new EventOccurrenceDomainObject())
+ ->setId($occurrenceId)
+ ->setCapacity(10)
+ ->setUsedCapacity(5)
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findById')
+ ->with($occurrenceId)
+ ->andReturn($occurrence);
+
+ $this->service->decreaseQuantitySold($priceId, $adjustment, $occurrenceId);
+ }
+
+ public function testIncreaseQuantitySoldSkipsOccurrenceWhenNull(): void
+ {
+ $priceId = 100;
+
+ $price = Mockery::mock(ProductPriceDomainObject::class);
+ $price->shouldReceive('getProductId')->andReturn(10);
+
+ $this->productPriceRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn($price);
+
+ $this->productRepository
+ ->shouldReceive('getCapacityAssignmentsByProductId')
+ ->andReturn(collect());
+
+ $priceUpdateCalled = false;
+ $this->productPriceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->andReturnUsing(function () use (&$priceUpdateCalled) {
+ $priceUpdateCalled = true;
+ return 1;
+ });
+
+ $this->occurrenceRepository
+ ->shouldNotReceive('updateWhere');
+
+ $this->service->increaseQuantitySold($priceId, 1, null);
+
+ $this->assertTrue($priceUpdateCalled);
+ }
+
+ public function testUpdateQuantitiesFromOrderPassesOccurrenceId(): void
+ {
+ $orderItem = (new OrderItemDomainObject())
+ ->setId(1)
+ ->setProductPriceId(100)
+ ->setQuantity(2)
+ ->setEventOccurrenceId(5);
+
+ $order = (new OrderDomainObject())
+ ->setOrderItems(new Collection([$orderItem]));
+
+ $price = Mockery::mock(ProductPriceDomainObject::class);
+ $price->shouldReceive('getProductId')->andReturn(10);
+
+ $this->productPriceRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['id' => 100])
+ ->andReturn($price);
+
+ $this->productRepository
+ ->shouldReceive('getCapacityAssignmentsByProductId')
+ ->with(10)
+ ->andReturn(collect());
+
+ $this->productPriceRepository
+ ->shouldReceive('updateWhere')
+ ->once();
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ Mockery::on(fn($data) => array_key_exists('used_capacity', $data)),
+ ['id' => 5],
+ );
+
+ $occurrence = (new EventOccurrenceDomainObject())
+ ->setId(5)
+ ->setCapacity(null)
+ ->setUsedCapacity(2)
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findById')
+ ->with(5)
+ ->andReturn($occurrence);
+
+ $this->service->updateQuantitiesFromOrder($order);
+ }
+
+ public function testIncreaseQuantitySoldSetsOccurrenceToSoldOutWhenAtCapacity(): void
+ {
+ $priceId = 100;
+ $occurrenceId = 5;
+
+ $price = Mockery::mock(ProductPriceDomainObject::class);
+ $price->shouldReceive('getProductId')->andReturn(10);
+
+ $this->productPriceRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['id' => $priceId])
+ ->andReturn($price);
+
+ $this->productRepository
+ ->shouldReceive('getCapacityAssignmentsByProductId')
+ ->with(10)
+ ->andReturn(collect());
+
+ $this->productPriceRepository
+ ->shouldReceive('updateWhere')
+ ->once();
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->with(
+ Mockery::on(fn($data) => array_key_exists('used_capacity', $data)),
+ ['id' => $occurrenceId],
+ )
+ ->once();
+
+ $occurrence = (new EventOccurrenceDomainObject())
+ ->setId($occurrenceId)
+ ->setCapacity(10)
+ ->setUsedCapacity(10)
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findById')
+ ->with($occurrenceId)
+ ->andReturn($occurrence);
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->with(
+ ['status' => EventOccurrenceStatus::SOLD_OUT->name],
+ ['id' => $occurrenceId],
+ )
+ ->once();
+
+ $this->service->increaseQuantitySold($priceId, 1, $occurrenceId);
+ }
+
+ public function testIncreaseQuantitySoldDoesNotSetSoldOutWhenCapacityIsNull(): void
+ {
+ $priceId = 100;
+ $occurrenceId = 5;
+
+ $price = Mockery::mock(ProductPriceDomainObject::class);
+ $price->shouldReceive('getProductId')->andReturn(10);
+
+ $this->productPriceRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['id' => $priceId])
+ ->andReturn($price);
+
+ $this->productRepository
+ ->shouldReceive('getCapacityAssignmentsByProductId')
+ ->with(10)
+ ->andReturn(collect());
+
+ $this->productPriceRepository
+ ->shouldReceive('updateWhere')
+ ->once();
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->with(
+ Mockery::on(fn($data) => array_key_exists('used_capacity', $data)),
+ ['id' => $occurrenceId],
+ )
+ ->once();
+
+ $occurrence = (new EventOccurrenceDomainObject())
+ ->setId($occurrenceId)
+ ->setCapacity(null)
+ ->setUsedCapacity(100)
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findById')
+ ->with($occurrenceId)
+ ->andReturn($occurrence);
+
+ $this->service->increaseQuantitySold($priceId, 1, $occurrenceId);
+ }
+
+ public function testDecreaseQuantitySoldResetsOccurrenceFromSoldOutToActive(): void
+ {
+ $priceId = 100;
+ $occurrenceId = 5;
+
+ $price = Mockery::mock(ProductPriceDomainObject::class);
+ $price->shouldReceive('getProductId')->andReturn(10);
+
+ $this->productPriceRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['id' => $priceId])
+ ->andReturn($price);
+
+ $this->productRepository
+ ->shouldReceive('getCapacityAssignmentsByProductId')
+ ->with(10)
+ ->andReturn(collect());
+
+ $this->productPriceRepository
+ ->shouldReceive('updateWhere')
+ ->once();
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->with(
+ Mockery::on(fn($data) => array_key_exists('used_capacity', $data)),
+ ['id' => $occurrenceId],
+ )
+ ->once();
+
+ $occurrence = (new EventOccurrenceDomainObject())
+ ->setId($occurrenceId)
+ ->setCapacity(10)
+ ->setUsedCapacity(9)
+ ->setStatus(EventOccurrenceStatus::SOLD_OUT->name);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findById')
+ ->with($occurrenceId)
+ ->andReturn($occurrence);
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->with(
+ ['status' => EventOccurrenceStatus::ACTIVE->name],
+ ['id' => $occurrenceId],
+ )
+ ->once();
+
+ $this->service->decreaseQuantitySold($priceId, 1, $occurrenceId);
+ }
+
+ public function testDecreaseQuantitySoldDoesNotResetNonSoldOutOccurrence(): void
+ {
+ $priceId = 100;
+ $occurrenceId = 5;
+
+ $price = Mockery::mock(ProductPriceDomainObject::class);
+ $price->shouldReceive('getProductId')->andReturn(10);
+
+ $this->productPriceRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['id' => $priceId])
+ ->andReturn($price);
+
+ $this->productRepository
+ ->shouldReceive('getCapacityAssignmentsByProductId')
+ ->with(10)
+ ->andReturn(collect());
+
+ $this->productPriceRepository
+ ->shouldReceive('updateWhere')
+ ->once();
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->with(
+ Mockery::on(fn($data) => array_key_exists('used_capacity', $data)),
+ ['id' => $occurrenceId],
+ )
+ ->once();
+
+ $occurrence = (new EventOccurrenceDomainObject())
+ ->setId($occurrenceId)
+ ->setCapacity(10)
+ ->setUsedCapacity(5)
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findById')
+ ->with($occurrenceId)
+ ->andReturn($occurrence);
+
+ $this->service->decreaseQuantitySold($priceId, 1, $occurrenceId);
+ }
+
+ public function testIncreaseQuantitySoldDoesNotOverrideCancelledStatus(): void
+ {
+ $priceId = 100;
+ $occurrenceId = 5;
+
+ $price = Mockery::mock(ProductPriceDomainObject::class);
+ $price->shouldReceive('getProductId')->andReturn(10);
+
+ $this->productPriceRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['id' => $priceId])
+ ->andReturn($price);
+
+ $this->productRepository
+ ->shouldReceive('getCapacityAssignmentsByProductId')
+ ->with(10)
+ ->andReturn(collect());
+
+ $this->productPriceRepository
+ ->shouldReceive('updateWhere')
+ ->once();
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->with(
+ Mockery::on(fn($data) => array_key_exists('used_capacity', $data)),
+ ['id' => $occurrenceId],
+ )
+ ->once();
+
+ $occurrence = (new EventOccurrenceDomainObject())
+ ->setId($occurrenceId)
+ ->setCapacity(10)
+ ->setUsedCapacity(10)
+ ->setStatus(EventOccurrenceStatus::CANCELLED->name);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findById')
+ ->with($occurrenceId)
+ ->andReturn($occurrence);
+
+ $this->occurrenceRepository
+ ->shouldNotReceive('updateWhere')
+ ->with(
+ ['status' => EventOccurrenceStatus::SOLD_OUT->name],
+ ['id' => $occurrenceId],
+ );
+
+ $this->service->increaseQuantitySold($priceId, 1, $occurrenceId);
+ }
+
+ protected function tearDown(): void
+ {
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/Report/ReportServiceTest.php b/backend/tests/Unit/Services/Domain/Report/ReportServiceTest.php
new file mode 100644
index 0000000000..16f356902d
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Report/ReportServiceTest.php
@@ -0,0 +1,165 @@
+cache = Mockery::mock(CacheRepository::class);
+ $this->queryBuilder = Mockery::mock(DatabaseManager::class);
+ $this->eventRepository = Mockery::mock(EventRepositoryInterface::class);
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getTimezone')->andReturn('UTC');
+
+ $this->eventRepository->shouldReceive('findById')->with(1)->andReturn($event);
+ }
+
+ private function setupCachePassthrough(): void
+ {
+ $this->cache->shouldReceive('remember')
+ ->andReturnUsing(fn($key, $ttl, $callback) => $callback());
+ }
+
+ public function testProductSalesReportGeneratesWithoutOccurrence(): void
+ {
+ $this->setupCachePassthrough();
+ $this->queryBuilder->shouldReceive('select')
+ ->once()
+ ->with(Mockery::on(fn($sql) => str_contains($sql, 'filtered_orders') && !str_contains($sql, ':occurrence_id')), ['event_id' => 1])
+ ->andReturn([]);
+
+ $report = new ProductSalesReport($this->cache, $this->queryBuilder, $this->eventRepository);
+ $result = $report->generateReport(1, Carbon::now()->subDays(30), Carbon::now());
+
+ $this->assertCount(0, $result);
+ }
+
+ public function testProductSalesReportGeneratesWithOccurrence(): void
+ {
+ $this->setupCachePassthrough();
+ $this->queryBuilder->shouldReceive('select')
+ ->once()
+ ->with(
+ Mockery::on(fn($sql) => str_contains($sql, ':occurrence_id')),
+ ['event_id' => 1, 'occurrence_id' => 10],
+ )
+ ->andReturn([]);
+
+ $report = new ProductSalesReport($this->cache, $this->queryBuilder, $this->eventRepository);
+ $result = $report->generateReport(1, Carbon::now()->subDays(30), Carbon::now(), occurrenceId: 10);
+
+ $this->assertCount(0, $result);
+ }
+
+ public function testDailySalesReportUsesEventDailyStatsWithoutOccurrence(): void
+ {
+ $this->setupCachePassthrough();
+ $this->queryBuilder->shouldReceive('select')
+ ->once()
+ ->with(
+ Mockery::on(fn($sql) => str_contains($sql, 'event_daily_statistics') && !str_contains($sql, 'event_occurrence_daily_statistics')),
+ ['event_id' => 1],
+ )
+ ->andReturn([]);
+
+ $report = new DailySalesReport($this->cache, $this->queryBuilder, $this->eventRepository);
+ $result = $report->generateReport(1, Carbon::now()->subDays(7), Carbon::now());
+
+ $this->assertCount(0, $result);
+ }
+
+ public function testDailySalesReportUsesOccurrenceDailyStatsWithOccurrence(): void
+ {
+ $this->setupCachePassthrough();
+ $this->queryBuilder->shouldReceive('select')
+ ->once()
+ ->with(
+ Mockery::on(fn($sql) => str_contains($sql, 'event_occurrence_daily_statistics') && str_contains($sql, ':occurrence_id')),
+ ['event_id' => 1, 'occurrence_id' => 10],
+ )
+ ->andReturn([]);
+
+ $report = new DailySalesReport($this->cache, $this->queryBuilder, $this->eventRepository);
+ $result = $report->generateReport(1, Carbon::now()->subDays(7), Carbon::now(), occurrenceId: 10);
+
+ $this->assertCount(0, $result);
+ }
+
+ public function testPromoCodesReportGeneratesWithOccurrence(): void
+ {
+ $this->setupCachePassthrough();
+ $this->queryBuilder->shouldReceive('select')
+ ->once()
+ ->with(
+ Mockery::on(fn($sql) => str_contains($sql, ':occurrence_id')),
+ ['event_id' => 1, 'occurrence_id' => 10],
+ )
+ ->andReturn([]);
+
+ $report = new PromoCodesReport($this->cache, $this->queryBuilder, $this->eventRepository);
+ $result = $report->generateReport(1, Carbon::now()->subDays(30), Carbon::now(), occurrenceId: 10);
+
+ $this->assertCount(0, $result);
+ }
+
+ public function testPromoCodesReportGeneratesWithoutOccurrence(): void
+ {
+ $this->setupCachePassthrough();
+ $this->queryBuilder->shouldReceive('select')
+ ->once()
+ ->with(Mockery::on(fn($sql) => !str_contains($sql, ':occurrence_id')), ['event_id' => 1])
+ ->andReturn([]);
+
+ $report = new PromoCodesReport($this->cache, $this->queryBuilder, $this->eventRepository);
+ $result = $report->generateReport(1, Carbon::now()->subDays(30), Carbon::now());
+
+ $this->assertCount(0, $result);
+ }
+
+ public function testOccurrenceSummaryReportGenerates(): void
+ {
+ $this->setupCachePassthrough();
+ $this->queryBuilder->shouldReceive('select')
+ ->once()
+ ->with(
+ Mockery::on(fn($sql) => str_contains($sql, 'event_occurrences') && str_contains($sql, 'event_occurrence_statistics')),
+ Mockery::on(fn($bindings) => $bindings['event_id'] === 1
+ && isset($bindings['start_date'])
+ && isset($bindings['end_date'])),
+ )
+ ->andReturn([
+ (object) ['occurrence_id' => 1, 'products_sold' => 5, 'total_gross' => 100],
+ ]);
+
+ $report = new OccurrenceSummaryReport($this->cache, $this->queryBuilder, $this->eventRepository);
+ $result = $report->generateReport(1);
+
+ $this->assertCount(1, $result);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/SelfService/SelfServiceResendEmailServiceTest.php b/backend/tests/Unit/Services/Domain/SelfService/SelfServiceResendEmailServiceTest.php
index ef83d435a8..a083973ac3 100644
--- a/backend/tests/Unit/Services/Domain/SelfService/SelfServiceResendEmailServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/SelfService/SelfServiceResendEmailServiceTest.php
@@ -25,11 +25,17 @@
class SelfServiceResendEmailServiceTest extends TestCase
{
private SelfServiceResendEmailService $service;
+
private MockInterface|SendAttendeeTicketService $sendAttendeeTicketService;
+
private MockInterface|SendOrderDetailsService $sendOrderDetailsService;
+
private MockInterface|AttendeeRepositoryInterface $attendeeRepository;
+
private MockInterface|OrderRepositoryInterface $orderRepository;
+
private MockInterface|EventRepositoryInterface $eventRepository;
+
private MockInterface|OrderAuditLogService $orderAuditLogService;
protected function setUp(): void
@@ -53,7 +59,7 @@ protected function setUp(): void
);
}
- public function testResendAttendeeTicketSuccessfully(): void
+ public function test_resend_attendee_ticket_successfully(): void
{
$attendeeId = 456;
$orderId = 123;
@@ -73,9 +79,12 @@ public function testResendAttendeeTicketSuccessfully(): void
$event->shouldReceive('getEventSettings')->andReturn($eventSettings);
$event->shouldReceive('getOrganizer')->andReturn($organizer);
+ // Three eager-loads: order (with nested order items), event_occurrence,
+ // and product — so the attendee-ticket email can render the occurrence
+ // date, venue, and ticket type.
$this->attendeeRepository
->shouldReceive('loadRelation')
- ->once()
+ ->times(3)
->with(Mockery::type(Relationship::class))
->andReturnSelf();
@@ -134,7 +143,7 @@ public function testResendAttendeeTicketSuccessfully(): void
$this->assertTrue(true);
}
- public function testResendOrderConfirmationSuccessfully(): void
+ public function test_resend_order_confirmation_successfully(): void
{
$orderId = 123;
$eventId = 1;
@@ -169,9 +178,12 @@ public function testResendOrderConfirmationSuccessfully(): void
])
->andReturn($order);
+ // organizer + event_settings + event_occurrences — the occurrence load is
+ // needed so OrderSummary can render the correct date for multi-occurrence
+ // orders where the primary-occurrence resolver returns null.
$this->eventRepository
->shouldReceive('loadRelation')
- ->twice()
+ ->times(3)
->andReturnSelf();
$this->eventRepository
@@ -213,7 +225,7 @@ public function testResendOrderConfirmationSuccessfully(): void
$this->assertTrue(true);
}
- public function testResendAttendeeTicketLoadsCorrectRelationships(): void
+ public function test_resend_attendee_ticket_loads_correct_relationships(): void
{
$attendeeId = 456;
$orderId = 123;
@@ -229,14 +241,12 @@ public function testResendAttendeeTicketLoadsCorrectRelationships(): void
$event->shouldReceive('getEventSettings')->andReturn($eventSettings);
$event->shouldReceive('getOrganizer')->andReturn($organizer);
+ // Order + event_occurrence + product — three nested eager-loads so
+ // the resend email can show the occurrence date and the ticket type.
$this->attendeeRepository
->shouldReceive('loadRelation')
- ->once()
- ->with(Mockery::on(function ($relationship) {
- return $relationship instanceof Relationship
- && $relationship->getDomainObject() === OrderDomainObject::class
- && $relationship->getName() === 'order';
- }))
+ ->times(3)
+ ->with(Mockery::type(Relationship::class))
->andReturnSelf();
$this->attendeeRepository
@@ -283,7 +293,7 @@ public function testResendAttendeeTicketLoadsCorrectRelationships(): void
$this->assertTrue(true);
}
- public function testResendOrderConfirmationLoadsCorrectRelationships(): void
+ public function test_resend_order_confirmation_loads_correct_relationships(): void
{
$orderId = 123;
$eventId = 1;
@@ -298,17 +308,22 @@ public function testResendOrderConfirmationLoadsCorrectRelationships(): void
$event->shouldReceive('getEventSettings')->andReturn($eventSettings);
$event->shouldReceive('getOrganizer')->andReturn($organizer);
- $loadRelationCallCount = 0;
$this->orderRepository
->shouldReceive('loadRelation')
->times(3)
- ->with(Mockery::on(function ($domainObject) use (&$loadRelationCallCount) {
- $loadRelationCallCount++;
- return in_array($domainObject, [
- OrderItemDomainObject::class,
+ ->with(Mockery::on(function ($arg) {
+ // OrderItem is now passed as a Relationship with a nested
+ // event_occurrence load so the summary email can show the
+ // occurrence date. Attendee and Invoice are still plain class
+ // strings.
+ if ($arg instanceof \HiEvents\Repository\Eloquent\Value\Relationship) {
+ return true;
+ }
+
+ return in_array($arg, [
AttendeeDomainObject::class,
InvoiceDomainObject::class,
- ]);
+ ], true);
}))
->andReturnSelf();
@@ -319,7 +334,7 @@ public function testResendOrderConfirmationLoadsCorrectRelationships(): void
$this->eventRepository
->shouldReceive('loadRelation')
- ->twice()
+ ->times(3)
->andReturnSelf();
$this->eventRepository
diff --git a/backend/tests/Unit/Services/Domain/Waitlist/CancelWaitlistEntryServiceTest.php b/backend/tests/Unit/Services/Domain/Waitlist/CancelWaitlistEntryServiceTest.php
index 208249eb4c..146c1ed3b3 100644
--- a/backend/tests/Unit/Services/Domain/Waitlist/CancelWaitlistEntryServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Waitlist/CancelWaitlistEntryServiceTest.php
@@ -119,6 +119,7 @@ public function testSuccessfullyCancelsByTokenWhenStatusIsOfferedDeletesOrder():
$entry->shouldReceive('getOrderId')->andReturn($orderId);
$entry->shouldReceive('getEventId')->andReturn(10);
$entry->shouldReceive('getProductPriceId')->andReturn(20);
+ $entry->shouldReceive('getEventOccurrenceId')->andReturn(30);
$this->waitlistEntryRepository
->shouldReceive('findFirstWhere')
@@ -161,7 +162,9 @@ public function testSuccessfullyCancelsByTokenWhenStatusIsOfferedDeletesOrder():
$this->assertEquals(WaitlistEntryStatus::CANCELLED->name, $result->getStatus());
Event::assertDispatched(CapacityChangedEvent::class, function ($event) {
- return $event->eventId === 10 && $event->productId === 99;
+ return $event->eventId === 10
+ && $event->productId === 99
+ && $event->eventOccurrenceId === 30;
});
}
diff --git a/backend/tests/Unit/Services/Domain/Waitlist/CreateWaitlistEntryServiceTest.php b/backend/tests/Unit/Services/Domain/Waitlist/CreateWaitlistEntryServiceTest.php
index 815481c98d..1c6a928a05 100644
--- a/backend/tests/Unit/Services/Domain/Waitlist/CreateWaitlistEntryServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Waitlist/CreateWaitlistEntryServiceTest.php
@@ -70,18 +70,19 @@ public function testSuccessfullyCreatesWaitlistEntryWithCorrectPosition(): void
'event_id' => 1,
['status', 'in', [WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFERED->name]],
'product_price_id' => 10,
+ 'event_occurrence_id' => null,
])
->andReturnNull();
$this->waitlistEntryRepository
->shouldReceive('lockForProductPrice')
->once()
- ->with(10);
+ ->with(10, null);
$this->waitlistEntryRepository
->shouldReceive('getMaxPosition')
->once()
- ->with(10)
+ ->with(10, null)
->andReturn(3);
$createdEntry = new WaitlistEntryDomainObject();
@@ -100,6 +101,7 @@ public function testSuccessfullyCreatesWaitlistEntryWithCorrectPosition(): void
->with(Mockery::on(function ($attributes) {
return $attributes['event_id'] === 1
&& $attributes['product_price_id'] === 10
+ && $attributes['event_occurrence_id'] === null
&& $attributes['email'] === 'test@example.com'
&& $attributes['first_name'] === 'John'
&& $attributes['last_name'] === 'Doe'
@@ -139,7 +141,7 @@ public function testPreventsDuplicateEntryForSameEmailAndProduct(): void
$this->waitlistEntryRepository
->shouldReceive('lockForProductPrice')
->once()
- ->with(10);
+ ->with(10, null);
$this->waitlistEntryRepository
->shouldReceive('findFirstWhere')
@@ -149,6 +151,7 @@ public function testPreventsDuplicateEntryForSameEmailAndProduct(): void
'event_id' => 1,
['status', 'in', [WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFERED->name]],
'product_price_id' => 10,
+ 'event_occurrence_id' => null,
])
->andReturn($existingEntry);
@@ -184,11 +187,12 @@ public function testDispatchesSendWaitlistConfirmationEmailJob(): void
$this->waitlistEntryRepository
->shouldReceive('lockForProductPrice')
->once()
- ->with(10);
+ ->with(10, null);
$this->waitlistEntryRepository
->shouldReceive('getMaxPosition')
->once()
+ ->with(10, null)
->andReturn(0);
$createdEntry = new WaitlistEntryDomainObject();
@@ -225,7 +229,7 @@ public function testPreventsDuplicateEntryWithPlusAlias(): void
$this->waitlistEntryRepository
->shouldReceive('lockForProductPrice')
->once()
- ->with(10);
+ ->with(10, null);
$this->waitlistEntryRepository
->shouldReceive('findFirstWhere')
@@ -235,6 +239,7 @@ public function testPreventsDuplicateEntryWithPlusAlias(): void
'event_id' => 1,
['status', 'in', [WaitlistEntryStatus::WAITING->name, WaitlistEntryStatus::OFFERED->name]],
'product_price_id' => 10,
+ 'event_occurrence_id' => null,
])
->andReturn($existingEntry);
diff --git a/backend/tests/Unit/Services/Domain/Waitlist/ProcessWaitlistServiceTest.php b/backend/tests/Unit/Services/Domain/Waitlist/ProcessWaitlistServiceTest.php
index e307ebba3f..8921263eaf 100644
--- a/backend/tests/Unit/Services/Domain/Waitlist/ProcessWaitlistServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Waitlist/ProcessWaitlistServiceTest.php
@@ -2,7 +2,9 @@
namespace Tests\Unit\Services\Domain\Waitlist;
+use HiEvents\DomainObjects\Enums\EventType;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
@@ -14,9 +16,11 @@
use HiEvents\Exceptions\ResourceConflictException;
use HiEvents\Exceptions\ResourceNotFoundException;
use HiEvents\Jobs\Waitlist\SendWaitlistOfferEmailJob;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
use HiEvents\Repository\Interfaces\WaitlistEntryRepositoryInterface;
+use HiEvents\Services\Domain\EventOccurrence\OccurrencePurchaseEligibilityService;
use HiEvents\Services\Domain\Order\OrderItemProcessingService;
use HiEvents\Services\Domain\Order\OrderManagementService;
use HiEvents\Services\Domain\Product\AvailableProductQuantitiesFetchService;
@@ -33,14 +37,25 @@
class ProcessWaitlistServiceTest extends TestCase
{
private ProcessWaitlistService $service;
+
private MockInterface|WaitlistEntryRepositoryInterface $waitlistEntryRepository;
+
private MockInterface|DatabaseManager $databaseManager;
+
private MockInterface|OrderManagementService $orderManagementService;
+
private MockInterface|OrderItemProcessingService $orderItemProcessingService;
+
private MockInterface|ProductRepositoryInterface $productRepository;
+
private MockInterface|AvailableProductQuantitiesFetchService $availableQuantitiesService;
+
private MockInterface|ProductPriceRepositoryInterface $productPriceRepository;
+ private MockInterface|EventOccurrenceRepositoryInterface $eventOccurrenceRepository;
+
+ private MockInterface|OccurrencePurchaseEligibilityService $eligibilityService;
+
protected function setUp(): void
{
parent::setUp();
@@ -52,6 +67,39 @@ protected function setUp(): void
$this->productRepository = Mockery::mock(ProductRepositoryInterface::class);
$this->availableQuantitiesService = Mockery::mock(AvailableProductQuantitiesFetchService::class);
$this->productPriceRepository = Mockery::mock(ProductPriceRepositoryInterface::class);
+ $this->eventOccurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->eligibilityService = Mockery::mock(OccurrencePurchaseEligibilityService::class);
+
+ // Default to "always eligible" for the existing happy-path tests; the
+ // dedicated past/cancelled/visibility tests override these on the spy.
+ // assertOccurrencePurchasable's return type is the loaded domain object,
+ // so we need a real instance — null would TypeError before the test
+ // assertion can run.
+ $defaultEligibilityOccurrence = new EventOccurrenceDomainObject;
+ $defaultEligibilityOccurrence->setId(50);
+ $defaultEligibilityOccurrence->setEventId(1);
+ $this->eligibilityService
+ ->shouldReceive('assertOccurrencePurchasable')
+ ->zeroOrMoreTimes()
+ ->andReturn($defaultEligibilityOccurrence)
+ ->byDefault();
+ $this->eligibilityService
+ ->shouldReceive('assertProductsVisibleOnOccurrence')
+ ->zeroOrMoreTimes()
+ ->andReturnNull()
+ ->byDefault();
+
+ // The eligibility helper looks up the productPrice to feed the visibility
+ // check; tests that don't reach offerEntry don't bother re-mocking this
+ // path so we provide a benign default that resolves to a synthetic price.
+ $defaultProductPrice = new ProductPriceDomainObject;
+ $defaultProductPrice->setId(0);
+ $defaultProductPrice->setProductId(0);
+ $this->productPriceRepository
+ ->shouldReceive('findById')
+ ->zeroOrMoreTimes()
+ ->andReturn($defaultProductPrice)
+ ->byDefault();
$this->waitlistEntryRepository
->shouldReceive('lockForProductPrice')
@@ -65,6 +113,16 @@ protected function setUp(): void
->zeroOrMoreTimes()
->andReturn(true);
+ $occurrence = new EventOccurrenceDomainObject;
+ $occurrence->setId(50);
+ $occurrence->setEventId(1);
+
+ $this->eventOccurrenceRepository
+ ->shouldReceive('findWhere')
+ ->zeroOrMoreTimes()
+ ->andReturn(collect([$occurrence]))
+ ->byDefault();
+
$this->service = new ProcessWaitlistService(
waitlistEntryRepository: $this->waitlistEntryRepository,
databaseManager: $this->databaseManager,
@@ -73,29 +131,34 @@ protected function setUp(): void
productRepository: $this->productRepository,
availableQuantitiesService: $this->availableQuantitiesService,
productPriceRepository: $this->productPriceRepository,
+ eventOccurrenceRepository: $this->eventOccurrenceRepository,
+ eligibilityService: $this->eligibilityService,
);
}
private function createMockEvent(int $id = 1, string $currency = 'USD'): EventDomainObject
{
- $event = new EventDomainObject();
+ $event = new EventDomainObject;
$event->setId($id);
$event->setCurrency($currency);
+ $event->setType(EventType::SINGLE->name);
+
return $event;
}
private function createMockEventSettings(?int $timeoutMinutes = 30): EventSettingDomainObject
{
- $eventSettings = new EventSettingDomainObject();
+ $eventSettings = new EventSettingDomainObject;
$eventSettings->setWaitlistOfferTimeoutMinutes($timeoutMinutes);
+
return $eventSettings;
}
- private function mockAvailableQuantities(int $eventId, int $priceId, int $quantityAvailable = 10): void
+ private function mockAvailableQuantities(int $eventId, int $priceId, int $quantityAvailable = 10, ?int $occurrenceId = 50): void
{
$this->availableQuantitiesService
->shouldReceive('getAvailableProductQuantities')
- ->with($eventId, true)
+ ->with($eventId, true, $occurrenceId)
->andReturn(new AvailableProductQuantitiesResponseDTO(
productQuantities: collect([
new AvailableProductQuantitiesDTO(
@@ -113,7 +176,7 @@ private function mockAvailableQuantities(int $eventId, int $priceId, int $quanti
private function mockOrderCreation(): OrderDomainObject
{
- $order = new OrderDomainObject();
+ $order = new OrderDomainObject;
$order->setId(100);
$order->setShortId('o_test123');
@@ -122,11 +185,12 @@ private function mockOrderCreation(): OrderDomainObject
->once()
->withArgs(function () {
$args = func_get_args();
- return count($args) >= 7 && is_string($args[6]) && !empty($args[6]);
+
+ return count($args) >= 7 && is_string($args[6]) && ! empty($args[6]);
})
->andReturn($order);
- $productPrice = new ProductPriceDomainObject();
+ $productPrice = new ProductPriceDomainObject;
$productPrice->setId(1);
$productPrice->setProductId(10);
@@ -134,7 +198,7 @@ private function mockOrderCreation(): OrderDomainObject
->shouldReceive('findById')
->andReturn($productPrice);
- $product = new ProductDomainObject();
+ $product = new ProductDomainObject;
$product->setId(10);
$product->setProductPrices(new Collection([$productPrice]));
@@ -145,7 +209,7 @@ private function mockOrderCreation(): OrderDomainObject
->shouldReceive('findById')
->andReturn($product);
- $orderItem = new OrderItemDomainObject();
+ $orderItem = new OrderItemDomainObject;
$this->orderItemProcessingService
->shouldReceive('process')
->once()
@@ -159,7 +223,7 @@ private function mockOrderCreation(): OrderDomainObject
return $order;
}
- public function testSuccessfullyOffersToNextWaitingEntry(): void
+ public function test_successfully_offers_to_next_waiting_entry(): void
{
Bus::fake();
@@ -181,11 +245,12 @@ public function testSuccessfullyOffersToNextWaitingEntry(): void
$waitingEntry->shouldReceive('getId')->andReturn(1);
$waitingEntry->shouldReceive('getLocale')->andReturn('en');
$waitingEntry->shouldReceive('getProductPriceId')->andReturn($productPriceId);
+ $waitingEntry->shouldReceive('getEventOccurrenceId')->andReturn(null);
$this->waitlistEntryRepository
->shouldReceive('getNextWaitingEntries')
->once()
- ->with($productPriceId, Mockery::any())
+ ->with($productPriceId)
->andReturn(new Collection([$waitingEntry]));
$order = $this->mockOrderCreation();
@@ -196,7 +261,7 @@ public function testSuccessfullyOffersToNextWaitingEntry(): void
->with(
Mockery::on(function ($attributes) use ($order) {
return $attributes['status'] === WaitlistEntryStatus::OFFERED->name
- && !empty($attributes['offer_token'])
+ && ! empty($attributes['offer_token'])
&& $attributes['offered_at'] !== null
&& $attributes['offer_expires_at'] !== null
&& $attributes['order_id'] === $order->getId();
@@ -204,7 +269,7 @@ public function testSuccessfullyOffersToNextWaitingEntry(): void
['id' => 1],
);
- $updatedEntry = new WaitlistEntryDomainObject();
+ $updatedEntry = new WaitlistEntryDomainObject;
$updatedEntry->setId(1);
$updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name);
$updatedEntry->setOfferToken('some-token');
@@ -225,11 +290,12 @@ public function testSuccessfullyOffersToNextWaitingEntry(): void
Bus::assertDispatched(SendWaitlistOfferEmailJob::class, function ($job) {
$reflection = new \ReflectionClass($job);
$sessionProp = $reflection->getProperty('sessionIdentifier');
- return !empty($sessionProp->getValue($job));
+
+ return ! empty($sessionProp->getValue($job));
});
}
- public function testSetsCorrectOfferTokenAndOfferExpiresAt(): void
+ public function test_sets_correct_offer_token_and_offer_expires_at(): void
{
Bus::fake();
@@ -252,11 +318,12 @@ public function testSetsCorrectOfferTokenAndOfferExpiresAt(): void
$waitingEntry->shouldReceive('getId')->andReturn(5);
$waitingEntry->shouldReceive('getLocale')->andReturn('en');
$waitingEntry->shouldReceive('getProductPriceId')->andReturn($productPriceId);
+ $waitingEntry->shouldReceive('getEventOccurrenceId')->andReturn(null);
$this->waitlistEntryRepository
->shouldReceive('getNextWaitingEntries')
->once()
- ->with($productPriceId, Mockery::any())
+ ->with($productPriceId)
->andReturn(new Collection([$waitingEntry]));
$this->mockOrderCreation();
@@ -268,12 +335,13 @@ public function testSetsCorrectOfferTokenAndOfferExpiresAt(): void
->with(
Mockery::on(function ($attributes) use (&$capturedAttributes) {
$capturedAttributes = $attributes;
+
return true;
}),
['id' => 5],
);
- $updatedEntry = new WaitlistEntryDomainObject();
+ $updatedEntry = new WaitlistEntryDomainObject;
$updatedEntry->setId(5);
$updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name);
@@ -293,7 +361,7 @@ public function testSetsCorrectOfferTokenAndOfferExpiresAt(): void
$this->assertNotNull($capturedAttributes['order_id']);
}
- public function testCreatesReservedOrderWhenOffering(): void
+ public function test_creates_reserved_order_when_offering(): void
{
Bus::fake();
@@ -315,14 +383,15 @@ public function testCreatesReservedOrderWhenOffering(): void
$waitingEntry->shouldReceive('getId')->andReturn(1);
$waitingEntry->shouldReceive('getLocale')->andReturn('en');
$waitingEntry->shouldReceive('getProductPriceId')->andReturn($productPriceId);
+ $waitingEntry->shouldReceive('getEventOccurrenceId')->andReturn(null);
$this->waitlistEntryRepository
->shouldReceive('getNextWaitingEntries')
->once()
- ->with($productPriceId, Mockery::any())
+ ->with($productPriceId)
->andReturn(new Collection([$waitingEntry]));
- $order = new OrderDomainObject();
+ $order = new OrderDomainObject;
$order->setId(100);
$order->setShortId('o_test123');
@@ -330,17 +399,17 @@ public function testCreatesReservedOrderWhenOffering(): void
->shouldReceive('createNewOrder')
->once()
->with(
- Mockery::on(fn($v) => $v === $event->getId()),
- Mockery::on(fn($v) => $v instanceof EventDomainObject),
- Mockery::on(fn($v) => $v === 30),
- Mockery::on(fn($v) => $v === 'en'),
- Mockery::on(fn($v) => $v === null),
- Mockery::on(fn($v) => $v === null),
- Mockery::on(fn($v) => is_string($v) && !empty($v)),
+ Mockery::on(fn ($v) => $v === $event->getId()),
+ Mockery::on(fn ($v) => $v instanceof EventDomainObject),
+ Mockery::on(fn ($v) => $v === 30),
+ Mockery::on(fn ($v) => $v === 'en'),
+ Mockery::on(fn ($v) => $v === null),
+ Mockery::on(fn ($v) => $v === null),
+ Mockery::on(fn ($v) => is_string($v) && ! empty($v)),
)
->andReturn($order);
- $productPrice = new ProductPriceDomainObject();
+ $productPrice = new ProductPriceDomainObject;
$productPrice->setId(1);
$productPrice->setProductId(10);
@@ -348,7 +417,7 @@ public function testCreatesReservedOrderWhenOffering(): void
->shouldReceive('findById')
->andReturn($productPrice);
- $product = new ProductDomainObject();
+ $product = new ProductDomainObject;
$product->setId(10);
$product->setProductPrices(new Collection([$productPrice]));
@@ -360,7 +429,7 @@ public function testCreatesReservedOrderWhenOffering(): void
->with(10)
->andReturn($product);
- $orderItem = new OrderItemDomainObject();
+ $orderItem = new OrderItemDomainObject;
$this->orderItemProcessingService
->shouldReceive('process')
->once()
@@ -375,11 +444,11 @@ public function testCreatesReservedOrderWhenOffering(): void
->shouldReceive('updateWhere')
->once()
->with(
- Mockery::on(fn($attrs) => $attrs['order_id'] === 100),
+ Mockery::on(fn ($attrs) => $attrs['order_id'] === 100),
['id' => 1],
);
- $updatedEntry = new WaitlistEntryDomainObject();
+ $updatedEntry = new WaitlistEntryDomainObject;
$updatedEntry->setId(1);
$updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name);
$updatedEntry->setOrderId(100);
@@ -395,7 +464,109 @@ public function testCreatesReservedOrderWhenOffering(): void
$this->assertEquals(100, $result->first()->getOrderId());
}
- public function testThrowsWhenNoWaitingEntries(): void
+ public function test_single_event_waitlist_reserved_order_uses_hidden_occurrence(): void
+ {
+ Bus::fake();
+
+ $productPriceId = 10;
+ $occurrenceId = 321;
+ $event = $this->createMockEvent(id: 44);
+ $eventSettings = $this->createMockEventSettings(30);
+
+ $occurrence = new EventOccurrenceDomainObject;
+ $occurrence->setId($occurrenceId);
+ $occurrence->setEventId($event->getId());
+
+ $this->eventOccurrenceRepository
+ ->shouldReceive('findWhere')
+ ->twice()
+ ->andReturn(collect([$occurrence]));
+
+ $this->databaseManager
+ ->shouldReceive('transaction')
+ ->once()
+ ->andReturnUsing(fn ($callback) => $callback());
+
+ $this->mockAvailableQuantities($event->getId(), $productPriceId, occurrenceId: $occurrenceId);
+
+ $waitingEntry = Mockery::mock(WaitlistEntryDomainObject::class);
+ $waitingEntry->shouldReceive('getId')->andReturn(1);
+ $waitingEntry->shouldReceive('getLocale')->andReturn('en');
+ $waitingEntry->shouldReceive('getProductPriceId')->andReturn($productPriceId);
+ $waitingEntry->shouldReceive('getEventOccurrenceId')->andReturn(null);
+
+ $this->waitlistEntryRepository
+ ->shouldReceive('getNextWaitingEntries')
+ ->once()
+ ->with($productPriceId)
+ ->andReturn(new Collection([$waitingEntry]));
+
+ $order = new OrderDomainObject;
+ $order->setId(100);
+ $order->setShortId('o_test123');
+
+ $this->orderManagementService
+ ->shouldReceive('createNewOrder')
+ ->once()
+ ->andReturn($order);
+
+ $productPrice = new ProductPriceDomainObject;
+ $productPrice->setId($productPriceId);
+ $productPrice->setProductId(10);
+
+ $this->productPriceRepository
+ ->shouldReceive('findById')
+ ->andReturn($productPrice);
+
+ $product = new ProductDomainObject;
+ $product->setId(10);
+ $product->setProductPrices(new Collection([$productPrice]));
+
+ $this->productRepository
+ ->shouldReceive('loadRelation')
+ ->andReturnSelf();
+ $this->productRepository
+ ->shouldReceive('findById')
+ ->with(10)
+ ->andReturn($product);
+
+ $capturedOccurrenceId = null;
+ $orderItem = new OrderItemDomainObject;
+ $this->orderItemProcessingService
+ ->shouldReceive('process')
+ ->once()
+ ->withArgs(function ($orderArg, Collection $productsOrderDetails) use ($order, $occurrenceId, &$capturedOccurrenceId) {
+ $capturedOccurrenceId = $productsOrderDetails->first()->event_occurrence_id;
+
+ return $orderArg === $order && $capturedOccurrenceId === $occurrenceId;
+ })
+ ->andReturn(new Collection([$orderItem]));
+
+ $this->orderManagementService
+ ->shouldReceive('updateOrderTotals')
+ ->once()
+ ->andReturn($order);
+
+ $this->waitlistEntryRepository
+ ->shouldReceive('updateWhere')
+ ->once();
+
+ $updatedEntry = new WaitlistEntryDomainObject;
+ $updatedEntry->setId(1);
+ $updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name);
+ $updatedEntry->setOrderId(100);
+
+ $this->waitlistEntryRepository
+ ->shouldReceive('findById')
+ ->once()
+ ->andReturn($updatedEntry);
+
+ $this->service->offerToNext($productPriceId, 1, $event, $eventSettings);
+
+ $this->assertSame($occurrenceId, $capturedOccurrenceId);
+ }
+
+ public function test_throws_when_no_waiting_entries(): void
{
$productPriceId = 10;
$quantity = 2;
@@ -409,20 +580,18 @@ public function testThrowsWhenNoWaitingEntries(): void
return $callback();
});
- $this->mockAvailableQuantities($event->getId(), $productPriceId);
-
$this->waitlistEntryRepository
->shouldReceive('getNextWaitingEntries')
->once()
- ->with($productPriceId, Mockery::any())
- ->andReturn(new Collection());
+ ->with($productPriceId)
+ ->andReturn(new Collection);
$this->expectException(NoCapacityAvailableException::class);
$this->service->offerToNext($productPriceId, $quantity, $event, $eventSettings);
}
- public function testCapsOffersAtAvailableCapacity(): void
+ public function test_caps_offers_at_available_capacity(): void
{
Bus::fake();
@@ -444,11 +613,12 @@ public function testCapsOffersAtAvailableCapacity(): void
$waitingEntry->shouldReceive('getId')->andReturn(1);
$waitingEntry->shouldReceive('getLocale')->andReturn('en');
$waitingEntry->shouldReceive('getProductPriceId')->andReturn($productPriceId);
+ $waitingEntry->shouldReceive('getEventOccurrenceId')->andReturn(null);
$this->waitlistEntryRepository
->shouldReceive('getNextWaitingEntries')
->once()
- ->with($productPriceId, Mockery::any())
+ ->with($productPriceId)
->andReturn(new Collection([$waitingEntry]));
$this->mockOrderCreation();
@@ -457,7 +627,7 @@ public function testCapsOffersAtAvailableCapacity(): void
->shouldReceive('updateWhere')
->once();
- $updatedEntry = new WaitlistEntryDomainObject();
+ $updatedEntry = new WaitlistEntryDomainObject;
$updatedEntry->setId(1);
$updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name);
@@ -471,7 +641,101 @@ public function testCapsOffersAtAvailableCapacity(): void
$this->assertCount(1, $result);
}
- public function testThrowsWhenNoCapacityAtAll(): void
+ public function test_offer_to_next_skips_full_occurrence_and_offers_later_eligible_entry(): void
+ {
+ Bus::fake();
+
+ $productPriceId = 10;
+ $event = $this->createMockEvent();
+ $event->setType(EventType::RECURRING->name);
+ $eventSettings = $this->createMockEventSettings();
+
+ $this->databaseManager
+ ->shouldReceive('transaction')
+ ->once()
+ ->andReturnUsing(fn ($callback) => $callback());
+
+ $fullOccurrenceEntry = new WaitlistEntryDomainObject;
+ $fullOccurrenceEntry->setId(1);
+ $fullOccurrenceEntry->setLocale('en');
+ $fullOccurrenceEntry->setProductPriceId($productPriceId);
+ $fullOccurrenceEntry->setEventOccurrenceId(11);
+
+ $eligibleEntry = new WaitlistEntryDomainObject;
+ $eligibleEntry->setId(2);
+ $eligibleEntry->setLocale('en');
+ $eligibleEntry->setProductPriceId($productPriceId);
+ $eligibleEntry->setEventOccurrenceId(22);
+
+ $this->waitlistEntryRepository
+ ->shouldReceive('getNextWaitingEntries')
+ ->once()
+ ->with($productPriceId)
+ ->andReturn(new Collection([$fullOccurrenceEntry, $eligibleEntry]));
+
+ $this->mockAvailableQuantities($event->getId(), $productPriceId, 0, 11);
+ $this->mockAvailableQuantities($event->getId(), $productPriceId, 1, 22);
+
+ $order = new OrderDomainObject;
+ $order->setId(100);
+ $order->setShortId('o_test123');
+
+ $this->orderManagementService
+ ->shouldReceive('createNewOrder')
+ ->once()
+ ->andReturn($order);
+
+ $productPrice = new ProductPriceDomainObject;
+ $productPrice->setId($productPriceId);
+ $productPrice->setProductId(10);
+
+ $this->productPriceRepository
+ ->shouldReceive('findById')
+ ->andReturn($productPrice);
+
+ $product = new ProductDomainObject;
+ $product->setId(10);
+ $product->setProductPrices(new Collection([$productPrice]));
+
+ $this->productRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->productRepository->shouldReceive('findById')->andReturn($product);
+
+ $this->orderItemProcessingService
+ ->shouldReceive('process')
+ ->once()
+ ->withArgs(function ($orderArg, Collection $productsOrderDetails) use ($order) {
+ return $orderArg === $order
+ && $productsOrderDetails->first()->event_occurrence_id === 22;
+ })
+ ->andReturn(new Collection([new OrderItemDomainObject]));
+
+ $this->orderManagementService
+ ->shouldReceive('updateOrderTotals')
+ ->once()
+ ->andReturn($order);
+
+ $this->waitlistEntryRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(Mockery::any(), ['id' => 2]);
+
+ $updatedEntry = new WaitlistEntryDomainObject;
+ $updatedEntry->setId(2);
+ $updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name);
+
+ $this->waitlistEntryRepository
+ ->shouldReceive('findById')
+ ->once()
+ ->with(2)
+ ->andReturn($updatedEntry);
+
+ $result = $this->service->offerToNext($productPriceId, 1, $event, $eventSettings);
+
+ $this->assertCount(1, $result);
+ $this->assertSame(2, $result->first()->getId());
+ }
+
+ public function test_throws_when_no_capacity_at_all(): void
{
$productPriceId = 10;
$quantity = 2;
@@ -487,12 +751,22 @@ public function testThrowsWhenNoCapacityAtAll(): void
$this->mockAvailableQuantities($event->getId(), $productPriceId, 0);
+ $waitingEntry = Mockery::mock(WaitlistEntryDomainObject::class);
+ $waitingEntry->shouldReceive('getProductPriceId')->andReturn($productPriceId);
+ $waitingEntry->shouldReceive('getEventOccurrenceId')->andReturn(null);
+
+ $this->waitlistEntryRepository
+ ->shouldReceive('getNextWaitingEntries')
+ ->once()
+ ->with($productPriceId)
+ ->andReturn(new Collection([$waitingEntry]));
+
$this->expectException(NoCapacityAvailableException::class);
$this->service->offerToNext($productPriceId, $quantity, $event, $eventSettings);
}
- public function testOfferExpiresAtUsesDefaultWhenTimeoutNotSet(): void
+ public function test_offer_expires_at_uses_default_when_timeout_not_set(): void
{
Bus::fake();
@@ -514,11 +788,12 @@ public function testOfferExpiresAtUsesDefaultWhenTimeoutNotSet(): void
$waitingEntry->shouldReceive('getId')->andReturn(1);
$waitingEntry->shouldReceive('getLocale')->andReturn('en');
$waitingEntry->shouldReceive('getProductPriceId')->andReturn($productPriceId);
+ $waitingEntry->shouldReceive('getEventOccurrenceId')->andReturn(null);
$this->waitlistEntryRepository
->shouldReceive('getNextWaitingEntries')
->once()
- ->with($productPriceId, Mockery::any())
+ ->with($productPriceId)
->andReturn(new Collection([$waitingEntry]));
$this->mockOrderCreation();
@@ -530,12 +805,13 @@ public function testOfferExpiresAtUsesDefaultWhenTimeoutNotSet(): void
->with(
Mockery::on(function ($attributes) use (&$capturedAttributes) {
$capturedAttributes = $attributes;
+
return true;
}),
['id' => 1],
);
- $updatedEntry = new WaitlistEntryDomainObject();
+ $updatedEntry = new WaitlistEntryDomainObject;
$updatedEntry->setId(1);
$updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name);
@@ -549,7 +825,7 @@ public function testOfferExpiresAtUsesDefaultWhenTimeoutNotSet(): void
$this->assertNotNull($capturedAttributes['offer_expires_at']);
}
- public function testOfferSpecificEntrySuccessfully(): void
+ public function test_offer_specific_entry_successfully(): void
{
Bus::fake();
@@ -566,7 +842,7 @@ public function testOfferSpecificEntrySuccessfully(): void
return $callback();
});
- $entry = new WaitlistEntryDomainObject();
+ $entry = new WaitlistEntryDomainObject;
$entry->setId($entryId);
$entry->setStatus(WaitlistEntryStatus::WAITING->name);
$entry->setLocale('en');
@@ -588,14 +864,14 @@ public function testOfferSpecificEntrySuccessfully(): void
->with(
Mockery::on(function ($attributes) use ($order) {
return $attributes['status'] === WaitlistEntryStatus::OFFERED->name
- && !empty($attributes['offer_token'])
+ && ! empty($attributes['offer_token'])
&& $attributes['offered_at'] !== null
&& $attributes['order_id'] === $order->getId();
}),
['id' => $entryId],
);
- $updatedEntry = new WaitlistEntryDomainObject();
+ $updatedEntry = new WaitlistEntryDomainObject;
$updatedEntry->setId($entryId);
$updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name);
$updatedEntry->setOrderId($order->getId());
@@ -614,7 +890,7 @@ public function testOfferSpecificEntrySuccessfully(): void
Bus::assertDispatched(SendWaitlistOfferEmailJob::class);
}
- public function testOfferSpecificEntryThrowsWhenEntryNotFound(): void
+ public function test_offer_specific_entry_throws_when_entry_not_found(): void
{
$entryId = 99;
$eventId = 1;
@@ -639,7 +915,7 @@ public function testOfferSpecificEntryThrowsWhenEntryNotFound(): void
$this->service->offerSpecificEntry($entryId, $eventId, $event, $eventSettings);
}
- public function testOfferSpecificEntryThrowsWhenStatusNotOfferable(): void
+ public function test_offer_specific_entry_throws_when_status_not_offerable(): void
{
$entryId = 7;
$eventId = 1;
@@ -653,7 +929,7 @@ public function testOfferSpecificEntryThrowsWhenStatusNotOfferable(): void
return $callback();
});
- $entry = new WaitlistEntryDomainObject();
+ $entry = new WaitlistEntryDomainObject;
$entry->setId($entryId);
$entry->setStatus(WaitlistEntryStatus::PURCHASED->name);
@@ -667,7 +943,7 @@ public function testOfferSpecificEntryThrowsWhenStatusNotOfferable(): void
$this->service->offerSpecificEntry($entryId, $eventId, $event, $eventSettings);
}
- public function testOfferSpecificEntryAllowsReOfferForExpiredEntries(): void
+ public function test_offer_specific_entry_allows_re_offer_for_expired_entries(): void
{
Bus::fake();
@@ -684,7 +960,7 @@ public function testOfferSpecificEntryAllowsReOfferForExpiredEntries(): void
return $callback();
});
- $entry = new WaitlistEntryDomainObject();
+ $entry = new WaitlistEntryDomainObject;
$entry->setId($entryId);
$entry->setStatus(WaitlistEntryStatus::OFFER_EXPIRED->name);
$entry->setLocale('en');
@@ -703,7 +979,7 @@ public function testOfferSpecificEntryAllowsReOfferForExpiredEntries(): void
->shouldReceive('updateWhere')
->once();
- $updatedEntry = new WaitlistEntryDomainObject();
+ $updatedEntry = new WaitlistEntryDomainObject;
$updatedEntry->setId($entryId);
$updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name);
@@ -718,7 +994,7 @@ public function testOfferSpecificEntryAllowsReOfferForExpiredEntries(): void
Bus::assertDispatched(SendWaitlistOfferEmailJob::class);
}
- public function testOfferSpecificEntryThrowsWhenNoCapacityAvailable(): void
+ public function test_offer_specific_entry_throws_when_no_capacity_available(): void
{
$entryId = 7;
$eventId = 1;
@@ -733,7 +1009,7 @@ public function testOfferSpecificEntryThrowsWhenNoCapacityAvailable(): void
return $callback();
});
- $entry = new WaitlistEntryDomainObject();
+ $entry = new WaitlistEntryDomainObject;
$entry->setId($entryId);
$entry->setStatus(WaitlistEntryStatus::WAITING->name);
$entry->setLocale('en');
@@ -752,7 +1028,7 @@ public function testOfferSpecificEntryThrowsWhenNoCapacityAvailable(): void
$this->service->offerSpecificEntry($entryId, $eventId, $event, $eventSettings);
}
- public function testOfferSpecificEntryThrowsWhenCapacityFullyOffered(): void
+ public function test_offer_specific_entry_throws_when_capacity_fully_offered(): void
{
$entryId = 7;
$eventId = 1;
@@ -767,7 +1043,7 @@ public function testOfferSpecificEntryThrowsWhenCapacityFullyOffered(): void
return $callback();
});
- $entry = new WaitlistEntryDomainObject();
+ $entry = new WaitlistEntryDomainObject;
$entry->setId($entryId);
$entry->setStatus(WaitlistEntryStatus::WAITING->name);
$entry->setLocale('en');
diff --git a/docker/development/docker-compose.dev.yml b/docker/development/docker-compose.dev.yml
index 1c605156cd..ba5683104d 100644
--- a/docker/development/docker-compose.dev.yml
+++ b/docker/development/docker-compose.dev.yml
@@ -109,8 +109,11 @@ services:
POSTGRES_DB: '${DB_DATABASE}'
POSTGRES_USER: '${DB_USERNAME}'
POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}'
+ TEST_DB_NAME: '${TEST_DB_NAME:-hievents_test}'
volumes:
- 'app-pgsql:/var/lib/postgresql/data'
+ # Init scripts run once on a fresh data volume — creates hievents_test.
+ - './pgsql-init:/docker-entrypoint-initdb.d:ro'
networks:
- app
healthcheck:
diff --git a/docker/development/pgsql-init/01-create-test-db.sh b/docker/development/pgsql-init/01-create-test-db.sh
new file mode 100755
index 0000000000..0fa09e4cdd
--- /dev/null
+++ b/docker/development/pgsql-init/01-create-test-db.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+# Postgres entrypoint init script — runs once on a fresh data volume.
+# Creates the hievents_test database used by the test suite (the BaseRepositoryTest
+# guard refuses to run against any database whose name does not end in _test).
+#
+# Idempotent: existing test DBs are left alone.
+
+set -euo pipefail
+
+TEST_DB="${TEST_DB_NAME:-hievents_test}"
+
+psql -v ON_ERROR_STOP=1 --username "${POSTGRES_USER}" --dbname "${POSTGRES_DB}" <<-EOSQL
+ SELECT 'CREATE DATABASE ${TEST_DB} OWNER ${POSTGRES_USER}'
+ WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${TEST_DB}')\gexec
+EOSQL
+
+echo "Test database '${TEST_DB}' is ready."
diff --git a/docker/development/start-dev.sh b/docker/development/start-dev.sh
index 599ae442f8..53a3289a07 100755
--- a/docker/development/start-dev.sh
+++ b/docker/development/start-dev.sh
@@ -5,36 +5,95 @@ CERTS_FLAG="$1"
RED='\033[0;31m'
GREEN='\033[0;32m'
-BG_BLACK='\033[40m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+CYAN='\033[0;36m'
+MAGENTA='\033[0;35m'
+BOLD='\033[1m'
+DIM='\033[2m'
NC='\033[0m' # No Color
CERTS_DIR="./certs"
-echo -e "${GREEN}${BG_BLACK}Installing Hi.Events...${NC}"
+print_banner() {
+ echo ""
+ echo -e "${CYAN}${BOLD} ╔═══════════════════════════════════════════╗${NC}"
+ echo -e "${CYAN}${BOLD} ║ ║${NC}"
+ echo -e "${CYAN}${BOLD} ║ ${MAGENTA}Hi.Events Dev Launcher${CYAN} ║${NC}"
+ echo -e "${CYAN}${BOLD} ║ ║${NC}"
+ echo -e "${CYAN}${BOLD} ╚═══════════════════════════════════════════╝${NC}"
+ echo ""
+}
+
+step() {
+ echo -e "${BLUE}${BOLD}▶${NC} ${BOLD}$1${NC}"
+}
+
+info() {
+ echo -e " ${DIM}$1${NC}"
+}
+
+ok() {
+ echo -e " ${GREEN}✓${NC} $1"
+}
+
+warn() {
+ echo -e " ${YELLOW}⚠${NC} $1"
+}
+
+fail() {
+ echo -e " ${RED}✗${NC} $1"
+}
+
+# Prompt yes/no. $1 = question, $2 = default ("y" or "n")
+ask_yes_no() {
+ local prompt="$1"
+ local default="$2"
+ local hint
+ if [ "$default" = "y" ]; then
+ hint="${BOLD}Y${NC}/n"
+ else
+ hint="y/${BOLD}N${NC}"
+ fi
+ while true; do
+ echo -ne "${YELLOW}?${NC} ${BOLD}$prompt${NC} [$hint] "
+ read -r reply
+ reply="${reply:-$default}"
+ case "$reply" in
+ [Yy]*) return 0 ;;
+ [Nn]*) return 1 ;;
+ *) echo -e " ${DIM}Please answer y or n.${NC}" ;;
+ esac
+ done
+}
+
+print_banner
mkdir -p "$CERTS_DIR"
generate_unsigned_certs() {
if [ ! -f "$CERTS_DIR/localhost.crt" ] || [ ! -f "$CERTS_DIR/localhost.key" ]; then
- echo -e "${GREEN}Generating unsigned SSL certificates...${NC}"
- openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout "$CERTS_DIR/localhost.key" -out "$CERTS_DIR/localhost.crt" -subj "/CN=localhost"
+ step "Generating unsigned SSL certificates"
+ openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout "$CERTS_DIR/localhost.key" -out "$CERTS_DIR/localhost.crt" -subj "/CN=localhost" > /dev/null 2>&1
+ ok "Certificates generated"
else
- echo -e "${GREEN}SSL certificates already exist, skipping generation...${NC}"
+ ok "SSL certificates already exist"
fi
}
generate_signed_certs() {
if [ ! -f "$CERTS_DIR/localhost.crt" ] || [ ! -f "$CERTS_DIR/localhost.key" ]; then
if ! command -v mkcert &> /dev/null; then
- echo -e "${RED}mkcert is not installed.${NC}"
- echo "Please install mkcert by following the instructions at: https://github.com/FiloSottile/mkcert#installation"
- echo "Alternatively, you can generate unsigned certificates by using '--certs=unsigned' or omitting the --certs flag."
+ fail "mkcert is not installed."
+ info "Install via https://github.com/FiloSottile/mkcert#installation"
+ info "Or use unsigned certs: '--certs=unsigned' (or omit --certs)"
exit 1
else
- echo -e "${GREEN}Generating signed SSL certificates with mkcert...${NC}"
- mkcert -key-file "$CERTS_DIR/localhost.key" -cert-file "$CERTS_DIR/localhost.crt" localhost 127.0.0.1 ::1
+ step "Generating signed SSL certificates with mkcert"
+ mkcert -key-file "$CERTS_DIR/localhost.key" -cert-file "$CERTS_DIR/localhost.crt" localhost 127.0.0.1 ::1 > /dev/null 2>&1
+ ok "Certificates generated"
fi
else
- echo -e "${GREEN}SSL certificates already exist, skipping generation...${NC}"
+ ok "SSL certificates already exist"
fi
}
@@ -47,33 +106,72 @@ case "$CERTS_FLAG" in
;;
esac
-$COMPOSE_CMD up -d
+echo ""
+step "Setup options"
-if [ $? -ne 0 ]; then
- echo -e "${RED}Failed to start services with docker-compose.${NC}"
- exit 1
+WIPE_DB=false
+if ask_yes_no "Wipe the database and start fresh?" "n"; then
+ WIPE_DB=true
+ warn "Database will be wiped on startup"
+else
+ info "Keeping existing database"
fi
-echo -e "${GREEN}Running composer install in the backend service...${NC}"
+REINSTALL_DEPS=true
+if ask_yes_no "Reinstall frontend dependencies (yarn install)?" "y"; then
+ REINSTALL_DEPS=true
+ info "Frontend image will be rebuilt with fresh deps"
+else
+ REINSTALL_DEPS=false
+ info "Skipping frontend dependency reinstall"
+fi
+
+echo ""
+
+if [ "$WIPE_DB" = true ]; then
+ step "Tearing down existing containers and volumes"
+ $COMPOSE_CMD down -v > /dev/null 2>&1
+ ok "Containers and volumes removed"
+elif [ "$REINSTALL_DEPS" = true ]; then
+ step "Removing frontend container to refresh node_modules"
+ $COMPOSE_CMD rm -sfv frontend > /dev/null 2>&1
+ ok "Frontend container removed"
+fi
+
+if [ "$REINSTALL_DEPS" = true ]; then
+ step "Rebuilding frontend image (running yarn install)"
+ if ! $COMPOSE_CMD build frontend; then
+ fail "Frontend image build failed"
+ exit 1
+ fi
+ ok "Frontend image rebuilt"
+fi
+
+step "Starting services"
+if ! $COMPOSE_CMD up -d; then
+ fail "Failed to start services with docker compose."
+ exit 1
+fi
+ok "Services started"
-$COMPOSE_CMD exec -T backend composer install \
+step "Running composer install in the backend service"
+if ! $COMPOSE_CMD exec -T backend composer install \
--ignore-platform-reqs \
--no-interaction \
--optimize-autoloader \
- --prefer-dist
-
-if [ $? -ne 0 ]; then
- echo -e "${RED}Composer install failed within the backend service.${NC}"
+ --prefer-dist; then
+ fail "Composer install failed within the backend service."
exit 1
fi
+ok "Composer dependencies installed"
-echo -e "${GREEN}Waiting for the database to be ready...${NC}"
-while ! $COMPOSE_CMD logs pgsql | grep "ready to accept connections" > /dev/null; do
- echo -n '.'
- sleep 1
+step "Waiting for the database to be ready"
+while ! $COMPOSE_CMD logs pgsql 2>/dev/null | grep "ready to accept connections" > /dev/null; do
+ echo -n '.'
+ sleep 1
done
-
-echo -e "\n${GREEN}Database is ready. Proceeding with migrations...${NC}"
+echo ""
+ok "Database is ready"
if [ ! -f ./../../backend/.env ]; then
$COMPOSE_CMD exec backend cp .env.example .env
@@ -83,17 +181,40 @@ if [ ! -f ./../../frontend/.env ]; then
$COMPOSE_CMD exec frontend cp .env.example .env
fi
+step "Running migrations and setup"
$COMPOSE_CMD exec backend php artisan key:generate
$COMPOSE_CMD exec backend php artisan migrate
$COMPOSE_CMD exec backend chmod -R 775 /var/www/html/vendor/ezyang/htmlpurifier/library/HTMLPurifier/DefinitionCache/Serializer
$COMPOSE_CMD exec backend php artisan storage:link
if [ $? -ne 0 ]; then
- echo -e "${RED}Migrations failed.${NC}"
+ fail "Migrations failed."
exit 1
fi
+ok "Migrations complete"
+
+echo ""
+step "Background workers"
+
+if ask_yes_no "Start the queue worker?" "y"; then
+ $COMPOSE_CMD exec -d backend php artisan queue:work --queue=default,webhook-queue --sleep=3 --tries=3 --timeout=60
+ ok "Queue worker started (detached)"
+else
+ info "Skipped queue worker — start it later with:"
+ info "$COMPOSE_CMD exec backend php artisan queue:work"
+fi
+
+if ask_yes_no "Start the scheduler?" "y"; then
+ $COMPOSE_CMD exec -d backend php artisan schedule:work
+ ok "Scheduler started (detached)"
+else
+ info "Skipped scheduler — start it later with:"
+ info "$COMPOSE_CMD exec backend php artisan schedule:work"
+fi
-echo -e "${GREEN}Hi.Events is now running at:${NC} https://localhost:8443"
+echo ""
+echo -e "${GREEN}${BOLD} 🎉 Hi.Events is now running at:${NC} ${CYAN}${BOLD}https://localhost:8443${NC}"
+echo ""
case "$(uname -s)" in
Darwin) open https://localhost:8443/auth/register ;;
diff --git a/frontend/public/blank-slate/occurrence-schedule.svg b/frontend/public/blank-slate/occurrence-schedule.svg
new file mode 100644
index 0000000000..6283ffd08e
--- /dev/null
+++ b/frontend/public/blank-slate/occurrence-schedule.svg
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/logos/logos.zip b/frontend/public/logos/logos.zip
new file mode 100644
index 0000000000..cec03f9d72
Binary files /dev/null and b/frontend/public/logos/logos.zip differ
diff --git a/frontend/src/api/account.client.ts b/frontend/src/api/account.client.ts
index 848c398612..31e4288e65 100644
--- a/frontend/src/api/account.client.ts
+++ b/frontend/src/api/account.client.ts
@@ -1,5 +1,5 @@
import {api} from "./client.ts";
-import {Account, GenericDataResponse, IdParam, User, StripeConnectAccountsResponse} from "../types.ts";
+import {Account, GenericDataResponse, User} from "../types.ts";
interface CreateAccountRequest {
first_name: string;
@@ -21,14 +21,4 @@ export const accountClient = {
const response = await api.put>('accounts', account);
return response.data;
},
- getStripeConnectDetails: async (accountId: IdParam, platform?: string) => {
- const response = await api.post>(`accounts/${accountId}/stripe/connect`, {
- platform
- });
- return response.data;
- },
- getStripeConnectAccounts: async (accountId: IdParam) => {
- const response = await api.get>(`accounts/${accountId}/stripe/connect_accounts`);
- return response.data;
- }
-}
\ No newline at end of file
+}
diff --git a/frontend/src/api/admin.client.ts b/frontend/src/api/admin.client.ts
index 4fde19eac9..2bff604899 100644
--- a/frontend/src/api/admin.client.ts
+++ b/frontend/src/api/admin.client.ts
@@ -76,39 +76,49 @@ export interface UpdateConfigurationData {
bypass_application_fees?: boolean;
}
-export interface AssignConfigurationData {
- configuration_id: number;
-}
-
-export interface AccountVatSetting {
+export interface AdminOrganizerVatSetting {
id: number;
- account_id: number;
vat_registered: boolean;
vat_number: string | null;
vat_validated: boolean;
+ vat_validation_status: 'PENDING' | 'VALIDATING' | 'VALID' | 'INVALID' | 'FAILED' | null;
vat_validation_date: string | null;
business_name: string | null;
business_address: string | null;
vat_country_code: string | null;
- created_at: string;
- updated_at: string;
+}
+
+export interface AdminOrganizerSummary {
+ id: IdParam;
+ name: string;
+ configuration: AccountConfiguration | null;
+ vat_setting: AdminOrganizerVatSetting | null;
}
export interface AdminAccountDetail extends AdminAccount {
- configuration?: AccountConfiguration;
- vat_setting?: AccountVatSetting;
messaging_tier?: AccountMessagingTier;
+ organizers: AdminOrganizerSummary[];
}
-
-export interface UpdateAccountVatSettingsData {
- vat_registered: boolean;
+export interface UpdateAdminOrganizerVatSettingData {
+ vat_registered?: boolean;
vat_number?: string | null;
+ vat_validated?: boolean;
business_name?: string | null;
business_address?: string | null;
vat_country_code?: string | null;
}
+export interface UpdateOrganizerConfigurationOverrideData {
+ application_fees?: {
+ fixed: number;
+ percentage: number;
+ currency: string;
+ };
+ bypass_application_fees?: boolean;
+}
+
+
export interface AdminStats {
total_users: number;
total_accounts: number;
@@ -453,14 +463,6 @@ export const adminClient = {
return response.data;
},
- assignConfiguration: async (accountId: IdParam, data: AssignConfigurationData) => {
- const response = await api.put(
- `admin/accounts/${accountId}/configuration`,
- data
- );
- return response.data;
- },
-
getAllConfigurations: async () => {
const response = await api.get>(
'admin/configurations'
@@ -489,10 +491,26 @@ export const adminClient = {
return response.data;
},
- updateAccountVatSettings: async (accountId: IdParam, data: UpdateAccountVatSettingsData) => {
- const response = await api.put>(
- `admin/accounts/${accountId}/vat-settings`,
- data
+ assignOrganizerConfiguration: async (organizerId: IdParam, configurationId: IdParam) => {
+ const response = await api.put(
+ `admin/organizers/${organizerId}/configuration`,
+ { configuration_id: configurationId },
+ );
+ return response.data;
+ },
+
+ updateOrganizerConfigurationOverride: async (organizerId: IdParam, data: UpdateOrganizerConfigurationOverrideData) => {
+ const response = await api.patch>(
+ `admin/organizers/${organizerId}/configuration`,
+ data,
+ );
+ return response.data;
+ },
+
+ updateOrganizerVatSetting: async (organizerId: IdParam, data: UpdateAdminOrganizerVatSettingData) => {
+ const response = await api.put>(
+ `admin/organizers/${organizerId}/vat-settings`,
+ data,
);
return response.data;
},
diff --git a/frontend/src/api/attendee.client.ts b/frontend/src/api/attendee.client.ts
index ba44fb839d..258526dd4e 100644
--- a/frontend/src/api/attendee.client.ts
+++ b/frontend/src/api/attendee.client.ts
@@ -19,6 +19,8 @@ export interface CreateAttendeeRequest extends EditAttendeeRequest {
send_confirmation_email: boolean,
taxes_and_fees: TaxAndFee[],
locale: SupportedLocales,
+ event_occurrence_id?: number | null,
+ override_capacity?: boolean,
}
export const attendeesClient = {
@@ -56,8 +58,9 @@ export const attendeesClient = {
});
return response.data;
},
- export: async (eventId: IdParam): Promise => {
- const response = await api.post(`events/${eventId}/attendees/export`, {}, {
+ export: async (eventId: IdParam, eventOccurrenceId?: number | null): Promise => {
+ const body = eventOccurrenceId ? {event_occurrence_id: eventOccurrenceId} : {};
+ const response = await api.post(`events/${eventId}/attendees/export`, body, {
responseType: 'blob',
});
diff --git a/frontend/src/api/check-in.client.ts b/frontend/src/api/check-in.client.ts
index 709121e915..a74b55c66b 100644
--- a/frontend/src/api/check-in.client.ts
+++ b/frontend/src/api/check-in.client.ts
@@ -1,7 +1,9 @@
import {publicApi} from "./public-client";
import {
Attendee,
+ AttendeeDetailPublic,
CheckInList,
+ CheckInListStats,
GenericDataResponse,
GenericPaginatedResponse,
IdParam, PublicCheckIn,
@@ -14,6 +16,11 @@ export const publicCheckInClient = {
const response = await publicApi.get>(`/check-in-lists/${checkInListShortId}`);
return response.data;
},
+ getCheckInListStats: async (checkInListShortId: IdParam, eventOccurrenceId?: number | null) => {
+ const qs = eventOccurrenceId ? `?event_occurrence_id=${eventOccurrenceId}` : '';
+ const response = await publicApi.get>(`/check-in-lists/${checkInListShortId}/stats${qs}`);
+ return response.data;
+ },
getCheckInListAttendees: async (checkInListShortId: IdParam, pagination: QueryFilters) => {
const response = await publicApi.get>(`/check-in-lists/${checkInListShortId}/attendees` + queryParamsHelper.buildQueryString(pagination));
return response.data;
@@ -22,6 +29,10 @@ export const publicCheckInClient = {
const response = await publicApi.get>(`/check-in-lists/${checkInListShortId}/attendees/${attendeePublicId}`);
return response.data;
},
+ getCheckInListAttendeeDetail: async (checkInListShortId: IdParam, attendeePublicId: IdParam) => {
+ const response = await publicApi.get>(`/check-in-lists/${checkInListShortId}/attendees/${attendeePublicId}/detail`);
+ return response.data;
+ },
createCheckIn: async (checkInListShortId: IdParam, attendeePublicId: IdParam, action: 'check-in' | 'check-in-and-mark-order-as-paid') => {
const response = await publicApi.post>(`/check-in-lists/${checkInListShortId}/check-ins`, {
"attendees": [
diff --git a/frontend/src/api/event-occurrence.client.ts b/frontend/src/api/event-occurrence.client.ts
new file mode 100644
index 0000000000..3ea3704312
--- /dev/null
+++ b/frontend/src/api/event-occurrence.client.ts
@@ -0,0 +1,135 @@
+import {api} from "./client";
+import {publicApi} from "./public-client";
+import {
+ BulkUpdateOccurrencesRequest,
+ EventOccurrence,
+ GenerateOccurrencesRequest,
+ GenericDataResponse,
+ GenericPaginatedResponse,
+ IdParam,
+ ProductOccurrenceVisibility,
+ ProductPriceOccurrenceOverride,
+ QueryFilters,
+ UpsertEventOccurrenceRequest,
+ UpsertPriceOverrideRequest,
+} from "../types";
+import {queryParamsHelper} from "../utilites/queryParamsHelper.ts";
+
+export const eventOccurrenceClient = {
+ all: async (eventId: IdParam, pagination: QueryFilters, options: {includeStats?: boolean} = {}) => {
+ const queryString = queryParamsHelper.buildQueryString(pagination);
+ const separator = queryString.includes('?') ? '&' : '?';
+ const url = `events/${eventId}/occurrences` + queryString
+ + (options.includeStats === false ? `${separator}include_stats=false` : '');
+ const response = await api.get>(url);
+ return response.data;
+ },
+
+ get: async (eventId: IdParam, occurrenceId: IdParam) => {
+ const response = await api.get>(
+ `events/${eventId}/occurrences/${occurrenceId}`
+ );
+ return response.data;
+ },
+
+ create: async (eventId: IdParam, data: UpsertEventOccurrenceRequest) => {
+ const response = await api.post>(
+ `events/${eventId}/occurrences`,
+ data
+ );
+ return response.data;
+ },
+
+ update: async (eventId: IdParam, occurrenceId: IdParam, data: UpsertEventOccurrenceRequest) => {
+ const response = await api.put>(
+ `events/${eventId}/occurrences/${occurrenceId}`,
+ data
+ );
+ return response.data;
+ },
+
+ delete: async (eventId: IdParam, occurrenceId: IdParam) => {
+ const response = await api.delete>(
+ `events/${eventId}/occurrences/${occurrenceId}`
+ );
+ return response.data;
+ },
+
+ cancel: async (eventId: IdParam, occurrenceId: IdParam, refundOrders: boolean = false) => {
+ const response = await api.post>(
+ `events/${eventId}/occurrences/${occurrenceId}/cancel`,
+ {refund_orders: refundOrders}
+ );
+ return response.data;
+ },
+
+ reactivate: async (eventId: IdParam, occurrenceId: IdParam) => {
+ const response = await api.post>(
+ `events/${eventId}/occurrences/${occurrenceId}/reactivate`,
+ {}
+ );
+ return response.data;
+ },
+
+ generate: async (eventId: IdParam, data: GenerateOccurrencesRequest) => {
+ const response = await api.post>(
+ `events/${eventId}/occurrences/generate`,
+ data
+ );
+ return response.data;
+ },
+
+ bulkUpdate: async (eventId: IdParam, data: BulkUpdateOccurrencesRequest) => {
+ const response = await api.post<{ updated_count: number; updated_ids: number[] }>(
+ `events/${eventId}/occurrences/bulk-update`,
+ data
+ );
+ return response.data;
+ },
+
+ getPriceOverrides: async (eventId: IdParam, occurrenceId: IdParam) => {
+ const response = await api.get>(
+ `events/${eventId}/occurrences/${occurrenceId}/price-overrides`
+ );
+ return response.data;
+ },
+
+ upsertPriceOverride: async (eventId: IdParam, occurrenceId: IdParam, data: UpsertPriceOverrideRequest) => {
+ const response = await api.put>(
+ `events/${eventId}/occurrences/${occurrenceId}/price-overrides`,
+ data
+ );
+ return response.data;
+ },
+
+ deletePriceOverride: async (eventId: IdParam, occurrenceId: IdParam, overrideId: IdParam) => {
+ const response = await api.delete>(
+ `events/${eventId}/occurrences/${occurrenceId}/price-overrides/${overrideId}`
+ );
+ return response.data;
+ },
+
+ getProductVisibility: async (eventId: IdParam, occurrenceId: IdParam) => {
+ const response = await api.get>(
+ `events/${eventId}/occurrences/${occurrenceId}/product-visibility`
+ );
+ return response.data;
+ },
+
+ updateProductVisibility: async (eventId: IdParam, occurrenceId: IdParam, productIds: IdParam[]) => {
+ const response = await api.put>(
+ `events/${eventId}/occurrences/${occurrenceId}/product-visibility`,
+ {product_ids: productIds}
+ );
+ return response.data;
+ },
+};
+
+export const eventOccurrenceClientPublic = {
+ all: async (eventId: IdParam, pagination: QueryFilters) => {
+ const response = await publicApi.get>(
+ `events/${eventId}/occurrences` + queryParamsHelper.buildQueryString(pagination)
+ );
+ return response.data;
+ },
+};
diff --git a/frontend/src/api/event.client.ts b/frontend/src/api/event.client.ts
index ccf18834e9..c440647a75 100644
--- a/frontend/src/api/event.client.ts
+++ b/frontend/src/api/event.client.ts
@@ -37,9 +37,12 @@ export const eventsClient = {
return response.data;
},
- getEventStats: async (eventId: IdParam, dateRange?: string) => {
- const params = dateRange ? `?date_range=${dateRange}` : '';
- const response = await api.get>('events/' + eventId + '/stats' + params);
+ getEventStats: async (eventId: IdParam, options: {occurrenceId?: IdParam; dateRange?: string} = {}) => {
+ const params = new URLSearchParams();
+ if (options.occurrenceId) params.set('occurrence_id', String(options.occurrenceId));
+ if (options.dateRange) params.set('date_range', options.dateRange);
+ const qs = params.toString();
+ const response = await api.get>(`events/${eventId}/stats${qs ? '?' + qs : ''}`);
return response.data;
},
@@ -92,8 +95,12 @@ export const eventsClient = {
return response.data;
},
- getEventReport: async (eventId: IdParam, reportType: IdParam, startDate?: string, endDate?: string) => {
- const response = await api.get>('events/' + eventId + '/reports/' + reportType + '?start_date=' + startDate + '&end_date=' + endDate);
+ getEventReport: async (eventId: IdParam, reportType: IdParam, startDate?: string, endDate?: string, occurrenceId?: IdParam) => {
+ const params = new URLSearchParams();
+ if (startDate) params.append('start_date', startDate);
+ if (endDate) params.append('end_date', endDate);
+ if (occurrenceId) params.append('occurrence_id', String(occurrenceId));
+ const response = await api.get>('events/' + eventId + '/reports/' + reportType + '?' + params.toString());
return response.data;
}
}
@@ -104,8 +111,12 @@ export const eventsClientPublic = {
return response.data;
},
- findByID: async (eventId: any, promoCode: null | string) => {
- const response = await publicApi.get>('events/' + eventId + (promoCode ? '?promo_code=' + promoCode : ''));
+ findByID: async (eventId: any, promoCode?: null | string, eventOccurrenceId?: number | null) => {
+ const params = new URLSearchParams();
+ if (promoCode) params.set('promo_code', promoCode);
+ if (eventOccurrenceId) params.set('event_occurrence_id', String(eventOccurrenceId));
+ const queryString = params.toString();
+ const response = await publicApi.get>('events/' + eventId + (queryString ? '?' + queryString : ''));
return response.data;
},
}
diff --git a/frontend/src/api/order.client.ts b/frontend/src/api/order.client.ts
index c54d8e5a06..d56d575a95 100644
--- a/frontend/src/api/order.client.ts
+++ b/frontend/src/api/order.client.ts
@@ -41,6 +41,7 @@ export interface ProductPriceQuantityFormValue {
export interface ProductFormValue {
product_id: number,
quantities: ProductPriceQuantityFormValue[],
+ event_occurrence_id?: number,
}
export interface ProductFormPayload {
@@ -86,8 +87,9 @@ export const orderClient = {
return response.data;
},
- exportOrders: async (eventId: IdParam): Promise => {
- const response = await api.post(`events/${eventId}/orders/export`, {}, {
+ exportOrders: async (eventId: IdParam, eventOccurrenceId?: number | null): Promise => {
+ const body = eventOccurrenceId ? {event_occurrence_id: eventOccurrenceId} : {};
+ const response = await api.post(`events/${eventId}/orders/export`, body, {
responseType: 'blob',
});
diff --git a/frontend/src/api/organizer-stripe.client.ts b/frontend/src/api/organizer-stripe.client.ts
new file mode 100644
index 0000000000..e7d12d3ff9
--- /dev/null
+++ b/frontend/src/api/organizer-stripe.client.ts
@@ -0,0 +1,30 @@
+import {api} from "./client.ts";
+import {
+ GenericDataResponse,
+ IdParam,
+ OrganizerStripeConnectAccountsResponse,
+ OrganizerStripeConnectDetails,
+} from "../types.ts";
+
+export const organizerStripeClient = {
+ createOrGetConnectDetails: async (organizerId: IdParam, platform?: string) => {
+ const response = await api.post>(
+ `organizers/${organizerId}/stripe/connect`,
+ {platform},
+ );
+ return response.data;
+ },
+ getConnectAccounts: async (organizerId: IdParam) => {
+ const response = await api.get>(
+ `organizers/${organizerId}/stripe/connect_accounts`,
+ );
+ return response.data;
+ },
+ copyConnection: async (organizerId: IdParam, sourceOrganizerId: IdParam) => {
+ const response = await api.post>(
+ `organizers/${organizerId}/stripe/copy_from/${sourceOrganizerId}`,
+ {},
+ );
+ return response.data;
+ },
+};
diff --git a/frontend/src/api/vat.client.ts b/frontend/src/api/vat.client.ts
index 68068b4de2..63dcb0c563 100644
--- a/frontend/src/api/vat.client.ts
+++ b/frontend/src/api/vat.client.ts
@@ -3,9 +3,9 @@ import {GenericDataResponse, IdParam} from "../types.ts";
export type VatValidationStatus = 'PENDING' | 'VALIDATING' | 'VALID' | 'INVALID' | 'FAILED';
-export interface AccountVatSetting {
+export interface VatSetting {
id: number;
- account_id: number;
+ organizer_id: number;
vat_registered: boolean;
vat_number: string | null;
vat_validated: boolean;
@@ -26,17 +26,17 @@ export interface UpsertVatSettingRequest {
}
export const vatClient = {
- getVatSetting: async (accountId: IdParam) => {
- const response = await api.get>(
- `accounts/${accountId}/vat-settings`
+ getVatSetting: async (organizerId: IdParam) => {
+ const response = await api.get>(
+ `organizers/${organizerId}/vat-settings`,
);
return response.data;
},
- upsertVatSetting: async (accountId: IdParam, data: UpsertVatSettingRequest) => {
- const response = await api.post>(
- `accounts/${accountId}/vat-settings`,
- data
+ upsertVatSetting: async (organizerId: IdParam, data: UpsertVatSettingRequest) => {
+ const response = await api.post>(
+ `organizers/${organizerId}/vat-settings`,
+ data,
);
return response.data;
},
diff --git a/frontend/src/api/waitlist.client.ts b/frontend/src/api/waitlist.client.ts
index 96e6c05123..9fbda9f4ba 100644
--- a/frontend/src/api/waitlist.client.ts
+++ b/frontend/src/api/waitlist.client.ts
@@ -19,17 +19,24 @@ export const waitlistClient = {
return response.data;
},
- stats: async (eventId: IdParam) => {
+ stats: async (eventId: IdParam, eventOccurrenceId?: IdParam | null) => {
+ const query = new URLSearchParams();
+
+ if (eventOccurrenceId) {
+ query.set('event_occurrence_id', String(eventOccurrenceId));
+ }
+
+ const queryString = query.toString();
const response = await api.get(
- `events/${eventId}/waitlist/stats`,
+ `events/${eventId}/waitlist/stats${queryString ? `?${queryString}` : ''}`,
);
return response.data;
},
- offerNext: async (eventId: IdParam, productPriceId: number, quantity: number = 1) => {
+ offerNext: async (eventId: IdParam, productPriceId: number, quantity: number = 1, eventOccurrenceId?: IdParam | null) => {
const response = await api.post>(
`events/${eventId}/waitlist/offer-next`,
- {product_price_id: productPriceId, quantity},
+ {product_price_id: productPriceId, quantity, event_occurrence_id: eventOccurrenceId},
);
return response.data;
},
diff --git a/frontend/src/components/common/AddToCalendarCTA/index.tsx b/frontend/src/components/common/AddToCalendarCTA/index.tsx
index 0ba32735dd..d47ea4000f 100644
--- a/frontend/src/components/common/AddToCalendarCTA/index.tsx
+++ b/frontend/src/components/common/AddToCalendarCTA/index.tsx
@@ -2,14 +2,16 @@ import {t} from "@lingui/macro";
import {Button} from "@mantine/core";
import {IconCalendar} from "@tabler/icons-react";
import {Event} from "../../../types.ts";
+import {OccurrenceDateOverride} from "../../../utilites/calendar.ts";
import {CalendarOptionsPopover} from "../CalendarOptionsPopover";
import classes from './AddToCalendarCTA.module.scss';
interface AddToCalendarCTAProps {
event: Event;
+ occurrence?: OccurrenceDateOverride;
}
-export const AddToCalendarCTA = ({event}: AddToCalendarCTAProps) => {
+export const AddToCalendarCTA = ({event, occurrence}: AddToCalendarCTAProps) => {
return (
@@ -19,7 +21,7 @@ export const AddToCalendarCTA = ({event}: AddToCalendarCTAProps) => {
{t`Don't forget!`}
{t`Add this event to your calendar`}
-
+
{t`Add to Calendar`}
diff --git a/frontend/src/components/common/AttendeeCheckInTable/PermissionDeniedMessage.tsx b/frontend/src/components/common/AttendeeCheckInTable/PermissionDeniedMessage.tsx
deleted file mode 100644
index 8a005d19af..0000000000
--- a/frontend/src/components/common/AttendeeCheckInTable/PermissionDeniedMessage.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import {Anchor, Button} from "@mantine/core";
-import {t, Trans} from "@lingui/macro";
-import classes from "./QrScanner.module.scss";
-
-interface PermissionDeniedMessageProps {
- onRequestPermission: () => void;
- onClose: () => void;
-}
-
-export const PermissionDeniedMessage = ({
- onRequestPermission,
- onClose
-}: PermissionDeniedMessageProps) => {
- return (
-
-
- Camera permission was denied. Request
- Permission again,
- or if this doesn't work,
- you will need to grant
- this page access to your camera in your browser settings.
-
-
-
-
- {t`Close`}
-
-
-
- );
-};
\ No newline at end of file
diff --git a/frontend/src/components/common/AttendeeCheckInTable/QrScanner.module.scss b/frontend/src/components/common/AttendeeCheckInTable/QrScanner.module.scss
deleted file mode 100644
index 33878f6a84..0000000000
--- a/frontend/src/components/common/AttendeeCheckInTable/QrScanner.module.scss
+++ /dev/null
@@ -1,100 +0,0 @@
-@use "../../../styles/mixins";
-
-@keyframes colorfulBorder {
- 0% {
- border-color: #ffffff50;
- }
- 50% {
- border-color: #00000050;
- }
- 100% {
- border-color: #ffffff50;
- }
-}
-
-.videoContainer {
- position: relative;
- display: flex;
- justify-content: center;
- align-items: center;
-
- .permissionMessage {
- position: absolute;
- width: 100vw;
- padding: 20px;
- text-align: center;
- background-color: #000000;
- color: #fff;
- z-index: 3;
-
- a {
- color: #dddddd;
- text-decoration: underline;
- }
- }
-
- .flashToggle {
- position: absolute;
- top: 20px;
- left: 20px;
- z-index: 2;
- }
-
- .soundToggle {
- position: absolute;
- bottom: 20px;
- left: 20px;
- z-index: 2;
- }
-
- .closeButton {
- position: absolute;
- top: 20px;
- right: 20px;
- z-index: 2;
- }
-
- .switchCameraButton {
- position: absolute;
- bottom: 20px;
- right: 20px;
- z-index: 2;
- }
-
- //scanner overlay is a square div that scales as the browser window scales
- .scannerOverlay {
- width: 60vw;
- height: 60vw;
- border: 5px solid #ffffff50;
- position: absolute;
- animation: colorfulBorder 10s infinite;
- border-radius: 10px;
- outline: solid 50vmax rgb(71 46 120 / 50%);
- transition: outline-color 0.2s ease-out;
- min-width: 200px;
- min-height: 200px;
-
- @include mixins.respond-above(md) {
- width: 40vw;
- height: 40vw;
- }
- }
-
- .scannerOverlay.success {
- outline: solid 50vmax rgb(80 148 80 / 75%);
- }
-
- .scannerOverlay.failure {
- outline: solid 50vmax rgb(193 72 72 / 75%);
- }
-
- .scannerOverlay.checkingIn {
- outline: solid 50vmax rgb(172 158 85 / 60%);
- }
-
- video {
- width: 100vw !important;
- height: 100vh !important;
- object-fit: cover;
- }
-}
diff --git a/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx b/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx
deleted file mode 100644
index 121fde9e31..0000000000
--- a/frontend/src/components/common/AttendeeCheckInTable/QrScanner.tsx
+++ /dev/null
@@ -1,215 +0,0 @@
-import {useEffect, useRef, useState} from 'react';
-import QrScanner from 'qr-scanner';
-import {useDebouncedValue} from '@mantine/hooks';
-import classes from './QrScanner.module.scss';
-import {showError} from "../../../utilites/notifications.tsx";
-import {t} from "@lingui/macro";
-import {QrScannerControls} from './QrScannerControls';
-import {PermissionDeniedMessage} from './PermissionDeniedMessage';
-
-interface QRScannerComponentProps {
- onAttendeeScanned: (attendeePublicId: string) => void;
- onClose: () => void;
- isSoundOn?: boolean;
-}
-
-export const QRScannerComponent = (props: QRScannerComponentProps) => {
- const videoRef = useRef(null);
- const qrScannerRef = useRef(null);
- const [permissionGranted, setPermissionGranted] = useState(false);
- const [permissionDenied, setPermissionDenied] = useState(false);
- const [isCheckingIn, setIsCheckingIn] = useState(false);
- const [isFlashAvailable, setIsFlashAvailable] = useState(false);
- const [isFlashOn, setIsFlashOn] = useState(false);
- const [cameraList, setCameraList] = useState();
- const [processedAttendeeIds, setProcessedAttendeeIds] = useState([]);
- const latestProcessedAttendeeIdsRef = useRef([]);
-
- const [currentAttendeeId, setCurrentAttendeeId] = useState(null);
- const [debouncedAttendeeId] = useDebouncedValue(currentAttendeeId, 1000);
- const [isScanFailed, setIsScanFailed] = useState(false);
- const [isScanSucceeded, setIsScanSucceeded] = useState(false);
-
- const scanSuccessAudioRef = useRef(null);
- const scanErrorAudioRef = useRef(null);
- const scanInProgressAudioRef = useRef(null);
-
- const [isSoundOn, setIsSoundOn] = useState(() => {
- // Use the prop value if provided, otherwise fallback to unified storage
- if (props.isSoundOn !== undefined) {
- return props.isSoundOn;
- }
- const storedIsSoundOn = localStorage.getItem("scannerSoundOn");
- return storedIsSoundOn === null ? true : JSON.parse(storedIsSoundOn);
- });
-
- // Sync with prop changes
- useEffect(() => {
- if (props.isSoundOn !== undefined) {
- setIsSoundOn(props.isSoundOn);
- }
- }, [props.isSoundOn]);
-
- useEffect(() => {
- // Only save to localStorage if not controlled by props
- if (props.isSoundOn === undefined) {
- localStorage.setItem("scannerSoundOn", JSON.stringify(isSoundOn));
- }
- }, [isSoundOn, props.isSoundOn]);
-
- useEffect(() => {
- latestProcessedAttendeeIdsRef.current = processedAttendeeIds;
- }, [processedAttendeeIds]);
-
- const startScanner = async () => {
- try {
- await navigator.mediaDevices.getUserMedia({video: true});
- setPermissionGranted(true);
- if (videoRef.current) {
- qrScannerRef.current = new QrScanner(videoRef.current, (result) => {
- setCurrentAttendeeId(result.data);
- }, {
- maxScansPerSecond: 1,
- });
- qrScannerRef.current.start();
- }
- } catch (error) {
- setPermissionDenied(true);
- console.error(error);
- }
- };
-
- useEffect(() => {
- if (debouncedAttendeeId) {
- const latestProcessedAttendeeIds = latestProcessedAttendeeIdsRef.current;
- const alreadyScanned = latestProcessedAttendeeIds.includes(debouncedAttendeeId);
-
- if (isScanSucceeded || isScanFailed) {
- return;
- }
-
- if (alreadyScanned) {
- showError(t`You already scanned this ticket`);
-
- setIsScanFailed(true);
- setInterval(() => setIsScanFailed(false), 500);
- if (isSoundOn && scanErrorAudioRef.current) {
- scanErrorAudioRef.current.play();
- }
-
- return;
- }
-
- if (!isCheckingIn && !alreadyScanned) {
- setIsCheckingIn(true);
- if (isSoundOn && scanInProgressAudioRef.current) {
- scanInProgressAudioRef.current.play();
- }
-
- props.onAttendeeScanned(debouncedAttendeeId);
- setIsCheckingIn(false);
- setProcessedAttendeeIds(prevIds => [...prevIds, debouncedAttendeeId]);
- setCurrentAttendeeId(null);
-
- setIsScanSucceeded(true);
- setInterval(() => setIsScanSucceeded(false), 500);
- if (isSoundOn && scanSuccessAudioRef.current) {
- scanSuccessAudioRef.current.play();
- }
- }
- }
- }, [debouncedAttendeeId]);
-
- const stopScanner = () => {
- if (qrScannerRef.current) {
- qrScannerRef.current.stop();
- qrScannerRef.current.destroy();
- qrScannerRef.current = null;
- }
- };
-
- const handleClose = () => {
- stopScanner();
- props.onClose();
- };
-
- const handleFlashToggle = () => {
- if (!isFlashAvailable) {
- showError(t`Flash is not available on this device`);
- return;
- }
- if (qrScannerRef.current) {
- if (isFlashOn) {
- qrScannerRef.current.turnFlashOff();
- } else {
- qrScannerRef.current.turnFlashOn();
- }
- setIsFlashOn(!isFlashOn);
- }
- };
-
- const handleSoundToggle = () => {
- setIsSoundOn(!isSoundOn);
- };
-
- const requestPermission = async () => {
- setPermissionDenied(false);
- await startScanner();
- };
-
- const updateFlashAvailability = async () => {
- if (qrScannerRef.current) {
- const hasFlash = await qrScannerRef.current.hasFlash();
- setIsFlashAvailable(hasFlash);
- }
- };
-
- useEffect(() => {
- startScanner().then(() => {
- updateFlashAvailability().catch(console.error);
- QrScanner.listCameras(true)
- .then(cameras => setCameraList(cameras));
- });
-
- return () => {
- if (permissionGranted) {
- stopScanner();
- }
- };
- }, []);
-
- const handleCameraSelection = (camera: QrScanner.Camera) => {
- return qrScannerRef.current?.setCamera(camera.id)
- .then(() => updateFlashAvailability().catch(console.error));
- };
-
- return (
-
- {permissionDenied && (
-
- )}
-
-
-
-
-
-
-
-
-
-
-
- );
-};
diff --git a/frontend/src/components/common/AttendeeCheckInTable/QrScannerControls.tsx b/frontend/src/components/common/AttendeeCheckInTable/QrScannerControls.tsx
deleted file mode 100644
index 57ed4a171c..0000000000
--- a/frontend/src/components/common/AttendeeCheckInTable/QrScannerControls.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import {Button, Menu} from "@mantine/core";
-import {IconBulb, IconBulbOff, IconCameraRotate, IconVolume, IconVolumeOff, IconX} from "@tabler/icons-react";
-import {t} from "@lingui/macro";
-import QrScanner from "qr-scanner";
-import classes from "./QrScanner.module.scss";
-
-interface QrScannerControlsProps {
- isFlashAvailable: boolean;
- isFlashOn: boolean;
- isSoundOn: boolean;
- cameraList: QrScanner.Camera[] | undefined;
- onFlashToggle: () => void;
- onSoundToggle: () => void;
- onCameraSelect: (camera: QrScanner.Camera) => void;
- onClose: () => void;
-}
-
-export const QrScannerControls = ({
- isFlashAvailable,
- isFlashOn,
- isSoundOn,
- cameraList,
- onFlashToggle,
- onSoundToggle,
- onCameraSelect,
- onClose
-}: QrScannerControlsProps) => {
- return (
- <>
-
- {!isFlashAvailable && }
- {isFlashAvailable && }
-
-
- {isSoundOn && }
- {!isSoundOn && }
-
-
-
-
-
-
-
-
-
-
- {t`Select Camera`}
- {cameraList?.map((camera, index) => (
- onCameraSelect(camera)}>
- {camera.label}
-
- ))}
-
-
-
- >
- );
-};
\ No newline at end of file
diff --git a/frontend/src/components/common/AttendeeTable/AttendeeTable.module.scss b/frontend/src/components/common/AttendeeTable/AttendeeTable.module.scss
index 42c8772668..d9531b7cdf 100644
--- a/frontend/src/components/common/AttendeeTable/AttendeeTable.module.scss
+++ b/frontend/src/components/common/AttendeeTable/AttendeeTable.module.scss
@@ -74,6 +74,16 @@
white-space: nowrap;
}
+.occurrenceChip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 12px;
+ line-height: 1.3;
+ color: var(--mantine-primary-color-filled);
+ white-space: nowrap;
+}
+
.orderId {
font-size: 11px;
color: var(--mantine-color-dimmed);
diff --git a/frontend/src/components/common/AttendeeTable/index.tsx b/frontend/src/components/common/AttendeeTable/index.tsx
index 310b0a5f51..48ef9728b7 100644
--- a/frontend/src/components/common/AttendeeTable/index.tsx
+++ b/frontend/src/components/common/AttendeeTable/index.tsx
@@ -1,6 +1,7 @@
import {ActionIcon, Anchor, Avatar, Button, Group, Popover, Tooltip} from '@mantine/core';
import {Attendee, IdParam, MessageType} from "../../../types.ts";
import {
+ IconCalendarEvent,
IconCheck,
IconClock,
IconClipboardList,
@@ -32,7 +33,7 @@ import {ManageAttendeeModal} from "../../modals/ManageAttendeeModal";
import {ManageOrderModal} from "../../modals/ManageOrderModal";
import {ActionMenu} from '../ActionMenu';
import {CheckInStatusModal} from "../CheckInStatusModal";
-import {prettyDate} from "../../../utilites/dates.ts";
+import {formatDateWithLocale, prettyDate} from "../../../utilites/dates.ts";
import {TanStackTable, TanStackTableColumn} from "../TanStackTable";
import {ColumnVisibilityToggle} from "../ColumnVisibilityToggle";
import {CellContext} from "@tanstack/react-table";
@@ -40,10 +41,12 @@ import classes from './AttendeeTable.module.scss';
interface AttendeeTableProps {
attendees: Attendee[];
- openCreateModal: () => void;
+ openCreateModal?: () => void;
+ compact?: boolean;
+ occurrenceId?: IdParam;
}
-export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps) => {
+export const AttendeeTable = ({attendees, openCreateModal, compact, occurrenceId}: AttendeeTableProps) => {
const {eventId} = useParams();
const [isMessageModalOpen, messageModal] = useDisclosure(false);
const [isViewModalOpen, viewModalOpen] = useDisclosure(false);
@@ -58,7 +61,11 @@ export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps)
const resendTicketMutation = useResendAttendeeTicket();
const clipboard = useClipboard({timeout: 2000});
- const hasCheckInLists = checkInLists?.data && checkInLists.data.length > 0;
+ const relevantCheckInLists = checkInLists?.data?.filter(list =>
+ !occurrenceId || !list.event_occurrence_id || list.event_occurrence_id === Number(occurrenceId)
+ ) || [];
+
+ const hasCheckInLists = relevantCheckInLists.length > 0;
const handleModalClick = (attendee: Attendee, modal: {
open: () => void
@@ -106,7 +113,9 @@ export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps)
};
const getCheckInCount = (attendee: Attendee) => {
- return attendee.check_ins?.length || 0;
+ if (!attendee.check_ins) return 0;
+ if (!occurrenceId) return attendee.check_ins.length;
+ return attendee.check_ins.filter(ci => ci.event_occurrence_id === Number(occurrenceId)).length;
};
const hasCheckIns = (attendee: Attendee) => {
@@ -240,7 +249,9 @@ export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps)
header: t`Order & Ticket`,
enableHiding: true,
cell: (info: CellContext) => {
- const ticketTitle = getProductFromEvent(info.row.original.product_id, event)?.title;
+ const attendee = info.row.original;
+ const ticketTitle = getProductFromEvent(attendee.product_id, event)?.title;
+ const occurrence = attendee.event_occurrence;
return (
@@ -249,17 +260,26 @@ export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps)
length={25}
/>
+ {occurrence && event?.timezone && (
+
+
+ {formatDateWithLocale(occurrence.start_date, 'shortDate', event.timezone)}
+ {' '}
+ {formatDateWithLocale(occurrence.start_date, 'timeOnly', event.timezone)}
+ {occurrence.label && ` · ${occurrence.label}`}
+
+ )}
handleOrderClick(info.row.original.order_id)}
+ onClick={() => handleOrderClick(attendee.order_id)}
style={{cursor: 'pointer', color: 'inherit', textDecoration: 'none'}}
>
- {info.row.original.order?.public_id}
+ {attendee.order?.public_id}
- {info.row.original.order?.created_at && event?.timezone && (
+ {attendee.order?.created_at && event?.timezone && (
- {prettyDate(info.row.original.order.created_at, event.timezone)}
+ {prettyDate(attendee.order.created_at, event.timezone)}
)}
@@ -309,7 +329,7 @@ export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps)
cell: (info: CellContext) => {
const checkInCount = getCheckInCount(info.row.original);
const hasChecked = hasCheckIns(info.row.original);
- const totalLists = checkInLists?.data?.length || 0;
+ const totalLists = relevantCheckInLists.length;
return (
{t`Your attendees will appear here once they have registered for your event. You can also manually add attendees.`}
- }
- color={'green'}
- onClick={() => openCreateModal()}>{t`Manually add an Attendee`}
-
+ {openCreateModal && (
+ }
+ color={'green'}
+ onClick={() => openCreateModal()}>{t`Manually add an Attendee`}
+
+ )}
>
)}
/>
@@ -420,14 +442,17 @@ export const AttendeeTable = ({attendees, openCreateModal}: AttendeeTableProps)
data={attendees}
columns={columns}
storageKey="attendee-table"
- enableColumnVisibility={true}
- renderColumnVisibilityToggle={(table) => }
+ enableColumnVisibility={!compact}
+ renderColumnVisibilityToggle={!compact ? (table) => : undefined}
+ hideHeader={compact}
+ noCard={compact}
/>
{(selectedAttendee && isMessageModalOpen) && }
{(selectedAttendee?.id && isViewModalOpen) && {
+ // Prefer attendee.event_occurrence (hydrated by backend) over caller-supplied
+ // occurrence, so the ticket always shows the date the attendee is booked for
+ // rather than the event's aggregated range.
+ const ticketOccurrence = attendee.event_occurrence ?? occurrence;
const productPrice = getAttendeeProductPrice(attendee, product);
const hasVenue = event?.settings?.location_details?.venue_name || event?.settings?.location_details?.address_line_1;
@@ -74,7 +80,10 @@ export const AttendeeTicket = ({
{t`Date & Time`}
- {prettyDate(event.start_date, event.timezone, true)}
+
+ {ticketOccurrence?.label && (
+ {ticketOccurrence.label}
+ )}
{event?.organizer?.name && (
diff --git a/frontend/src/components/common/CalendarOptionsPopover/index.tsx b/frontend/src/components/common/CalendarOptionsPopover/index.tsx
index 4ec46571af..8abd37f3c1 100644
--- a/frontend/src/components/common/CalendarOptionsPopover/index.tsx
+++ b/frontend/src/components/common/CalendarOptionsPopover/index.tsx
@@ -2,15 +2,16 @@ import {t} from "@lingui/macro";
import {Button, Popover, Stack, Text} from "@mantine/core";
import {IconBrandGoogle, IconDownload} from "@tabler/icons-react";
import {Event} from "../../../types.ts";
-import {createGoogleCalendarUrl, downloadICSFile} from "../../../utilites/calendar.ts";
+import {createGoogleCalendarUrl, downloadICSFile, OccurrenceDateOverride} from "../../../utilites/calendar.ts";
import {ReactNode} from "react";
interface CalendarOptionsPopoverProps {
event: Event;
+ occurrence?: OccurrenceDateOverride;
children: ReactNode;
}
-export const CalendarOptionsPopover = ({event, children}: CalendarOptionsPopoverProps) => {
+export const CalendarOptionsPopover = ({event, occurrence, children}: CalendarOptionsPopoverProps) => {
return (
@@ -23,7 +24,7 @@ export const CalendarOptionsPopover = ({event, children}: CalendarOptionsPopover
variant="light"
size="xs"
leftSection={}
- onClick={() => window?.open(createGoogleCalendarUrl(event), '_blank')}
+ onClick={() => window?.open(createGoogleCalendarUrl(event, occurrence), '_blank')}
fullWidth
>
{t`Google Calendar`}
@@ -32,7 +33,7 @@ export const CalendarOptionsPopover = ({event, children}: CalendarOptionsPopover
variant="light"
size="xs"
leftSection={}
- onClick={() => downloadICSFile(event)}
+ onClick={() => downloadICSFile(event, occurrence)}
fullWidth
>
{t`Download .ics`}
diff --git a/frontend/src/components/common/Callout/Callout.module.scss b/frontend/src/components/common/Callout/Callout.module.scss
new file mode 100644
index 0000000000..7976d5d82f
--- /dev/null
+++ b/frontend/src/components/common/Callout/Callout.module.scss
@@ -0,0 +1,118 @@
+.callout {
+ position: relative;
+ display: flex;
+ gap: 12px;
+ align-items: flex-start;
+ padding: 14px 16px;
+ border-radius: 14px;
+ margin-bottom: 16px;
+ // Faint left accent stripe via box-shadow inset — no wasted horizontal space.
+ overflow: hidden;
+ background: #fff;
+ border: 1px solid var(--hi-color-gray-2);
+}
+
+.info {
+ background: linear-gradient(135deg, color-mix(in srgb, var(--mantine-color-primary-5) 6%, #fff), #fff 55%);
+ border-color: color-mix(in srgb, var(--mantine-color-primary-5) 22%, transparent);
+}
+
+.tip {
+ background: linear-gradient(135deg, color-mix(in srgb, #f59f00 8%, #fff), #fff 55%);
+ border-color: color-mix(in srgb, #f59f00 26%, transparent);
+}
+
+.warning {
+ background: linear-gradient(135deg, color-mix(in srgb, #e03131 8%, #fff), #fff 55%);
+ border-color: color-mix(in srgb, #e03131 28%, transparent);
+}
+
+.success {
+ background: linear-gradient(135deg, color-mix(in srgb, var(--hi-color-money-green) 10%, #fff), #fff 55%);
+ border-color: color-mix(in srgb, var(--hi-color-money-green) 30%, transparent);
+}
+
+.iconWrap {
+ width: 22px;
+ height: 22px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ margin-top: 1px;
+ background: transparent;
+}
+
+.iconWrap_info {
+ color: var(--mantine-color-primary-7);
+}
+
+.iconWrap_tip {
+ color: #b46400;
+}
+
+.iconWrap_warning {
+ color: #c92a2a;
+}
+
+.iconWrap_success {
+ color: #087f5b;
+}
+
+.body {
+ flex: 1;
+ min-width: 0;
+ padding-top: 1px;
+}
+
+.title {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--hi-text);
+ line-height: 1.3;
+ letter-spacing: -0.01em;
+ margin-bottom: 3px;
+}
+
+.text {
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--hi-color-gray-dark);
+
+ a {
+ color: var(--hi-primary);
+ font-weight: 600;
+ }
+
+ b, strong {
+ color: var(--hi-text);
+ font-weight: 600;
+ }
+}
+
+.dismissBtn {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ border: none;
+ background: transparent;
+ color: var(--hi-color-gray-dark);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: background 140ms ease, color 140ms ease;
+
+ &:hover {
+ background: var(--hi-color-gray);
+ color: var(--hi-text);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--hi-primary);
+ outline-offset: 1px;
+ }
+}
diff --git a/frontend/src/components/common/Callout/index.tsx b/frontend/src/components/common/Callout/index.tsx
new file mode 100644
index 0000000000..b49982b4ef
--- /dev/null
+++ b/frontend/src/components/common/Callout/index.tsx
@@ -0,0 +1,64 @@
+import {ReactNode} from "react";
+import {t} from "@lingui/macro";
+import {
+ IconAlertTriangle,
+ IconBulb,
+ IconCheck,
+ IconInfoCircle,
+ IconX,
+} from "@tabler/icons-react";
+import classes from "./Callout.module.scss";
+
+export type CalloutVariant = "info" | "tip" | "warning" | "success";
+
+interface CalloutProps {
+ variant?: CalloutVariant;
+ title?: ReactNode;
+ children: ReactNode;
+ icon?: ReactNode;
+ onDismiss?: () => void;
+ className?: string;
+}
+
+const defaultIcon: Record = {
+ info: ,
+ tip: ,
+ warning: ,
+ success: ,
+};
+
+/**
+ * Callout — a friendlier alternative to Mantine's Alert for contextual hints, onboarding
+ * nudges, and helpful framing inside forms. Use it over Alert when the tone should feel
+ * conversational rather than "system-generated".
+ */
+export const Callout = ({
+ variant = "info",
+ title,
+ children,
+ icon,
+ onDismiss,
+ className,
+ }: CalloutProps) => {
+ return (
+
+ );
+};
diff --git a/frontend/src/components/common/CheckIn/AttendeeList.tsx b/frontend/src/components/common/CheckIn/AttendeeList.tsx
deleted file mode 100644
index 7662b397a2..0000000000
--- a/frontend/src/components/common/CheckIn/AttendeeList.tsx
+++ /dev/null
@@ -1,120 +0,0 @@
-import {Button, Loader} from "@mantine/core";
-import {IconTicket} from "@tabler/icons-react";
-import {t} from "@lingui/macro";
-import {Attendee} from "../../../types.ts";
-import classes from "../../layouts/CheckIn/CheckIn.module.scss";
-
-interface AttendeeListProps {
- attendees: Attendee[] | undefined;
- products: { id: number; title: string; }[] | undefined;
- isLoading: boolean;
- isCheckInPending: boolean;
- isDeletePending: boolean;
- allowOrdersAwaitingOfflinePaymentToCheckIn: boolean;
- onCheckInToggle: (attendee: Attendee) => void;
- onClickSound?: () => void;
-}
-
-export const AttendeeList = ({
- attendees,
- products,
- isLoading,
- isCheckInPending,
- isDeletePending,
- allowOrdersAwaitingOfflinePaymentToCheckIn,
- onCheckInToggle,
- onClickSound
- }: AttendeeListProps) => {
- const checkInButtonText = (attendee: Attendee) => {
- if (!allowOrdersAwaitingOfflinePaymentToCheckIn && attendee.status === 'AWAITING_PAYMENT') {
- return t`Cannot Check In`;
- }
-
- if (attendee.status === 'CANCELLED') {
- return t`Cannot Check In (Cancelled)`;
- }
-
- if (attendee.check_in) {
- return t`Check Out`;
- }
-
- return t`Check In`;
- };
-
- const getButtonColor = (attendee: Attendee) => {
- if (attendee.check_in || attendee.status === 'CANCELLED') {
- return 'red';
- }
- if (attendee.status === 'AWAITING_PAYMENT' && !allowOrdersAwaitingOfflinePaymentToCheckIn) {
- return 'gray';
- }
- return 'teal';
- };
-
- if (isLoading || !attendees || !products) {
- return (
-
-
-
- );
- }
-
- if (attendees.length === 0) {
- return (
-
- No attendees to show.
-
- );
- }
-
- return (
-
- {attendees.map(attendee => {
- const isAttendeeAwaitingPayment = attendee.status === 'AWAITING_PAYMENT';
-
- return (
-
-
-
- {attendee.first_name} {attendee.last_name}
-
- {attendee.status === 'CANCELLED' ? (
-
- {t`Ticket Cancelled`}
-
- ) : null}
-
- {attendee.email}
-
- {isAttendeeAwaitingPayment && (
-
- {t`Awaiting payment`}
-
- )}
-
- {attendee.public_id}
-
-
- {products.find(product => product.id === attendee.product_id)?.title}
-
-
-
- {
- onClickSound?.();
- onCheckInToggle(attendee);
- }}
- disabled={isCheckInPending || isDeletePending || attendee.status === 'CANCELLED'}
- loading={isCheckInPending || isDeletePending}
- color={getButtonColor(attendee)}
- >
- {checkInButtonText(attendee)}
-
-
-
- );
- })}
-
- );
-};
diff --git a/frontend/src/components/common/CheckIn/CheckInDescriptionModal.tsx b/frontend/src/components/common/CheckIn/CheckInDescriptionModal.tsx
new file mode 100644
index 0000000000..5c28cedd5b
--- /dev/null
+++ b/frontend/src/components/common/CheckIn/CheckInDescriptionModal.tsx
@@ -0,0 +1,44 @@
+import {Button, Modal} from "@mantine/core";
+import {t} from "@lingui/macro";
+import {IconInfoCircle} from "@tabler/icons-react";
+
+interface Props {
+ isOpen: boolean;
+ description: string | null | undefined;
+ onDismiss: () => void;
+}
+
+export const CheckInDescriptionModal = ({isOpen, description, onDismiss}: Props) => {
+ if (!description) return null;
+
+ return (
+
+ {t`Door staff instructions`}
+
+ }
+ size="md"
+ centered
+ radius="md"
+ padding={24}
+ >
+
+ {description}
+
+
+ {t`Got it`}
+
+
+ );
+};
diff --git a/frontend/src/components/common/CheckIn/CheckInInfoModal.tsx b/frontend/src/components/common/CheckIn/CheckInInfoModal.tsx
index a0d5584f2b..a4cfe7aff4 100644
--- a/frontend/src/components/common/CheckIn/CheckInInfoModal.tsx
+++ b/frontend/src/components/common/CheckIn/CheckInInfoModal.tsx
@@ -1,8 +1,9 @@
import {Modal, Progress} from "@mantine/core";
-import {Trans} from "@lingui/macro";
+import {t, Trans} from "@lingui/macro";
import Truncate from "../Truncate";
import {CheckInList} from "../../../types.ts";
-import classes from "../../layouts/CheckIn/CheckIn.module.scss";
+import {PoweredByFooter} from "../PoweredByFooter";
+import {getConfig} from "../../../utilites/config";
interface CheckInInfoModalProps {
isOpen: boolean;
@@ -11,53 +12,97 @@ interface CheckInInfoModalProps {
}
export const CheckInInfoModal = ({
- isOpen,
- checkInList,
- onClose
-}: CheckInInfoModalProps) => {
+ isOpen,
+ checkInList,
+ onClose,
+ }: CheckInInfoModalProps) => {
if (!checkInList) return null;
-
+
+ const total = checkInList.total_attendees;
+ const checkedIn = checkInList.checked_in_attendees;
+ const percent = total > 0 ? (checkedIn / total) * 100 : 0;
+ const appName = getConfig("VITE_APP_NAME", "Hi.Events");
+ const logoSrc = getConfig("VITE_APP_LOGO_LIGHT", "/logos/hi-events-text-light.svg");
+
return (
- }
+ size="md"
+ centered
+ radius="md"
+ padding={24}
+ transitionProps={{transition: "fade", duration: 200}}
>
-
-
-
-
-
-
-
-
-
-
- <>
-
-
- {`${checkInList.checked_in_attendees}/${checkInList.total_attendees}`} checked in
-
-
+
+
+ {t`Progress`}
+
+
+ {`${checkedIn} / ${total}`} checked in
+
+
+
-
- >
+ {checkInList.description && (
+
+
+ {t`Staff instructions`}
-
- {checkInList.description && (
-
- {checkInList.description}
-
- )}
+ {checkInList.description}
-
-
+ )}
+
+
+
+
+
+
);
};
diff --git a/frontend/src/components/common/CheckIn/HidScannerStatus.tsx b/frontend/src/components/common/CheckIn/HidScannerStatus.tsx
deleted file mode 100644
index 4cc250f7be..0000000000
--- a/frontend/src/components/common/CheckIn/HidScannerStatus.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import {Button} from "@mantine/core";
-import {IconScan, IconX} from "@tabler/icons-react";
-import {t} from "@lingui/macro";
-import {showSuccess} from "../../../utilites/notifications.tsx";
-
-interface HidScannerStatusProps {
- isActive: boolean;
- pageHasFocus: boolean;
- onDisable: () => void;
-}
-
-export const HidScannerStatus = ({
- isActive,
- pageHasFocus,
- onDisable
-}: HidScannerStatusProps) => {
- if (!isActive) return null;
-
- return (
-
-
-
-
- {pageHasFocus
- ? 'USB Scanner Active - Ready to Scan'
- : 'USB Scanner Paused - Click anywhere to resume scanning'}
-
-
-
}
- miw={95}
- onClick={() => {
- onDisable();
- showSuccess(t`USB Scanner mode deactivated`);
- }}
- >
- Disable
-
-
- );
-};
diff --git a/frontend/src/components/common/CheckIn/ScannerSelectionModal.tsx b/frontend/src/components/common/CheckIn/ScannerSelectionModal.tsx
deleted file mode 100644
index 3ffe4c65eb..0000000000
--- a/frontend/src/components/common/CheckIn/ScannerSelectionModal.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import {Button, Modal, Stack} from "@mantine/core";
-import {IconCamera, IconScan} from "@tabler/icons-react";
-import {t} from "@lingui/macro";
-import {showSuccess} from "../../../utilites/notifications.tsx";
-
-interface ScannerSelectionModalProps {
- isOpen: boolean;
- isHidScannerActive: boolean;
- onClose: () => void;
- onCameraSelect: () => void;
- onHidScannerSelect: () => void;
-}
-
-export const ScannerSelectionModal = ({
- isOpen,
- isHidScannerActive,
- onClose,
- onCameraSelect,
- onHidScannerSelect
-}: ScannerSelectionModalProps) => {
- return (
-
-
- }
- onClick={onCameraSelect}
- fullWidth
- variant="light"
- >
- {t`Camera Scanner`}
-
- }
- onClick={() => {
- onHidScannerSelect();
- if (!isHidScannerActive) {
- showSuccess(t`USB Scanner mode activated. Start scanning tickets now.`);
- }
- }}
- fullWidth
- variant="light"
- color={isHidScannerActive ? "gray" : undefined}
- disabled={isHidScannerActive}
- >
- {isHidScannerActive ? t`USB Scanner Already Active` : t`USB/HID Scanner`}
-
-
- {t`Cancel`}
-
-
-
- );
-};
\ No newline at end of file
diff --git a/frontend/src/components/common/CheckInListTable/CheckInListTable.module.scss b/frontend/src/components/common/CheckInListTable/CheckInListTable.module.scss
new file mode 100644
index 0000000000..7ae5c700bd
--- /dev/null
+++ b/frontend/src/components/common/CheckInListTable/CheckInListTable.module.scss
@@ -0,0 +1,102 @@
+.listDetails {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-width: 0;
+ flex: 1;
+}
+
+.listName {
+ font-size: 15px;
+ font-weight: 600;
+ line-height: 1.3;
+ text-decoration: none;
+ color: var(--mantine-color-text);
+
+ &:hover {
+ text-decoration: underline;
+ cursor: pointer;
+ }
+}
+
+.nameRow {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.listDescription {
+ font-size: 12px;
+ color: var(--mantine-color-dimmed);
+ line-height: 1.3;
+}
+
+.productsText {
+ font-size: 12px;
+ color: var(--mantine-color-dimmed);
+ line-height: 1.3;
+}
+
+.occurrenceContainer {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.occurrenceChip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 12px;
+ line-height: 1.3;
+ color: var(--mantine-primary-color-filled);
+ white-space: nowrap;
+}
+
+.occurrenceText {
+ font-size: 13px;
+ color: var(--mantine-color-dimmed);
+}
+
+.progressContainer {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-width: 140px;
+}
+
+.progressText {
+ font-size: 12px;
+ color: var(--mantine-color-dimmed);
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.statusBadge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 6px 12px;
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ white-space: nowrap;
+
+ &[data-status="active"] {
+ background: var(--mantine-color-green-1);
+ color: var(--mantine-color-green-9);
+ }
+
+ &[data-status="inactive"] {
+ background: var(--mantine-color-gray-1);
+ color: var(--mantine-color-gray-6);
+ }
+}
+
+.actionsMenu {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+}
diff --git a/frontend/src/components/common/CheckInListTable/index.tsx b/frontend/src/components/common/CheckInListTable/index.tsx
new file mode 100644
index 0000000000..039103157f
--- /dev/null
+++ b/frontend/src/components/common/CheckInListTable/index.tsx
@@ -0,0 +1,300 @@
+import {Anchor, Badge, Button, Progress} from '@mantine/core';
+import {CheckInList, Event, EventType, IdParam} from "../../../types.ts";
+import {
+ IconCalendarEvent,
+ IconCheck,
+ IconCopy,
+ IconExternalLink,
+ IconPencil,
+ IconPlus,
+ IconTrash,
+ IconUsers,
+ IconX,
+} from "@tabler/icons-react";
+import {useMemo, useState} from "react";
+import {useDisclosure} from "@mantine/hooks";
+import {useParams} from "react-router";
+import {t, Trans} from "@lingui/macro";
+import {NoResultsSplash} from "../NoResultsSplash";
+import {EditCheckInListModal} from "../../modals/EditCheckInListModal";
+import {useDeleteCheckInList} from "../../../mutations/useDeleteCheckInList";
+import {showError, showSuccess} from "../../../utilites/notifications.tsx";
+import {confirmationDialog} from "../../../utilites/confirmationDialog.tsx";
+import {TanStackTable, TanStackTableColumn} from "../TanStackTable";
+import {ActionMenu} from '../ActionMenu';
+import {CellContext} from "@tanstack/react-table";
+import Truncate from "../Truncate";
+import {formatDateWithLocale} from "../../../utilites/dates.ts";
+import classes from './CheckInListTable.module.scss';
+
+interface CheckInListTableProps {
+ checkInLists: CheckInList[];
+ openCreateModal: () => void;
+ event?: Event;
+}
+
+export const CheckInListTable = ({checkInLists, openCreateModal, event}: CheckInListTableProps) => {
+ const [editModalOpen, {open: openEditModal, close: closeEditModal}] = useDisclosure(false);
+ const [selectedCheckInListId, setSelectedCheckInListId] = useState
();
+ const deleteMutation = useDeleteCheckInList();
+ const {eventId} = useParams();
+ const isRecurring = event?.type === EventType.RECURRING;
+
+ const handleDeleteCheckInList = (checkInListId: IdParam, eventId: IdParam) => {
+ deleteMutation.mutate({checkInListId, eventId}, {
+ onSuccess: () => {
+ showSuccess(t`Check-In List deleted successfully`);
+ },
+ onError: (error: any) => {
+ showError(error.message);
+ }
+ });
+ }
+
+ const columns = useMemo[]>(
+ () => {
+ const allColumns: TanStackTableColumn[] = [
+ {
+ id: 'name',
+ header: t`Check-In List`,
+ enableHiding: false,
+ cell: (info: CellContext) => {
+ const list = info.row.original;
+ const coversAllTickets = !list.products || list.products.length === 0;
+ return (
+
+
+
{
+ setSelectedCheckInListId(list.id as IdParam);
+ openEditModal();
+ }}
+ >
+
+
+ {list.is_system_default && (
+
{t`Default`}
+ )}
+
+
+ {coversAllTickets
+ ? t`Covers every ticket`
+ : list.products!.length === 1
+ ? t`Includes 1 product`
+ : Includes {list.products!.length} products
+ }
+
+
+ );
+ },
+ meta: {
+ headerStyle: {minWidth: 250},
+ },
+ },
+ {
+ id: 'occurrence',
+ header: t`Date`,
+ enableHiding: true,
+ cell: (info: CellContext) => {
+ const list = info.row.original;
+ const occurrence = list.event_occurrence;
+
+ if (!occurrence || !event?.timezone) {
+ return (
+
+ {t`All Dates`}
+
+ );
+ }
+
+ return (
+
+
+
+ {formatDateWithLocale(occurrence.start_date, 'shortDate', event.timezone)}
+ {' '}
+ {formatDateWithLocale(occurrence.start_date, 'timeOnly', event.timezone)}
+ {occurrence.label && ` · ${occurrence.label}`}
+
+
+ );
+ },
+ meta: {
+ headerStyle: {minWidth: 160},
+ },
+ },
+ {
+ id: 'progress',
+ header: t`Check-Ins`,
+ enableHiding: true,
+ cell: (info: CellContext) => {
+ const list = info.row.original;
+ const percentage = list.total_attendees === 0
+ ? 0
+ : (list.checked_in_attendees / list.total_attendees) * 100;
+ return (
+
+
0 ? 'primary' : 'green'}
+ size="md"
+ />
+
+
+ {list.checked_in_attendees} / {list.total_attendees}
+
+
+ );
+ },
+ meta: {
+ headerStyle: {minWidth: 160},
+ },
+ },
+ {
+ id: 'status',
+ header: t`Status`,
+ enableHiding: true,
+ cell: (info: CellContext) => {
+ const list = info.row.original;
+ const isActive = !list.is_expired && list.is_active;
+ return (
+
+ {isActive ? (
+ <>
+
+ {t`Active`}
+ >
+ ) : (
+ <>
+
+ {t`Inactive`}
+ >
+ )}
+
+ );
+ },
+ meta: {
+ headerStyle: {minWidth: 100},
+ },
+ },
+ {
+ id: 'actions',
+ header: '',
+ enableHiding: false,
+ cell: (info: CellContext) => {
+ const list = info.row.original;
+ const manageItems = [
+ {
+ label: t`Edit Check-In List`,
+ icon: ,
+ onClick: () => {
+ setSelectedCheckInListId(list.id as IdParam);
+ openEditModal();
+ }
+ },
+ {
+ label: t`Copy Check-In URL`,
+ icon: ,
+ onClick: () => {
+ navigator.clipboard.writeText(
+ `${window.location.origin}/check-in/${list.short_id}`
+ ).then(() => {
+ showSuccess(t`Check-In URL copied to clipboard`);
+ });
+ }
+ },
+ {
+ label: t`Open Check-In Page`,
+ icon: ,
+ onClick: () => {
+ window.open(`/check-in/${list.short_id}`, '_blank');
+ }
+ },
+ ];
+ const groups: {label: string; items: any[]}[] = [
+ {label: t`Manage`, items: manageItems},
+ ];
+ if (!list.is_system_default) {
+ groups.push({
+ label: t`Danger zone`,
+ items: [
+ {
+ label: t`Delete Check-In List`,
+ icon: ,
+ onClick: () => {
+ confirmationDialog(
+ t`Are you sure you would like to delete this Check-In List?`,
+ () => {
+ handleDeleteCheckInList(
+ list.id as IdParam,
+ eventId,
+ );
+ })
+ },
+ color: 'red',
+ },
+ ],
+ });
+ }
+ return (
+
+ );
+ },
+ meta: {
+ sticky: 'right',
+ },
+ },
+ ];
+
+ return allColumns.filter(column => {
+ if (column.id === 'occurrence' && !isRecurring) {
+ return false;
+ }
+ return true;
+ });
+ },
+ [eventId, isRecurring, event?.timezone]
+ );
+
+ if (checkInLists.length === 0) {
+ return (
+
+
+
+
+ Check-in lists help you manage event entry by day, area, or ticket type. You can link tickets to specific lists such as VIP zones or Day 1 passes and share a secure check-in link with staff. No account is required. Check-in works on mobile, desktop, or tablet, using a device camera or HID USB scanner.
+
+
+ }
+ color={'green'}
+ onClick={() => openCreateModal()}>{t`Create Check-In List`}
+
+ >
+ )}
+ />
+ );
+ }
+
+ return (
+ <>
+
+ {(editModalOpen && selectedCheckInListId)
+ && }
+ >
+ );
+};
diff --git a/frontend/src/components/common/CheckInStatusModal/index.tsx b/frontend/src/components/common/CheckInStatusModal/index.tsx
index 17f3825555..8147d53c1d 100644
--- a/frontend/src/components/common/CheckInStatusModal/index.tsx
+++ b/frontend/src/components/common/CheckInStatusModal/index.tsx
@@ -22,7 +22,7 @@ export const CheckInStatusModal = ({
isOpen,
onClose
}: CheckInStatusModalProps) => {
- const {data: checkInListsResponse, isLoading, ...rest} = useGetEventCheckInLists(eventId);
+ const {data: checkInListsResponse, isLoading} = useGetEventCheckInLists(eventId);
if (isLoading) {
return (
@@ -48,11 +48,17 @@ export const CheckInStatusModal = ({
);
}
- const checkInLists = checkInListsResponse?.data || [];
+ const allCheckInLists = checkInListsResponse?.data || [];
+ const checkInLists = allCheckInLists.filter(list =>
+ !list.event_occurrence_id || list.event_occurrence_id === attendee.event_occurrence_id
+ );
const attendeeCheckIns = attendee.check_ins || [];
const getCheckInForList = (listId: number | undefined) => {
- return attendeeCheckIns.find(ci => ci.check_in_list_id === listId);
+ return attendeeCheckIns.find(ci =>
+ ci.check_in_list_id === listId
+ && (!attendee.event_occurrence_id || ci.event_occurrence_id === attendee.event_occurrence_id)
+ );
};
const isAttendeeEligibleForList = (list: CheckInList) => {
diff --git a/frontend/src/components/common/Editor/Controls/InsertLiquidVariableControl.tsx b/frontend/src/components/common/Editor/Controls/InsertLiquidVariableControl.tsx
index 411e23dd42..84db1cd813 100644
--- a/frontend/src/components/common/Editor/Controls/InsertLiquidVariableControl.tsx
+++ b/frontend/src/components/common/Editor/Controls/InsertLiquidVariableControl.tsx
@@ -15,6 +15,14 @@ interface TemplateVariable {
category?: string;
}
+const OCCURRENCE_VARIABLES: TemplateVariable[] = [
+ {label: t`Occurrence Start Date`, value: 'occurrence.start_date', description: t`Start date of the occurrence`, category: t`Occurrence`},
+ {label: t`Occurrence Start Time`, value: 'occurrence.start_time', description: t`Start time of the occurrence`, category: t`Occurrence`},
+ {label: t`Occurrence End Date`, value: 'occurrence.end_date', description: t`End date of the occurrence`, category: t`Occurrence`},
+ {label: t`Occurrence End Time`, value: 'occurrence.end_time', description: t`End time of the occurrence`, category: t`Occurrence`},
+ {label: t`Occurrence Label`, value: 'occurrence.label', description: t`Label for the occurrence`, category: t`Occurrence`},
+];
+
const TEMPLATE_VARIABLES: Record = {
order_confirmation: [
// Order Information
@@ -39,6 +47,7 @@ const TEMPLATE_VARIABLES: Record = {
{label: t`Event Timezone`, value: 'event.timezone', description: t`Event timezone`, category: t`Event`},
{label: t`Event Venue`, value: 'event.location_details.venue_name', description: t`The event venue`, category: t`Event`},
+ ...OCCURRENCE_VARIABLES,
// Organization
{label: t`Organizer Name`, value: 'organizer.name', description: t`Event organizer name`, category: t`Organization`},
@@ -62,7 +71,7 @@ const TEMPLATE_VARIABLES: Record = {
// Order Information
{label: t`Order Payment Pending`, value: 'order.is_awaiting_offline_payment', description: t`True if payment pending`, category: t`Order`},
{label: t`Order Status`, value: 'order.status', description: t`Order Status`, category: t`Order`},
- {label: t`Offline Payment`, value: 'is_offline_payment', description: t`True if offline payment`, category: t`Order`},
+ {label: t`Offline Payment`, value: 'order.is_offline_payment', description: t`True if offline payment`, category: t`Order`},
// Event Information
{label: t`Event Title`, value: 'event.title', description: t`Name of the event`, category: t`Event`},
@@ -73,6 +82,8 @@ const TEMPLATE_VARIABLES: Record = {
{label: t`Event Timezone`, value: 'event.timezone', description: t`Event timezone`, category: t`Event`},
{label: t`Event Venue`, value: 'event.location_details.venue_name', description: t`The event venue`, category: t`Event`},
+ ...OCCURRENCE_VARIABLES,
+
// Organization
{label: t`Organizer Name`, value: 'organizer.name', description: t`Event organizer name`, category: t`Organization`},
{label: t`Organizer Email`, value: 'organizer.email', description: t`Organizer email address`, category: t`Organization`},
@@ -82,6 +93,25 @@ const TEMPLATE_VARIABLES: Record = {
{label: t`Offline Payment Instructions`, value: 'settings.offline_payment_instructions', description: t`How to pay offline`, category: t`Settings`},
{label: t`Post Checkout Message`, value: 'settings.post_checkout_message', description: t`Custom message after checkout`, category: t`Settings`},
],
+ occurrence_cancellation: [
+ {label: t`Event Title`, value: 'event.title', description: t`Name of the event`, category: t`Event`},
+ {label: t`Event Date`, value: 'event.date', description: t`Date of the event`, category: t`Event`},
+ {label: t`Event Time`, value: 'event.time', description: t`Start time of the event`, category: t`Event`},
+ {label: t`Event Full Address`, value: 'event.full_address', description: t`The full event address`, category: t`Event`},
+ {label: t`Event Description`, value: 'event.description', description: t`Event details`, category: t`Event`},
+ {label: t`Event Timezone`, value: 'event.timezone', description: t`Event timezone`, category: t`Event`},
+ {label: t`Event Venue`, value: 'event.location_details.venue_name', description: t`The event venue`, category: t`Event`},
+ {label: t`Event URL`, value: 'event.url', description: t`Link to event homepage`, category: t`Event`},
+
+ ...OCCURRENCE_VARIABLES,
+
+ {label: t`Refund Issued`, value: 'cancellation.refund_issued', description: t`Whether refunds are being processed`, category: t`Cancellation`},
+
+ {label: t`Organizer Name`, value: 'organizer.name', description: t`Event organizer name`, category: t`Organization`},
+ {label: t`Organizer Email`, value: 'organizer.email', description: t`Organizer email address`, category: t`Organization`},
+
+ {label: t`Support Email`, value: 'settings.support_email', description: t`Contact email for support`, category: t`Settings`},
+ ],
};
export function InsertLiquidVariableControl({templateType = 'order_confirmation'}: InsertLiquidVariableControlProps) {
diff --git a/frontend/src/components/common/EmailTemplateEditor/EmailTemplateEditor.tsx b/frontend/src/components/common/EmailTemplateEditor/EmailTemplateEditor.tsx
index 8cd25b00cc..6b2f5234ba 100644
--- a/frontend/src/components/common/EmailTemplateEditor/EmailTemplateEditor.tsx
+++ b/frontend/src/components/common/EmailTemplateEditor/EmailTemplateEditor.tsx
@@ -54,7 +54,10 @@ export const EmailTemplateEditor = ({
if (!template && defaultTemplate && defaultTemplate.subject && defaultTemplate.body) {
form.setFieldValue('subject', defaultTemplate.subject);
form.setFieldValue('body', defaultTemplate.body);
- form.setFieldValue('ctaLabel', templateType === 'order_confirmation' ? t`View Order` : t`View Ticket`);
+ const defaultCtaLabel = templateType === 'order_confirmation' ? t`View Order`
+ : templateType === 'occurrence_cancellation' ? t`View Event`
+ : t`View Ticket`;
+ form.setFieldValue('ctaLabel', defaultCtaLabel);
form.setFieldValue('isActive', true);
}
}, [defaultTemplate, template]);
@@ -66,7 +69,7 @@ export const EmailTemplateEditor = ({
subject: form.values.subject,
body: form.values.body,
template_type: templateType,
- ctaLabel: form.values.ctaLabel || (templateType === 'order_confirmation' ? t`View Order` : t`View Ticket`),
+ ctaLabel: form.values.ctaLabel || (templateType === 'order_confirmation' ? t`View Order` : templateType === 'occurrence_cancellation' ? t`View Event` : t`View Ticket`),
});
}
};
@@ -93,6 +96,7 @@ export const EmailTemplateEditor = ({
const templateTypeLabels: Record = {
'order_confirmation': t`Order Confirmation`,
'attendee_ticket': t`Attendee Ticket`,
+ 'occurrence_cancellation': t`Date Cancellation`,
};
return (
diff --git a/frontend/src/components/common/EmailTemplateSettings/EmailTemplateSettingsBase.tsx b/frontend/src/components/common/EmailTemplateSettings/EmailTemplateSettingsBase.tsx
index dbc0fb44c7..f234c91906 100644
--- a/frontend/src/components/common/EmailTemplateSettings/EmailTemplateSettingsBase.tsx
+++ b/frontend/src/components/common/EmailTemplateSettings/EmailTemplateSettingsBase.tsx
@@ -1,6 +1,6 @@
import {useState} from 'react';
import {ActionIcon, Alert, Badge, Button, Group, LoadingOverlay, Modal, Paper, Stack, Text} from '@mantine/core';
-import {IconAlertCircle, IconEdit, IconInfoCircle, IconMail, IconPlus, IconTrash} from '@tabler/icons-react';
+import {IconAlertCircle, IconBrandStripe, IconEdit, IconInfoCircle, IconMail, IconPlus, IconTrash} from '@tabler/icons-react';
import {t, Trans} from '@lingui/macro';
import {useDisclosure} from '@mantine/hooks';
import {EmailTemplateEditor} from '../EmailTemplateEditor';
@@ -11,13 +11,15 @@ import {
CreateEmailTemplateRequest,
EmailTemplate,
EmailTemplateType,
+ EventType,
UpdateEmailTemplateRequest,
DefaultEmailTemplate
} from '../../../types';
import {Card} from '../Card';
import {HeadingWithDescription} from '../Card/CardHeading';
import {useGetAccount} from '../../../queries/useGetAccount';
-import {StripeConnectButton} from '../StripeConnectButton';
+import {NavLink} from 'react-router';
+import {useGetEvent} from '../../../queries/useGetEvent';
interface EmailTemplateSettingsBaseProps {
// Context
@@ -53,6 +55,7 @@ interface EmailTemplateSettingsBaseProps {
onSaveSuccess?: () => void;
onDeleteSuccess?: () => void;
onError?: (error: any, message: string) => void;
+ eventType?: EventType;
}
export const EmailTemplateSettingsBase = ({
@@ -68,19 +71,25 @@ export const EmailTemplateSettingsBase = ({
onCreateTemplate,
onSaveSuccess,
onDeleteSuccess,
- onError
+ onError,
+ eventType
}: EmailTemplateSettingsBaseProps) => {
const [editorOpened, {open: openEditor, close: closeEditor}] = useDisclosure(false);
const [editingTemplate, setEditingTemplate] = useState(null);
const [editingType, setEditingType] = useState('order_confirmation');
const handleFormError = useFormErrorResponseHandler();
const {data: account, isFetched: isAccountFetched} = useGetAccount();
+ const eventQuery = useGetEvent(contextType === 'event' ? contextId : undefined);
+ const stripeOrganizerId = contextType === 'organizer'
+ ? contextId
+ : eventQuery.data?.organizer_id;
const isAccountVerified = isAccountFetched && account?.is_account_email_confirmed;
const accountRequiresManualVerification = isAccountFetched && account?.requires_manual_verification;
const isModifyDisabled = !isAccountVerified || accountRequiresManualVerification;
const orderConfirmationTemplate = templates.find(t => t.template_type === 'order_confirmation');
const attendeeTicketTemplate = templates.find(t => t.template_type === 'attendee_ticket');
+ const occurrenceCancellationTemplate = templates.find(t => t.template_type === 'occurrence_cancellation');
const handleCreateTemplate = (type: EmailTemplateType) => {
setEditingTemplate(null);
@@ -199,11 +208,13 @@ export const EmailTemplateSettingsBase = ({
const templateTypeLabels: Record = {
'order_confirmation': t`Order Confirmation`,
'attendee_ticket': t`Attendee Ticket`,
+ 'occurrence_cancellation': t`Date Cancellation`,
};
const templateDescriptions: Record = {
'order_confirmation': t`Sent to customers when they place an order`,
'attendee_ticket': t`Sent to each attendee with their ticket details`,
+ 'occurrence_cancellation': t`Sent to attendees when a scheduled date is cancelled`,
};
const getTemplateStatusBadge = (template?: EmailTemplate) => {
@@ -350,9 +361,18 @@ export const EmailTemplateSettingsBase = ({
{t`Due to the high risk of spam, you must connect a Stripe account before you can modify email templates. This is to ensure that all event organizers are verified and accountable.`}
-
-
-
+ {stripeOrganizerId && (
+
+ }
+ variant="light"
+ >
+ {t`Connect Stripe`}
+
+
+ )}
)}
@@ -379,6 +399,15 @@ export const EmailTemplateSettingsBase = ({
label={templateTypeLabels.attendee_ticket}
description={templateDescriptions.attendee_ticket}
/>
+
+ {(contextType === 'organizer' || eventType === EventType.RECURRING) && (
+
+ )}
diff --git a/frontend/src/components/common/EventCard/EventCard.module.scss b/frontend/src/components/common/EventCard/EventCard.module.scss
index e1b4bfad6e..76a11548ed 100644
--- a/frontend/src/components/common/EventCard/EventCard.module.scss
+++ b/frontend/src/components/common/EventCard/EventCard.module.scss
@@ -174,6 +174,30 @@
margin-top: 2px;
}
+.recurringBadge {
+ position: absolute;
+ bottom: 10px;
+ right: 10px;
+ background: var(--mantine-color-white);
+ border-radius: var(--hi-radius-sm);
+ padding: 6px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
+ color: var(--mantine-color-primary-filled);
+}
+
+.recurringLabel {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ color: var(--mantine-color-primary-filled);
+ font-weight: 500;
+ padding-left: 6px;
+ border-left: 1px solid var(--mantine-color-gray-3);
+}
+
.content {
flex: 1;
padding: 16px 20px;
@@ -222,8 +246,12 @@
}
.eventDate {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
color: var(--mantine-color-text);
font-weight: 500;
+ white-space: nowrap;
}
.relativeDate {
diff --git a/frontend/src/components/common/EventCard/index.tsx b/frontend/src/components/common/EventCard/index.tsx
index 59ecbc963f..fdc362fa24 100644
--- a/frontend/src/components/common/EventCard/index.tsx
+++ b/frontend/src/components/common/EventCard/index.tsx
@@ -1,5 +1,5 @@
import {ActionIcon, Tooltip} from '@mantine/core';
-import {Event, IdParam, Product} from "../../../types.ts";
+import {Event, EventType, IdParam, Product} from "../../../types.ts";
import classes from "./EventCard.module.scss";
import {NavLink, useNavigate} from "react-router";
import {
@@ -7,6 +7,7 @@ import {
IconCopy,
IconDotsVertical,
IconEye,
+ IconRepeat,
IconSettings,
} from "@tabler/icons-react";
import {t} from "@lingui/macro"
@@ -151,10 +152,6 @@ export function EventCard({event}: EventCardProps) {
},
];
- const monthShort = formatDateWithLocale(event.start_date, 'monthShort', event.timezone);
- const dayOfMonth = formatDateWithLocale(event.start_date, 'dayOfMonth', event.timezone);
- const shortDateTime = formatDateWithLocale(event.start_date, 'shortDateTime', event.timezone);
- const relativeDateStr = relativeDate(event.start_date);
const locationText = getLocationText();
const revenue = event?.statistics?.sales_total_gross || 0;
@@ -165,6 +162,13 @@ export function EventCard({event}: EventCardProps) {
const isEnded = event.lifecycle_status === 'ENDED';
const isDraft = event.status === 'DRAFT';
+ const isRecurring = event.type === EventType.RECURRING;
+
+ const displayDate = (isRecurring && event.next_occurrence_start_date) || event.start_date;
+ const monthShort = formatDateWithLocale(displayDate, 'monthShort', event.timezone);
+ const dayOfMonth = formatDateWithLocale(displayDate, 'dayOfMonth', event.timezone);
+ const shortDateTime = formatDateWithLocale(displayDate, 'shortDateTime', event.timezone);
+ const relativeDateStr = relativeDate(displayDate);
return (
<>
@@ -189,13 +193,27 @@ export function EventCard({event}: EventCardProps) {
{dayOfMonth}
{monthShort}
+
+ {isRecurring && (
+
+
+
+ )}
{event.title}
-
{shortDateTime}
+
+ {shortDateTime}
+ {isRecurring && (
+
+
+ {t`Recurring`}
+
+ )}
+
({relativeDateStr})
{locationText && (
<>
diff --git a/frontend/src/components/common/EventDateRange/index.tsx b/frontend/src/components/common/EventDateRange/index.tsx
index 89c2ba9fbb..e157928d0b 100644
--- a/frontend/src/components/common/EventDateRange/index.tsx
+++ b/frontend/src/components/common/EventDateRange/index.tsx
@@ -1,18 +1,20 @@
-import { Event } from "../../../types.ts";
-import { formatDateWithLocale } from "../../../utilites/dates.ts";
+import {t} from "@lingui/macro";
+import {Event, EventOccurrence, EventOccurrenceStatus, EventType} from "../../../types.ts";
+import {formatDateWithLocale} from "../../../utilites/dates.ts";
interface EventDateRangeProps {
event: Event;
+ occurrence?: EventOccurrence;
}
-export const EventDateRange = ({ event }: EventDateRangeProps) => {
- const isSameDay = event.end_date && event.start_date.substring(0, 10) === event.end_date.substring(0, 10);
- const timezone = formatDateWithLocale(event.start_date, "timezone", event.timezone);
+const formatRange = (startDate: string, endDate: string | undefined, tz: string) => {
+ const isSameDay = endDate && startDate.substring(0, 10) === endDate.substring(0, 10);
+ const timezone = formatDateWithLocale(startDate, "timezone", tz);
if (isSameDay) {
- const dayFormatted = formatDateWithLocale(event.start_date, "dayName", event.timezone);
- const startTime = formatDateWithLocale(event.start_date, "timeOnly", event.timezone);
- const endTime = formatDateWithLocale(event.end_date!, "timeOnly", event.timezone);
+ const dayFormatted = formatDateWithLocale(startDate, "dayName", tz);
+ const startTime = formatDateWithLocale(startDate, "timeOnly", tz);
+ const endTime = formatDateWithLocale(endDate!, "timeOnly", tz);
return (
@@ -21,9 +23,9 @@ export const EventDateRange = ({ event }: EventDateRangeProps) => {
);
}
- const startDateFormatted = formatDateWithLocale(event.start_date, "fullDateTime", event.timezone);
- const endDateFormatted = event.end_date
- ? formatDateWithLocale(event.end_date, "fullDateTime", event.timezone)
+ const startDateFormatted = formatDateWithLocale(startDate, "fullDateTime", tz);
+ const endDateFormatted = endDate
+ ? formatDateWithLocale(endDate, "fullDateTime", tz)
: null;
return (
@@ -32,4 +34,33 @@ export const EventDateRange = ({ event }: EventDateRangeProps) => {
{endDateFormatted && ` - ${endDateFormatted}`} {timezone}
);
-}
+};
+
+export const EventDateRange = ({event, occurrence}: EventDateRangeProps) => {
+ if (occurrence) {
+ return formatRange(occurrence.start_date, occurrence.end_date, event.timezone);
+ }
+
+ if (event.type === EventType.RECURRING) {
+ const activeOccurrences = (event.occurrences || [])
+ .filter(o => o.status === EventOccurrenceStatus.ACTIVE && !o.is_past)
+ .sort((a, b) => a.start_date.localeCompare(b.start_date));
+
+ if (activeOccurrences.length > 0) {
+ const next = activeOccurrences[0];
+ if (activeOccurrences.length === 1) {
+ return formatRange(next.start_date, next.end_date, event.timezone);
+ }
+ const nextFormatted = formatDateWithLocale(next.start_date, "shortDateTime", event.timezone);
+ return (
+
+ {t`Next: ${nextFormatted}`} · {t`${activeOccurrences.length} upcoming dates`}
+
+ );
+ }
+
+ return
{t`No upcoming dates`} ;
+ }
+
+ return formatRange(event.start_date, event.end_date, event.timezone);
+};
diff --git a/frontend/src/components/common/InlineOrderSummary/index.tsx b/frontend/src/components/common/InlineOrderSummary/index.tsx
index 5f70770e4c..3c70f2aeda 100644
--- a/frontend/src/components/common/InlineOrderSummary/index.tsx
+++ b/frontend/src/components/common/InlineOrderSummary/index.tsx
@@ -80,8 +80,17 @@ export const InlineOrderSummary = ({
{event.title}
- {prettyDate(event.start_date, event.timezone, false)}
+ {prettyDate(
+ order.order_items?.[0]?.event_occurrence?.start_date || event.start_date,
+ event.timezone,
+ false
+ )}
+ {order.order_items?.[0]?.event_occurrence?.label && (
+
+ {order.order_items[0].event_occurrence.label}
+
+ )}
{location && (
{location}
)}
diff --git a/frontend/src/components/common/JoinWaitlistButton/index.tsx b/frontend/src/components/common/JoinWaitlistButton/index.tsx
index 30368db701..4f3ac337ff 100644
--- a/frontend/src/components/common/JoinWaitlistButton/index.tsx
+++ b/frontend/src/components/common/JoinWaitlistButton/index.tsx
@@ -9,11 +9,12 @@ interface JoinWaitlistButtonProps {
event: Event;
productPriceId: IdParam;
priceLabel?: string;
+ eventOccurrenceId?: IdParam;
}
-export const JoinWaitlistButton = ({product, event, productPriceId, priceLabel}: JoinWaitlistButtonProps) => {
+export const JoinWaitlistButton = ({product, event, productPriceId, priceLabel, eventOccurrenceId}: JoinWaitlistButtonProps) => {
const [modalOpen, {open: openModal, close: closeModal}] = useDisclosure(false);
- const {joined: hasJoined, markJoined} = useWaitlistJoined(event.id, productPriceId);
+ const {joined: hasJoined, markJoined} = useWaitlistJoined(event.id, productPriceId, eventOccurrenceId);
return (
<>
@@ -37,6 +38,7 @@ export const JoinWaitlistButton = ({product, event, productPriceId, priceLabel}:
event={event}
productPriceId={productPriceId}
priceLabel={priceLabel}
+ eventOccurrenceId={eventOccurrenceId}
onSuccess={() => {
markJoined();
closeModal();
diff --git a/frontend/src/components/common/OccurrenceAttendeesAndOrders/OccurrenceAttendeesAndOrders.module.scss b/frontend/src/components/common/OccurrenceAttendeesAndOrders/OccurrenceAttendeesAndOrders.module.scss
new file mode 100644
index 0000000000..e09386ed52
--- /dev/null
+++ b/frontend/src/components/common/OccurrenceAttendeesAndOrders/OccurrenceAttendeesAndOrders.module.scss
@@ -0,0 +1,39 @@
+.tabsCard {
+ padding: 0;
+
+ :global(.mantine-Tabs-list) {
+ padding: 0;
+ }
+
+ :global(.mantine-Tabs-tab:first-of-type) {
+ border-top-left-radius: var(--hi-radius-lg);
+ }
+}
+
+.tabCount {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 20px;
+ height: 20px;
+ padding: 0 6px;
+ margin-left: 6px;
+ border-radius: 10px;
+ font-size: 11px;
+ font-weight: 600;
+ background: var(--mantine-color-gray-1);
+ color: var(--mantine-color-gray-7);
+}
+
+.viewAllLink {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ padding-right: 12px;
+}
+
+@media (max-width: 768px) {
+ .viewAllLink {
+ display: none;
+ }
+}
diff --git a/frontend/src/components/common/OccurrenceAttendeesAndOrders/index.tsx b/frontend/src/components/common/OccurrenceAttendeesAndOrders/index.tsx
new file mode 100644
index 0000000000..4d59ca4394
--- /dev/null
+++ b/frontend/src/components/common/OccurrenceAttendeesAndOrders/index.tsx
@@ -0,0 +1,97 @@
+import {t} from "@lingui/macro";
+import {Anchor, Tabs, Text} from "@mantine/core";
+import {IconReceipt, IconUsers} from "@tabler/icons-react";
+import {useState} from "react";
+import {useNavigate, useParams} from "react-router";
+import {AttendeeTable} from "../AttendeeTable";
+import {OrdersTable} from "../OrdersTable";
+import {Card} from "../Card";
+import {useGetAttendees} from "../../../queries/useGetAttendees.ts";
+import {useGetEventOrders} from "../../../queries/useGetEventOrders.ts";
+import {useGetEvent} from "../../../queries/useGetEvent.ts";
+import {IdParam, QueryFilterOperator, QueryFilters} from "../../../types.ts";
+import classes from './OccurrenceAttendeesAndOrders.module.scss';
+
+interface OccurrenceAttendeesAndOrdersProps {
+ occurrenceId: IdParam;
+ perPage?: number;
+ onNavigateAway?: () => void;
+}
+
+export const OccurrenceAttendeesAndOrders = ({occurrenceId, perPage = 10, onNavigateAway}: OccurrenceAttendeesAndOrdersProps) => {
+ const {eventId} = useParams();
+ const navigate = useNavigate();
+ const {data: event} = useGetEvent(eventId);
+ const [activeTab, setActiveTab] = useState
('attendees');
+
+ const filters: QueryFilters = {
+ pageNumber: 1,
+ perPage,
+ sortBy: 'created_at',
+ sortDirection: 'desc',
+ filterFields: {
+ event_occurrence_id: {operator: QueryFilterOperator.Equals, value: String(occurrenceId)},
+ },
+ };
+
+ const attendeesQuery = useGetAttendees(eventId, filters);
+ const ordersQuery = useGetEventOrders(eventId, filters);
+
+ const attendeeCount = attendeesQuery.data?.meta?.total ?? 0;
+ const orderCount = ordersQuery.data?.meta?.total ?? 0;
+
+ const handleNavigate = (path: string) => {
+ onNavigateAway?.();
+ navigate(path);
+ };
+
+ const viewAllPath = activeTab === 'orders'
+ ? `/manage/event/${eventId}/orders?filterFields[event_occurrence_id][eq]=${occurrenceId}`
+ : `/manage/event/${eventId}/attendees?filterFields[event_occurrence_id][eq]=${occurrenceId}`;
+
+ if (!event) return null;
+
+ return (
+
+
+
+ }>
+ {t`Recent Attendees`}
+ {attendeeCount > 0 && {attendeeCount} }
+
+ }>
+ {t`Recent Orders`}
+ {orderCount > 0 && {orderCount} }
+
+
+
handleNavigate(viewAllPath)}>
+ {t`View All`}
+
+
+
+
+
+ {attendeesQuery.data?.data && attendeeCount > 0 && (
+
+ )}
+ {attendeesQuery.data?.data && attendeeCount === 0 && (
+
+ {t`No attendees yet for this date.`}
+
+ )}
+
+
+
+ {ordersQuery.data?.data && orderCount > 0 && (
+
+ )}
+ {ordersQuery.data?.data && orderCount === 0 && (
+
+ {t`No orders yet for this date.`}
+
+ )}
+
+
+
+ );
+};
diff --git a/frontend/src/components/common/OccurrenceSelect/index.tsx b/frontend/src/components/common/OccurrenceSelect/index.tsx
new file mode 100644
index 0000000000..38311ed4c9
--- /dev/null
+++ b/frontend/src/components/common/OccurrenceSelect/index.tsx
@@ -0,0 +1,172 @@
+import {CSSProperties, useMemo, useState} from "react";
+import {Combobox, InputBase, ScrollArea, Text, useCombobox} from "@mantine/core";
+import {IconCalendar, IconSearch} from "@tabler/icons-react";
+import {t} from "@lingui/macro";
+import {EventOccurrence} from "../../../types.ts";
+import {filterAndGroupOccurrences, formatOccurrenceLabel, isToday} from "./occurrenceSelectUtils.ts";
+
+interface OccurrenceSelectProps {
+ occurrences: EventOccurrence[];
+ timezone: string;
+ value: string | null;
+ onChange: (value: string | null) => void;
+ placeholder?: string;
+ clearable?: boolean;
+ label?: string;
+ description?: string;
+ size?: 'xs' | 'sm' | 'md';
+ allLabel?: string;
+ filterCancelled?: boolean;
+ style?: CSSProperties;
+}
+
+const MAX_VISIBLE = 50;
+
+export const OccurrenceSelect = ({
+ occurrences,
+ timezone: tz,
+ value,
+ onChange,
+ placeholder,
+ clearable = false,
+ label,
+ description,
+ size = 'sm',
+ allLabel,
+ filterCancelled = true,
+ style,
+}: OccurrenceSelectProps) => {
+ const [search, setSearch] = useState('');
+ const combobox = useCombobox({
+ onDropdownClose: () => {
+ setSearch('');
+ combobox.resetSelectedOption();
+ },
+ onDropdownOpen: () => {
+ combobox.focusSearchInput();
+ },
+ });
+
+ const {grouped, totalFiltered, totalAvailable} = useMemo(
+ () => filterAndGroupOccurrences(occurrences, {
+ search,
+ tz,
+ filterCancelled,
+ maxVisible: MAX_VISIBLE,
+ }),
+ [occurrences, tz, search, filterCancelled],
+ );
+
+ const selectedOcc = value
+ ? occurrences.find(o => String(o.id) === value)
+ : null;
+
+ const displayValue = selectedOcc
+ ? formatOccurrenceLabel(selectedOcc, tz)
+ : (value === '' && allLabel) ? allLabel : null;
+
+ return (
+
+ {
+ if (val === '__all__') {
+ onChange('');
+ } else if (val === '__clear__') {
+ onChange(null);
+ } else {
+ onChange(val);
+ }
+ combobox.closeDropdown();
+ }}
+ >
+
+ }
+ rightSection={ }
+ rightSectionPointerEvents="none"
+ onClick={() => combobox.toggleDropdown()}
+ >
+
+ {displayValue || (
+
+ {placeholder || t`Select occurrence`}
+
+ )}
+
+
+
+
+
+ setSearch(event.currentTarget.value)}
+ placeholder={t`Search dates...`}
+ leftSection={}
+ />
+
+
+ {allLabel && (
+
+ {allLabel}
+
+ )}
+
+ {clearable && value && (
+
+ {t`Clear`}
+
+ )}
+
+ {grouped.map(group => (
+
+ {group.items.map(occ => {
+ const isTodayOcc = isToday(occ, tz);
+ return (
+
+
+ {isTodayOcc && `${t`Today`} — `}
+ {formatOccurrenceLabel(occ, tz)}
+
+
+ );
+ })}
+
+ ))}
+
+ {totalFiltered === 0 && (
+ {t`No dates match your search`}
+ )}
+
+ {totalFiltered >= MAX_VISIBLE && totalAvailable > MAX_VISIBLE && (
+
+ {t`Showing ${MAX_VISIBLE} of ${totalAvailable} dates. Type to search.`}
+
+ )}
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/components/common/OccurrenceSelect/occurrenceSelectUtils.ts b/frontend/src/components/common/OccurrenceSelect/occurrenceSelectUtils.ts
new file mode 100644
index 0000000000..8c9941f824
--- /dev/null
+++ b/frontend/src/components/common/OccurrenceSelect/occurrenceSelectUtils.ts
@@ -0,0 +1,96 @@
+import dayjs from "dayjs";
+import utc from "dayjs/plugin/utc";
+import timezone from "dayjs/plugin/timezone";
+import {EventOccurrence} from "../../../types.ts";
+import {formatDateWithLocale} from "../../../utilites/dates.ts";
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+
+/**
+ * Pure presentation + filtering helpers shared by occurrence pickers
+ * (OccurrenceSelect, check-in filter pill). Keeps rendering in the consumer.
+ */
+
+export const formatOccurrenceLabel = (occ: EventOccurrence, tz: string): string => {
+ const date = formatDateWithLocale(occ.start_date, 'shortDate', tz);
+ const time = formatDateWithLocale(occ.start_date, 'timeOnly', tz);
+ return date + ' ' + time + (occ.label ? ` — ${occ.label}` : '');
+};
+
+export const getMonthKey = (occ: EventOccurrence, tz: string): string =>
+ dayjs.utc(occ.start_date).tz(tz).format('YYYY-MM');
+
+export const getMonthLabel = (monthKey: string): string =>
+ dayjs(monthKey + '-01').format('MMMM YYYY');
+
+export const isToday = (occ: EventOccurrence, tz: string): boolean => {
+ const occDate = dayjs.utc(occ.start_date).tz(tz).format('YYYY-MM-DD');
+ const today = dayjs().tz(tz).format('YYYY-MM-DD');
+ return occDate === today;
+};
+
+export interface OccurrenceGroup {
+ key: string;
+ label: string;
+ items: EventOccurrence[];
+}
+
+export interface FilterAndGroupResult {
+ grouped: OccurrenceGroup[];
+ /** Total occurrences after cancellation filter, before search/visibility cap. */
+ totalAvailable: number;
+ /** Matching occurrences after the search filter is applied. */
+ totalFiltered: number;
+ /** Whether the visible list was truncated by maxVisible. */
+ truncated: boolean;
+}
+
+interface FilterOptions {
+ search?: string;
+ tz: string;
+ filterCancelled?: boolean;
+ filterPast?: boolean;
+ maxVisible?: number;
+}
+
+/**
+ * Filter, group by month (in the event's tz), and cap the visible entries.
+ * Consumers use the `truncated` flag to show a "type to search" hint.
+ */
+export const filterAndGroupOccurrences = (
+ occurrences: EventOccurrence[],
+ {search, tz, filterCancelled = true, filterPast = false, maxVisible = 50}: FilterOptions,
+): FilterAndGroupResult => {
+ let items = occurrences;
+ if (filterCancelled) {
+ items = items.filter(o => o.status !== 'CANCELLED');
+ }
+ if (filterPast) {
+ items = items.filter(o => !o.is_past);
+ }
+ const totalAvailable = items.length;
+
+ const q = search?.trim().toLowerCase();
+ if (q) {
+ items = items.filter(occ => formatOccurrenceLabel(occ, tz).toLowerCase().includes(q));
+ }
+ const totalFiltered = items.length;
+
+ const truncated = items.length > maxVisible;
+ const visible = items.slice(0, maxVisible);
+
+ const map = new Map();
+ for (const occ of visible) {
+ const key = getMonthKey(occ, tz);
+ if (!map.has(key)) map.set(key, []);
+ map.get(key)!.push(occ);
+ }
+
+ const grouped: OccurrenceGroup[] = [];
+ for (const [key, items] of map) {
+ grouped.push({key, label: getMonthLabel(key), items});
+ }
+
+ return {grouped, totalAvailable, totalFiltered, truncated};
+};
diff --git a/frontend/src/components/common/OrderDetails/index.tsx b/frontend/src/components/common/OrderDetails/index.tsx
index 70d2b9cc37..732a3347f4 100644
--- a/frontend/src/components/common/OrderDetails/index.tsx
+++ b/frontend/src/components/common/OrderDetails/index.tsx
@@ -1,5 +1,5 @@
import {Anchor, Tooltip} from "@mantine/core";
-import {prettyDate, relativeDate} from "../../../utilites/dates.ts";
+import {formatDateWithLocale, prettyDate, relativeDate} from "../../../utilites/dates.ts";
import {OrderStatusBadge} from "../OrderStatusBadge";
import {Currency} from "../Currency";
import {Card, CardVariant} from "../Card";
@@ -16,6 +16,11 @@ export const OrderDetails = ({order, event, cardVariant = 'lightGray', style = {
cardVariant?: CardVariant,
style?: React.CSSProperties
}) => {
+ const occurrenceItems = order.order_items?.filter(item => item.event_occurrence) ?? [];
+ const uniqueOccurrences = Array.from(
+ new Map(occurrenceItems.map(item => [item.event_occurrence!.id, item.event_occurrence!])).values()
+ );
+
return (
@@ -46,6 +51,23 @@ export const OrderDetails = ({order, event, cardVariant = 'lightGray', style = {
+ {uniqueOccurrences.length > 0 && (
+
+
+ {uniqueOccurrences.length === 1 ? t`Occurrence` : t`Occurrences`}
+
+
+ {uniqueOccurrences.map(occurrence => (
+
+ {formatDateWithLocale(occurrence.start_date, 'shortDate', event.timezone)}
+ {' '}
+ {formatDateWithLocale(occurrence.start_date, 'timeOnly', event.timezone)}
+ {occurrence.label && ` · ${occurrence.label}`}
+
+ ))}
+
+
+ )}
{t`Status`}
diff --git a/frontend/src/components/common/OrderSummary/OrderSummary.module.scss b/frontend/src/components/common/OrderSummary/OrderSummary.module.scss
index e55fae1c15..40dec7fad8 100644
--- a/frontend/src/components/common/OrderSummary/OrderSummary.module.scss
+++ b/frontend/src/components/common/OrderSummary/OrderSummary.module.scss
@@ -20,6 +20,12 @@
.total {
font-size: 1.1em;
}
+
+ .occurrenceMeta {
+ margin-top: 4px;
+ font-size: 12px;
+ color: var(--mantine-color-dimmed);
+ }
}
.itemValue {
diff --git a/frontend/src/components/common/OrderSummary/index.tsx b/frontend/src/components/common/OrderSummary/index.tsx
index 2158757c21..e3ccc2f185 100644
--- a/frontend/src/components/common/OrderSummary/index.tsx
+++ b/frontend/src/components/common/OrderSummary/index.tsx
@@ -2,6 +2,7 @@ import {Event, Order} from "../../../types.ts";
import classes from "./OrderSummary.module.scss";
import {Currency} from "../Currency";
import {t} from "@lingui/macro";
+import {formatDateWithLocale} from "../../../utilites/dates.ts";
interface OrderSummaryProps {
event: Event,
@@ -14,10 +15,21 @@ export const OrderSummary = ({event, order, showFreeWhenZeroTotal = true}: Order
{order?.order_items?.map(item => {
+ const occurrence = item.event_occurrence;
return (
- {/* eslint-disable-next-line lingui/no-unlocalized-strings */}
-
{item.quantity} x {item.item_name}
+
+ {/* eslint-disable-next-line lingui/no-unlocalized-strings */}
+
{item.quantity} x {item.item_name}
+ {occurrence && (
+
+ {formatDateWithLocale(occurrence.start_date, 'shortDate', event.timezone)}
+ {' '}
+ {formatDateWithLocale(occurrence.start_date, 'timeOnly', event.timezone)}
+ {occurrence.label && ` · ${occurrence.label}`}
+
+ )}
+
{!!item.price_before_discount && (
diff --git a/frontend/src/components/common/OrdersTable/OrdersTable.module.scss b/frontend/src/components/common/OrdersTable/OrdersTable.module.scss
index 4cbb258c99..fb84eaa894 100644
--- a/frontend/src/components/common/OrdersTable/OrdersTable.module.scss
+++ b/frontend/src/components/common/OrdersTable/OrdersTable.module.scss
@@ -110,6 +110,17 @@
line-height: 1.3;
}
+// Occurrence Chip
+.occurrenceChip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 12px;
+ line-height: 1.3;
+ color: var(--mantine-primary-color-filled);
+ white-space: nowrap;
+}
+
// Items Section
.itemsBadge {
display: inline-flex;
diff --git a/frontend/src/components/common/OrdersTable/index.tsx b/frontend/src/components/common/OrdersTable/index.tsx
index 78da39a7a1..554e922810 100644
--- a/frontend/src/components/common/OrdersTable/index.tsx
+++ b/frontend/src/components/common/OrdersTable/index.tsx
@@ -4,6 +4,7 @@ import {Event, IdParam, Invoice, MessageType, Order} from "../../../types.ts";
import {
IconAlertCircle,
IconBasketCog,
+ IconCalendarEvent,
IconCash,
IconCheck,
IconClock,
@@ -23,7 +24,7 @@ import {
IconTrash,
IconX
} from "@tabler/icons-react";
-import {relativeDate} from "../../../utilites/dates.ts";
+import {formatDateWithLocale, relativeDate} from "../../../utilites/dates.ts";
import {ManageOrderModal} from "../../modals/ManageOrderModal";
import {useClipboard, useDisclosure} from "@mantine/hooks";
import {useMemo, useState} from "react";
@@ -49,9 +50,10 @@ import {eventCheckoutUrl} from "../../../utilites/urlHelper.ts";
interface OrdersTableProps {
event: Event,
orders: Order[];
+ compact?: boolean;
}
-export const OrdersTable = ({orders, event}: OrdersTableProps) => {
+export const OrdersTable = ({orders, event, compact}: OrdersTableProps) => {
const [isViewModalOpen, viewModal] = useDisclosure(false);
const [isCancelModalOpen, cancelModal] = useDisclosure(false);
const [isMessageModalOpen, messageModal] = useDisclosure(false);
@@ -278,6 +280,7 @@ export const OrdersTable = ({orders, event}: OrdersTableProps) => {
enableHiding: true,
cell: (info: CellContext
) => {
const order = info.row.original;
+ const occurrence = order.order_items?.[0]?.event_occurrence;
return (
{
>
{order.public_id}
+ {occurrence && event?.timezone && (
+
+
+ {formatDateWithLocale(occurrence.start_date, 'shortDate', event.timezone)}
+ {' '}
+ {formatDateWithLocale(occurrence.start_date, 'timeOnly', event.timezone)}
+ {occurrence.label && ` · ${occurrence.label}`}
+
+ )}
{relativeDate(order.created_at)}
@@ -478,8 +490,10 @@ export const OrdersTable = ({orders, event}: OrdersTableProps) => {
data={orders}
columns={columns}
storageKey="orders-table"
- enableColumnVisibility={true}
- renderColumnVisibilityToggle={(table) => }
+ enableColumnVisibility={!compact}
+ renderColumnVisibilityToggle={!compact ? (table) => : undefined}
+ hideHeader={compact}
+ noCard={compact}
/>
{orderId && (
<>
diff --git a/frontend/src/components/common/ProductPriceAvailability/index.tsx b/frontend/src/components/common/ProductPriceAvailability/index.tsx
index 552698cfb2..ca31b49950 100644
--- a/frontend/src/components/common/ProductPriceAvailability/index.tsx
+++ b/frontend/src/components/common/ProductPriceAvailability/index.tsx
@@ -1,4 +1,4 @@
-import {Event, Product, ProductPrice} from "../../../types.ts";
+import {Event, EventType, IdParam, Product, ProductPrice} from "../../../types.ts";
import {t} from "@lingui/macro";
import {Tooltip} from "@mantine/core";
import {prettyDate, relativeDate} from "../../../utilites/dates.ts";
@@ -9,12 +9,13 @@ interface ProductPriceSaleDateMessageProps {
price: ProductPrice;
event: Event;
product: Product;
+ eventOccurrenceId?: IdParam;
}
-const ProductPriceSaleDateMessage = ({price, event, product}: ProductPriceSaleDateMessageProps) => {
+const ProductPriceSaleDateMessage = ({price, event, product, eventOccurrenceId}: ProductPriceSaleDateMessageProps) => {
if (price.is_sold_out) {
- if (product.waitlist_enabled) {
- return ;
+ if (product.waitlist_enabled && (event.type !== EventType.RECURRING || eventOccurrenceId)) {
+ return ;
}
return t`Sold out`;
}
@@ -40,12 +41,13 @@ const ProductPriceSaleDateMessage = ({price, event, product}: ProductPriceSaleDa
interface ProductAvailabilityMessageProps {
product: Product;
event: Event;
+ eventOccurrenceId?: IdParam;
}
-export const ProductAvailabilityMessage = ({product, event}: ProductAvailabilityMessageProps) => {
+export const ProductAvailabilityMessage = ({product, event, eventOccurrenceId}: ProductAvailabilityMessageProps) => {
if (product.is_sold_out) {
- if (product.waitlist_enabled && product.type !== 'TIERED') {
- return ;
+ if (product.waitlist_enabled && (event.type !== EventType.RECURRING || eventOccurrenceId) && product.type !== 'TIERED') {
+ return ;
}
return t`Sold out`;
}
@@ -70,13 +72,14 @@ interface ProductAndPriceAvailabilityProps {
product: Product;
price: ProductPrice;
event: Event;
+ eventOccurrenceId?: IdParam;
}
-export const ProductPriceAvailability = ({product, price, event}: ProductAndPriceAvailabilityProps) => {
+export const ProductPriceAvailability = ({product, price, event, eventOccurrenceId}: ProductAndPriceAvailabilityProps) => {
if (product.type === 'TIERED') {
- return
+ return
}
- return
+ return
}
diff --git a/frontend/src/components/common/ReportTable/index.tsx b/frontend/src/components/common/ReportTable/index.tsx
index 69895ac79e..a6edd57317 100644
--- a/frontend/src/components/common/ReportTable/index.tsx
+++ b/frontend/src/components/common/ReportTable/index.tsx
@@ -9,11 +9,13 @@ import {Table, TableHead} from "../Table";
import '@mantine/dates/styles.css';
import {useGetEventReport} from "../../../queries/useGetEventReport.ts";
import {useParams} from "react-router";
-import {Event} from "../../../types.ts";
+import {Event, EventType, IdParam} from "../../../types.ts";
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import {NoResultsSplash} from "../NoResultsSplash";
+import {OccurrenceSelect} from "../OccurrenceSelect";
+import {useGetEventOccurrences} from "../../../queries/useGetEventOccurrences.ts";
import classes from './ReportTable.module.scss';
dayjs.extend(utc);
@@ -32,6 +34,7 @@ interface ReportProps {
event: Event
isLoading?: boolean;
showDateFilter?: boolean;
+ showOccurrenceFilter?: boolean;
defaultStartDate?: Date;
defaultEndDate?: Date;
onDateRangeChange?: (range: [Date | null, Date | null]) => void;
@@ -57,6 +60,7 @@ const ReportTable = >({
title,
columns,
showDateFilter = true,
+ showOccurrenceFilter = true,
defaultStartDate = new Date(new Date().setMonth(new Date().getMonth() - 3)),
defaultEndDate = new Date(),
onDateRangeChange,
@@ -73,8 +77,19 @@ const ReportTable = >({
const [showDatePickerInput, setShowDatePickerInput] = useState(showCustomDatePicker);
const [sortField, setSortField] = useState(null);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>(null);
+ const [selectedOccurrenceId, setSelectedOccurrenceId] = useState(undefined);
const {reportType, eventId} = useParams();
- const reportQuery = useGetEventReport(eventId, reportType, dateRange[0], dateRange[1]);
+
+ const isRecurring = event?.type === EventType.RECURRING;
+ const occurrencesQuery = useGetEventOccurrences(
+ eventId,
+ {pageNumber: 1, perPage: 500},
+ true,
+ {includeStats: false},
+ );
+ const occurrences = occurrencesQuery?.data?.data || [];
+
+ const reportQuery = useGetEventReport(eventId, reportType, dateRange[0], dateRange[1], selectedOccurrenceId);
const data = (reportQuery.data || []) as T[];
const calculateDateRange = (period: string): [Date | null, Date | null] => {
@@ -255,6 +270,16 @@ const ReportTable = >({
{title}
+ {isRecurring && showOccurrenceFilter && occurrences.length > 0 && event?.timezone && (
+ setSelectedOccurrenceId(value ? Number(value) : undefined)}
+ placeholder={t`All Occurrences`}
+ clearable
+ />
+ )}
{showDateFilter && (
void;
@@ -16,10 +16,9 @@ interface SearchBarWrapperProps {
placeholder?: string,
setSearchParams: (updates: Partial) => void,
searchParams: Partial,
- pagination?: PaginationData,
}
-export const SearchBarWrapper = ({setSearchParams, searchParams, pagination, placeholder}: SearchBarWrapperProps) => {
+export const SearchBarWrapper = ({setSearchParams, searchParams, placeholder}: SearchBarWrapperProps) => {
return (
{
- setSearchParams({
- sortBy: key,
- sortDirection: sortDirection,
- })
- },
- } : undefined}
/>
);
}
@@ -63,7 +50,7 @@ export const SearchBar = ({sortProps, onClear, value, onChange, ...props}: Searc
className={classes.searchBar}
leftSection={}
radius="sm"
- size="md"
+ size="sm"
value={searchValue}
{...props}
onChange={(event) => {
@@ -89,5 +76,3 @@ export const SearchBar = ({sortProps, onClear, value, onChange, ...props}: Searc
);
};
-
-
diff --git a/frontend/src/components/common/SortSelector/SortSelector.module.scss b/frontend/src/components/common/SortSelector/SortSelector.module.scss
index 5e7f932a2c..b10b581a2d 100644
--- a/frontend/src/components/common/SortSelector/SortSelector.module.scss
+++ b/frontend/src/components/common/SortSelector/SortSelector.module.scss
@@ -1,14 +1,9 @@
@use "../../../styles/mixins";
.selectWrapper {
- width: 100%;
- container-type: inline-size;
-
.select {
margin-bottom: 0 !important;
- @include mixins.respond-above(lg) {
- max-width: 180px;
- }
+ min-width: 160px;
input {
font-size: 0.9em;
diff --git a/frontend/src/components/common/SortSelector/index.tsx b/frontend/src/components/common/SortSelector/index.tsx
index 9628cbc705..18048e0267 100644
--- a/frontend/src/components/common/SortSelector/index.tsx
+++ b/frontend/src/components/common/SortSelector/index.tsx
@@ -23,7 +23,7 @@ export const SortSelector = ({options, onSortSelect, selected}: SortSelectorProp
return (
{
+interface StatBoxesProps {
+ occurrenceId?: IdParam;
+}
+
+export const StatBoxes = ({occurrenceId}: StatBoxesProps = {}) => {
const {eventId} = useParams();
- const eventStatsQuery = useGetEventStats(eventId);
+ const eventStatsQuery = useGetEventStats(eventId, {occurrenceId});
const eventQuery = useGetEvent(eventId);
const event = eventQuery?.data;
const {data: eventStats} = eventStatsQuery;
@@ -78,9 +83,13 @@ export const StatBoxes = () => {
}
];
+ const filteredData = occurrenceId
+ ? data.filter((stat) => stat.description !== t`Page views`)
+ : data;
+
return (
- {data.map((stat) => (
+ {filteredData.map((stat) => (
= ({
- buttonText,
- buttonIcon = ,
- variant = 'light',
- size = 'sm',
- fullWidth = false,
- className,
- platform
-}) => {
- const [fetchStripeDetails, setFetchStripeDetails] = useState(false);
- const [isReturningFromStripe, setIsReturningFromStripe] = useState(false);
- const accountQuery = useGetAccount();
- const account = accountQuery.data;
-
- // For Hi.Events, use the new platform parameter for Ireland migration
- // For open-source, use existing logic (no platform parameter)
- const platformToUse = isHiEvents() ? platform || 'ie' : undefined;
-
- const stripeDetailsQuery = useCreateOrGetStripeConnectDetails(
- account?.id || '',
- (!!account?.stripe_account_id || fetchStripeDetails) && !!account?.id,
- platformToUse
- );
-
- const stripeDetails = stripeDetailsQuery.data;
-
- useEffect(() => {
- if (typeof window === 'undefined') {
- return;
- }
- setIsReturningFromStripe(
- window.location.search.includes('is_return') ||
- window.location.search.includes('is_refresh')
- );
- }, []);
-
- useEffect(() => {
- if (fetchStripeDetails && !stripeDetailsQuery.isLoading && stripeDetails) {
- setFetchStripeDetails(false);
- showSuccess(t`Redirecting to Stripe...`);
- window.location.href = String(stripeDetails.connect_url);
- }
- }, [fetchStripeDetails, stripeDetailsQuery.isLoading, stripeDetails]);
-
- const handleClick = () => {
- if (!stripeDetails) {
- setFetchStripeDetails(true);
- } else {
- if (stripeDetails.is_connect_setup_complete) {
- showSuccess(t`Stripe setup is already complete.`);
- return;
- }
-
- if (typeof window !== 'undefined') {
- showSuccess(t`Redirecting to Stripe...`);
- window.location.href = String(stripeDetails.connect_url);
- }
- }
- };
-
- // Determine button text
- const getButtonText = () => {
- if (buttonText) return buttonText;
-
- if (!isReturningFromStripe && !account?.stripe_account_id) {
- return t`Connect with Stripe`;
- }
- return t`Complete Stripe Setup`;
- };
-
- return (
-
- {getButtonText()}
-
- );
-};
diff --git a/frontend/src/components/common/TanStackTable/index.tsx b/frontend/src/components/common/TanStackTable/index.tsx
index 74596d9cc7..9e272ac1b0 100644
--- a/frontend/src/components/common/TanStackTable/index.tsx
+++ b/frontend/src/components/common/TanStackTable/index.tsx
@@ -20,6 +20,9 @@ interface TanStackTableProps {
storageKey?: string;
enableColumnVisibility?: boolean;
renderColumnVisibilityToggle?: (table: ReturnType>) => React.ReactNode;
+ hideHeader?: boolean;
+ noCard?: boolean;
+ rowStyle?: (row: TData) => React.CSSProperties | undefined;
}
export function TanStackTable({
@@ -28,6 +31,9 @@ export function TanStackTable({
storageKey,
enableColumnVisibility = false,
renderColumnVisibilityToggle,
+ hideHeader = false,
+ noCard = false,
+ rowStyle,
}: TanStackTableProps) {
const [columnVisibility, setColumnVisibility] = useState(() => {
if (storageKey && enableColumnVisibility) {
@@ -60,6 +66,74 @@ export function TanStackTable({
}
}, [columnVisibility, storageKey, enableColumnVisibility]);
+ const tableContent = (
+
+
+ {!hideHeader && (
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ const columnMeta = header.column.columnDef.meta as TanStackTableColumnMeta | undefined;
+ const stickyClass = columnMeta?.sticky === 'left'
+ ? classes.stickyLeft
+ : columnMeta?.sticky === 'right'
+ ? classes.stickyRight
+ : '';
+
+ return (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+
+ );
+ })}
+
+ ))}
+
+ )}
+
+ {table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => {
+ const columnMeta = cell.column.columnDef.meta as TanStackTableColumnMeta | undefined;
+ const stickyClass = columnMeta?.sticky === 'left'
+ ? classes.stickyLeft
+ : columnMeta?.sticky === 'right'
+ ? classes.stickyRight
+ : '';
+
+ return (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ );
+ })}
+
+ ))}
+
+
+
+ );
+
return (
{enableColumnVisibility && renderColumnVisibilityToggle && (
@@ -67,71 +141,7 @@ export function TanStackTable({
{renderColumnVisibilityToggle(table)}
)}
-
-
-
-
- {table.getHeaderGroups().map((headerGroup) => (
-
- {headerGroup.headers.map((header) => {
- const columnMeta = header.column.columnDef.meta as TanStackTableColumnMeta | undefined;
- const stickyClass = columnMeta?.sticky === 'left'
- ? classes.stickyLeft
- : columnMeta?.sticky === 'right'
- ? classes.stickyRight
- : '';
-
- return (
-
- {header.isPlaceholder
- ? null
- : flexRender(
- header.column.columnDef.header,
- header.getContext()
- )}
-
- );
- })}
-
- ))}
-
-
- {table.getRowModel().rows.map((row) => (
-
- {row.getVisibleCells().map((cell) => {
- const columnMeta = cell.column.columnDef.meta as TanStackTableColumnMeta | undefined;
- const stickyClass = columnMeta?.sticky === 'left'
- ? classes.stickyLeft
- : columnMeta?.sticky === 'right'
- ? classes.stickyRight
- : '';
-
- return (
-
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
-
- );
- })}
-
- ))}
-
-
-
-
+ {noCard ? tableContent : {tableContent} }
);
}
diff --git a/frontend/src/components/common/ToolBar/ToolBar.module.scss b/frontend/src/components/common/ToolBar/ToolBar.module.scss
index a481b44c18..90c16104f5 100644
--- a/frontend/src/components/common/ToolBar/ToolBar.module.scss
+++ b/frontend/src/components/common/ToolBar/ToolBar.module.scss
@@ -1,55 +1,60 @@
@use "../../../styles/mixins";
-.card {
+.toolbar {
container-type: inline-size;
margin-bottom: 1rem;
+}
+
+// Row 1: search + action buttons
+.rowPrimary {
+ display: flex;
+ align-items: center;
+ gap: 8px;
- .wrapper {
- display: flex;
- gap: 10px;
- align-items: center;
- place-content: space-between;
-
- @include mixins.respond-below(sm, true) {
- flex-direction: column;
- align-items: flex-start;
- width: 100%;
- }
-
- .searchBar {
- margin-bottom: 0 !important;
- width: 100%;
- flex: 1;
- min-width: 0; // Prevents flex item from overflowing
- }
-
- .filterAndActions {
- display: flex;
- gap: 10px;
- align-items: center;
- place-self: flex-end;
-
- @include mixins.respond-below(sm, true) {
- width: 100%;
- justify-content: flex-end;
- }
- }
-
- .filter {
- display: flex;
- align-items: center;
- }
-
- .actions {
- display: flex;
- gap: 10px;
- align-items: center;
- place-self: flex-end;
- flex-wrap: wrap;
- }
+ @include mixins.respond-below(sm, true) {
+ flex-wrap: wrap;
}
+}
+
+.searchSlot {
+ flex: 1;
+ min-width: 0;
+ margin-bottom: 0 !important;
+}
+
+.actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+ margin-left: auto;
+}
+
+// Row 2: sort + filters + result count
+.rowFilters {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding-top: 10px;
+ flex-wrap: wrap;
+}
+
+.filterSlot {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.resultCount {
+ font-size: 12px;
+ color: var(--mantine-color-dimmed);
+ margin-left: auto;
+ white-space: nowrap;
+ flex-shrink: 0;
+ align-self: center;
- button {
- height: 42px !important;
+ @include mixins.respond-below(md) {
+ display: none;
}
}
diff --git a/frontend/src/components/common/ToolBar/index.tsx b/frontend/src/components/common/ToolBar/index.tsx
index 5f8f7dd3e1..f28a34b9ce 100644
--- a/frontend/src/components/common/ToolBar/index.tsx
+++ b/frontend/src/components/common/ToolBar/index.tsx
@@ -1,43 +1,54 @@
import React from "react";
import {Card} from "../Card";
import classes from './ToolBar.module.scss';
-import {Group} from '@mantine/core';
+import {t} from "@lingui/macro";
interface ToolBarProps {
children?: React.ReactNode[] | React.ReactNode;
searchComponent?: () => React.ReactNode;
filterComponent?: React.ReactNode;
+ resultCount?: number;
+ resultLabel?: string;
className?: string;
}
export const ToolBar: React.FC = ({
- searchComponent,
- filterComponent,
- children,
- className,
- }) => {
+ searchComponent,
+ filterComponent,
+ children,
+ resultCount,
+ resultLabel,
+ className,
+}) => {
return (
-
-
+
+
{searchComponent && (
-
+
{searchComponent()}
)}
+ {children && (
+
+ {children}
+
+ )}
+
-
+ {(filterComponent || resultCount !== undefined) && (
+
{filterComponent && (
-
+
{filterComponent}
)}
- {children && (
-
- {children}
-
+ {resultCount !== undefined && (
+
+ {resultCount.toLocaleString()} {resultLabel || t`results`}
+
)}
-
-
+
+ )}
);
};
diff --git a/frontend/src/components/common/WaitlistTable/index.tsx b/frontend/src/components/common/WaitlistTable/index.tsx
index 4796a2e3f4..b98a2093dd 100644
--- a/frontend/src/components/common/WaitlistTable/index.tsx
+++ b/frontend/src/components/common/WaitlistTable/index.tsx
@@ -4,8 +4,8 @@ import {IconDotsVertical, IconEye, IconSend, IconTrash} from "@tabler/icons-reac
import {useMemo, useState} from "react";
import {CellContext} from "@tanstack/react-table";
import {useDisclosure} from "@mantine/hooks";
-import {IdParam, WaitlistEntry, WaitlistEntryStatus} from "../../../types.ts";
-import {relativeDate} from "../../../utilites/dates.ts";
+import {Event, EventType, IdParam, WaitlistEntry, WaitlistEntryStatus} from "../../../types.ts";
+import {prettyDate, relativeDate} from "../../../utilites/dates.ts";
import {NoResultsSplash} from "../NoResultsSplash";
import {confirmationDialog} from "../../../utilites/confirmationDialog.tsx";
import {useRemoveWaitlistEntry} from "../../../mutations/useRemoveWaitlistEntry.ts";
@@ -18,6 +18,7 @@ import classes from './WaitlistTable.module.scss';
interface WaitlistTableProps {
eventId: IdParam;
entries: WaitlistEntry[];
+ event?: Event;
}
const statusLabelMap: Record string> = {
@@ -84,7 +85,9 @@ const ActionMenu = ({entry, onOffer, onRemove, onViewOrder}: {
);
};
-export const WaitlistTable = ({eventId, entries}: WaitlistTableProps) => {
+export const WaitlistTable = ({eventId, entries, event}: WaitlistTableProps) => {
+ const isRecurring = event?.type === EventType.RECURRING;
+ const timezone = event?.timezone || 'UTC';
const removeMutation = useRemoveWaitlistEntry();
const offerMutation = useOfferSpecificWaitlistEntry();
const [isOrderModalOpen, orderModal] = useDisclosure(false);
@@ -176,6 +179,27 @@ export const WaitlistTable = ({eventId, entries}: WaitlistTableProps) => {
return label ? `${title} - ${label}` : title;
}
},
+ ...(isRecurring ? [{
+ id: 'occurrence',
+ header: t`Date`,
+ enableHiding: true,
+ cell: (info: CellContext) => {
+ const entry = info.row.original;
+ const occurrence = entry.event_occurrence;
+ if (!occurrence) {
+ return — ;
+ }
+ const dateText = prettyDate(occurrence.start_date, timezone);
+ return (
+
+ {occurrence.label ? `${dateText} (${occurrence.label})` : dateText}
+
+ );
+ },
+ meta: {
+ headerStyle: {minWidth: 180},
+ },
+ } as TanStackTableColumn] : []),
{
id: 'status',
header: t`Status`,
@@ -227,7 +251,7 @@ export const WaitlistTable = ({eventId, entries}: WaitlistTableProps) => {
},
},
],
- [eventId]
+ [eventId, isRecurring, timezone]
);
if (entries.length === 0) {
diff --git a/frontend/src/components/forms/CheckInListForm/CheckInListForm.module.scss b/frontend/src/components/forms/CheckInListForm/CheckInListForm.module.scss
new file mode 100644
index 0000000000..d0a974433d
--- /dev/null
+++ b/frontend/src/components/forms/CheckInListForm/CheckInListForm.module.scss
@@ -0,0 +1,121 @@
+.advancedToggle {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ background: transparent;
+ border: none;
+ color: var(--hi-primary);
+ font-family: inherit;
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ padding: 6px 8px;
+ margin: 4px 0 12px -8px;
+ border-radius: 6px;
+ transition: background 140ms ease;
+
+ &:hover {
+ background: color-mix(in srgb, var(--hi-primary) 8%, transparent);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--hi-primary);
+ outline-offset: 1px;
+ }
+
+ .chevron {
+ transition: transform 180ms ease;
+
+ &.chevronOpen {
+ transform: rotate(90deg);
+ }
+ }
+}
+
+.visibilitySection {
+ margin-top: 20px;
+ padding: 16px;
+ background: var(--hi-color-gray);
+ border-radius: 12px;
+ border: 1px solid var(--hi-color-gray-2);
+}
+
+.visibilityHeader {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ margin-bottom: 14px;
+}
+
+.visibilityIcon {
+ width: 32px;
+ height: 32px;
+ border-radius: 8px;
+ background: #fff;
+ border: 1px solid var(--hi-color-gray-2);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--hi-color-gray-dark);
+ flex-shrink: 0;
+}
+
+.visibilityTitle {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--hi-text);
+ line-height: 1.3;
+ letter-spacing: -0.01em;
+}
+
+.visibilityHint {
+ font-size: 12px;
+ color: var(--hi-color-gray-dark);
+ line-height: 1.4;
+ margin-top: 2px;
+}
+
+.visibilityRows {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.visibilityRow {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 12px;
+ background: #fff;
+ border: 1px solid var(--hi-color-gray-2);
+ border-radius: 10px;
+}
+
+.visibilityRowIcon {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: var(--hi-color-gray);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--hi-color-gray-dark);
+ flex-shrink: 0;
+}
+
+.visibilityRowMain {
+ flex: 1;
+ min-width: 0;
+}
+
+.visibilityRowLabel {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--hi-text);
+}
+
+.visibilityRowDesc {
+ font-size: 12px;
+ color: var(--hi-color-gray-dark);
+ margin-top: 1px;
+}
diff --git a/frontend/src/components/forms/CheckInListForm/index.tsx b/frontend/src/components/forms/CheckInListForm/index.tsx
index 7b57c56222..e90058ee0b 100644
--- a/frontend/src/components/forms/CheckInListForm/index.tsx
+++ b/frontend/src/components/forms/CheckInListForm/index.tsx
@@ -1,35 +1,100 @@
-import {Alert, Textarea, TextInput} from "@mantine/core";
-import {t} from "@lingui/macro";
+import {Collapse, Select, Switch, Textarea, TextInput} from "@mantine/core";
+import {t, Trans} from "@lingui/macro";
import {UseFormReturnType} from "@mantine/form";
-import {CheckInListRequest, ProductCategory, ProductType} from "../../../types.ts";
+import {
+ CheckInListRequest,
+ EventOccurrence,
+ EventType,
+ ProductCategory,
+ ProductType
+} from "../../../types.ts";
import {InputGroup} from "../../common/InputGroup";
import {ProductSelector} from "../../common/ProductSelector";
-import {useEffect, useMemo} from "react";
-import {IconInfoCircle} from "@tabler/icons-react";
+import {Callout} from "../../common/Callout";
+import {useEffect, useMemo, useState} from "react";
+import {
+ IconChevronRight,
+ IconClipboardText,
+ IconEye,
+ IconMessageCircleQuestion,
+ IconReceipt2,
+} from "@tabler/icons-react";
+import {formatDateWithLocale} from "../../../utilites/dates.ts";
+import classes from "./CheckInListForm.module.scss";
interface CheckInListFormProps {
form: UseFormReturnType;
productCategories: ProductCategory[];
+ eventType?: EventType;
+ occurrences?: EventOccurrence[];
+ timezone?: string;
+ isNewForOccurrence?: boolean;
+ hideIntro?: boolean;
}
-export const CheckInListForm = ({form, productCategories}: CheckInListFormProps) => {
- const tickets = useMemo(() => {
- return productCategories
- .flatMap(category => category.products || [])
- .filter(product => product.product_type === ProductType.Ticket);
- }, [productCategories]);
+const hasAdvancedValuesSet = (form: UseFormReturnType): boolean => {
+ return !!(
+ form.values.description
+ || form.values.activates_at
+ || form.values.expires_at
+ || form.values.public_show_attendee_notes === false
+ || form.values.public_show_question_answers === false
+ || form.values.public_show_order_details === false
+ );
+};
+
+export const CheckInListForm = ({
+ form,
+ productCategories,
+ eventType,
+ occurrences,
+ timezone,
+ isNewForOccurrence,
+ hideIntro,
+ }: CheckInListFormProps) => {
+ const isRecurring = eventType === EventType.RECURRING;
+ const activeOccurrences = useMemo(() => {
+ if (!isRecurring || !occurrences || !timezone) return [];
+ return occurrences.filter(o => o.status !== 'CANCELLED');
+ }, [isRecurring, occurrences, timezone]);
+
+ const occurrenceOptions = useMemo(() => {
+ if (!activeOccurrences.length || !timezone) return [];
+ return activeOccurrences.map(o => ({
+ value: String(o.id),
+ label: formatDateWithLocale(o.start_date, 'shortDate', timezone)
+ + ' ' + formatDateWithLocale(o.start_date, 'timeOnly', timezone)
+ + (o.label ? ` — ${o.label}` : ''),
+ }));
+ }, [activeOccurrences, timezone]);
+ // Open advanced panel automatically if editing a list that already uses any of those options.
+ const [showAdvanced, setShowAdvanced] = useState(() => hasAdvancedValuesSet(form));
+
+ // UI mirror of "product_ids is empty" — default on for new lists.
+ const [scopeToAll, setScopeToAll] = useState(
+ () => !form.values.product_ids || form.values.product_ids.length === 0,
+ );
+
+ // Reflect late-hydrated values (edit modal sets product_ids in an effect).
useEffect(() => {
- if (tickets.length === 1 && (!form.values.product_ids || form.values.product_ids.length === 0)) {
- form.setFieldValue('product_ids', [String(tickets[0].id)]);
- }
- }, [tickets]);
+ const hasProducts = (form.values.product_ids?.length ?? 0) > 0;
+ if (hasProducts && scopeToAll) setScopeToAll(false);
+ }, [form.values.product_ids]);
+
+ const introTitle = isNewForOccurrence
+ ? t`Control who gets in for this date`
+ : t`Control who gets in, and when`;
return (
<>
- } color="blue" variant="light">
- {t`Check-in lists let you control entry across days, areas, or ticket types. You can share a secure check-in link with staff — no account required.`}
-
+ {!hideIntro && (
+
+
+ Split check-in across days, areas, or ticket types. Share the link with staff — no account needed on their end.
+
+
+ )}
- {
+ const checked = e.currentTarget.checked;
+ setScopeToAll(checked);
+ if (checked) {
+ form.setFieldValue('product_ids', []);
+ }
+ }}
/>
-
+ {!scopeToAll && (
+
+ )}
-
- 0 && (
+ form.setFieldValue('event_occurrence_id', val ? Number(val) : null)}
+ clearable
/>
- setShowAdvanced(v => !v)}
+ aria-expanded={showAdvanced}
+ >
+
+ {showAdvanced ? t`Hide advanced options` : t`Show advanced options`}
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t`What unauthenticated staff can see`}
+
+
+
+ Applies to anyone opening the shared check-in link without being signed in. Logged-in team members always see everything.
+
+
+
+
+
+
+
+
+
+
+
+
{t`Attendee notes`}
+
+ {t`Internal notes on the attendee's ticket`}
+
+
+
+
+
+
+
+
+
+
+
{t`Question answers`}
+
+ {t`Answers provided at checkout (e.g. meal choice)`}
+
+
+
+
+
+
+
+
+
+
+
{t`Order details`}
+
+ {t`Order number, purchase date, purchaser email`}
+
+
+
+
+
+
+
>
);
}
diff --git a/frontend/src/components/forms/ProductForm/index.tsx b/frontend/src/components/forms/ProductForm/index.tsx
index 76063f94fc..2eaf05c7d4 100644
--- a/frontend/src/components/forms/ProductForm/index.tsx
+++ b/frontend/src/components/forms/ProductForm/index.tsx
@@ -1,6 +1,14 @@
import {t, Trans} from "@lingui/macro";
import {UseFormReturnType} from "@mantine/form";
-import {Event, Product, ProductPriceType, TaxAndFee, TaxAndFeeCalculationType, TaxAndFeeType} from "../../../types.ts";
+import {
+ Event,
+ EventType,
+ Product,
+ ProductPriceType,
+ TaxAndFee,
+ TaxAndFeeCalculationType,
+ TaxAndFeeType
+} from "../../../types.ts";
import {
ActionIcon,
Alert,
@@ -182,6 +190,7 @@ export const ProductForm = ({form, product}: ProductFormProps) => {
const isDonationProduct = form.values.type === 'DONATION';
const {data: event} = useGetEvent(eventId);
const {data: taxesAndFees} = useGetTaxesAndFees();
+ const isRecurring = event?.type === EventType.RECURRING;
const handleTaxOrFeeCreated = (taxOrFee: TaxAndFee) => {
const currentIds = form.values.tax_and_fee_ids || [];
@@ -296,51 +305,70 @@ export const ProductForm = ({form, product}: ProductFormProps) => {
/>
{form.values.type !== ProductPriceType.Tiered && (
-
-
-
- Please enter the price excluding taxes and fees.
-
-
- Taxes and fees can be added below.
-
-
- )}
- />}
- placeholder="19.99"/>
-
-
- The number of products available for this product
-
-
- This value can be overridden if there are Capacity
- Limits associated with this product.
-
-
- )}
- />}
- />
-
+ <>
+
+
+
+ Please enter the price excluding taxes and fees.
+
+
+ Taxes and fees can be added below.
+
+
+ )}
+ />}
+ placeholder="19.99"/>
+
+
+ This is the default quantity across all dates. Each date's capacity
+ can further limit availability on the Occurrence Schedule
+ page .
+
+
+ ) : (
+
+
+ The number of products available for this product
+
+
+ This value can be overridden if there are Capacity
+ Limits associated with this product.
+
+
+ )}
+ />}
+ />
+
+ >
)}
{form.values.type === ProductPriceType.Tiered && (
+ {isRecurring && (
+ } mb={10}>
+ These are the default prices and quantities across all dates. Sale dates on tiers
+ apply globally. You can override prices and quantities for individual dates on
+ the Occurrence Schedule
+ page .
+
+ )}
{
{t`Sale Period`}
}>
+ {isRecurring && (
+ } mb={10}>
+ Sale period dates apply across all dates in your schedule. To control pricing and
+ availability for individual dates, use the overrides on the Occurrence Schedule page .
+
+ )}
@@ -466,9 +501,11 @@ export const ProductForm = ({form, product}: ProductFormProps) => {
}>
+ label={t`Hide product before sale start date`}
+ description={isRecurring ? t`Based on the global sale period above, not per date` : undefined}/>
+ label={t`Hide product after sale end date`}
+ description={isRecurring ? t`Based on the global sale period above, not per date` : undefined}/>
{
{t`You can create a promo code which targets this product on the`} {t`Promo Code page`} >}
+ description={<>{t`You can create a promo code which targets this product on the`}
+ {t`Promo Code page`} >}
{...form.getInputProps('is_hidden_without_promo_code', {type: 'checkbox'})}
label={t`Hide product unless user has applicable promo code`}
/>
@@ -487,6 +525,7 @@ export const ProductForm = ({form, product}: ProductFormProps) => {
{...form.getInputProps(`is_hidden`, {type: 'checkbox'})}
label={t`Hide this product from customers`}
/>
+
,
@@ -63,9 +64,13 @@ export const PromoCodeForm = ({form}: PromoCodeFormProps) => {
rightSectionWidth={'auto'}
/>
- } title={t`TIP`}>
+ }
+ variant="info"
+ title={t`Quick Tip`}
+ >
{t`A promo code with no discount can be used to reveal hidden products.`}
-
+
,
+ label: t`Occurrence Cancelled`,
+ value: 'occurrence.cancelled',
+ description: t`When a date is cancelled on a recurring event`,
+ },
];
return (
diff --git a/frontend/src/components/layouts/AppLayout/Sidebar/Sidebar.module.scss b/frontend/src/components/layouts/AppLayout/Sidebar/Sidebar.module.scss
index 62c1cdbad2..4d947a208c 100644
--- a/frontend/src/components/layouts/AppLayout/Sidebar/Sidebar.module.scss
+++ b/frontend/src/components/layouts/AppLayout/Sidebar/Sidebar.module.scss
@@ -104,6 +104,12 @@
margin-left: auto;
}
+ .navBadgeAlert {
+ margin-left: auto;
+ background: #d97706;
+ color: white;
+ }
+
&::before {
content: '';
position: absolute;
diff --git a/frontend/src/components/layouts/AppLayout/Sidebar/index.tsx b/frontend/src/components/layouts/AppLayout/Sidebar/index.tsx
index cb06fd4014..0bb5fb72df 100644
--- a/frontend/src/components/layouts/AppLayout/Sidebar/index.tsx
+++ b/frontend/src/components/layouts/AppLayout/Sidebar/index.tsx
@@ -62,8 +62,15 @@ export const Sidebar: React.FC = ({
>
{item.icon && }
{item.label}
- {item.badge !== undefined &&
- {item.badge} }
+ {item.badge !== undefined && (
+
+ {item.badge}
+
+ )}
{item.comingSoon &&
{t`Coming Soon`} }
diff --git a/frontend/src/components/layouts/AppLayout/types.ts b/frontend/src/components/layouts/AppLayout/types.ts
index c0204933e3..ee1dd218c6 100644
--- a/frontend/src/components/layouts/AppLayout/types.ts
+++ b/frontend/src/components/layouts/AppLayout/types.ts
@@ -8,6 +8,7 @@ export interface NavItem {
comingSoon?: boolean;
isActive?: (isActive: boolean) => boolean;
badge?: string | number | null | undefined;
+ badgeColor?: string;
onClick?: () => void;
showWhen?: () => boolean | undefined;
loading?: boolean;
diff --git a/frontend/src/components/layouts/AuthLayout/Auth.module.scss b/frontend/src/components/layouts/AuthLayout/Auth.module.scss
index 694c1cd42b..399d9e8581 100644
--- a/frontend/src/components/layouts/AuthLayout/Auth.module.scss
+++ b/frontend/src/components/layouts/AuthLayout/Auth.module.scss
@@ -5,7 +5,6 @@
min-height: 100vh;
display: flex;
position: relative;
- overflow: hidden;
}
.splitLayout {
@@ -22,7 +21,6 @@
flex-direction: column;
position: relative;
background: linear-gradient(135deg, #fafafa 0%, var(--hi-color-gray) 50%, #faf8fc 100%);
- overflow-y: auto;
z-index: 2;
@include mixins.respond-below(md) {
@@ -113,15 +111,26 @@
}
}
-// Right Panel - Premium visual with background image
+// =========================================================
+// RIGHT PANEL — product showcase, matches app's light lavender vibe
+// =========================================================
.rightPanel {
width: 55%;
- max-width: 700px;
- position: relative;
+ max-width: 760px;
+ position: sticky;
+ top: 0;
+ align-self: flex-start;
+ height: 100vh;
+ height: 100dvh;
overflow: hidden;
+ isolation: isolate;
+ background:
+ radial-gradient(ellipse 80% 60% at 80% 10%, color-mix(in srgb, var(--mantine-color-primary-4) 55%, transparent), transparent 70%),
+ radial-gradient(ellipse 70% 50% at 15% 90%, color-mix(in srgb, var(--mantine-color-secondary-4) 45%, transparent), transparent 70%),
+ linear-gradient(180deg, color-mix(in srgb, var(--mantine-color-primary-2) 70%, white) 0%, var(--mantine-color-primary-3) 100%);
@include mixins.respond-below(lg) {
- width: 45%;
+ width: 48%;
}
@include mixins.respond-below(md) {
@@ -129,203 +138,543 @@
}
}
-.backgroundImage {
+// Film-grain noise — adds organic texture to the gradient
+.noise {
position: absolute;
inset: 0;
- background-image: url("/images/backgrounds/nightlife-bg.jpg");
- background-size: cover;
- background-position: center;
- filter: grayscale(20%);
+ pointer-events: none;
+ z-index: 1;
+ opacity: 0.22;
+ mix-blend-mode: multiply;
+ background-image: url("data:image/svg+xml;utf8, ");
+ background-size: 160px 160px;
}
-.backgroundOverlay {
- position: absolute;
- inset: 0;
- background: linear-gradient(
- 135deg,
- var(--mantine-color-primary-9) 0%,
- var(--mantine-color-primary-8) 30%,
- var(--mantine-color-primary-6) 60%,
- var(--mantine-color-secondary-5) 100%
- );
- opacity: 0.92;
-}
-
-// Grid pattern overlay
-.gridPattern {
+// Subtle dot grid for texture
+.dotGrid {
position: absolute;
inset: 0;
- opacity: 0.04;
- background-image:
- linear-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px),
- linear-gradient(90deg, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
- background-size: 50px 50px;
-}
-
-// Subtle glow effects
-.glowEffect {
- position: absolute;
- border-radius: 50%;
- filter: blur(80px);
- opacity: 0.4;
+ background-image: radial-gradient(circle, color-mix(in srgb, var(--mantine-color-primary-9) 18%, transparent) 1px, transparent 1px);
+ background-size: 24px 24px;
+ opacity: 0.35;
+ mask-image: radial-gradient(ellipse 80% 70% at 50% 50%, black 30%, transparent 75%);
+ -webkit-mask-image: radial-gradient(ellipse 80% 70% at 50% 50%, black 30%, transparent 75%);
pointer-events: none;
+ z-index: 0;
}
-.glowTop {
- top: -100px;
- right: -50px;
- width: 300px;
- height: 300px;
- background: rgba(255, 255, 255, 0.15);
-}
-
-.glowBottom {
- bottom: -100px;
- left: -50px;
- width: 350px;
- height: 350px;
- background: var(--mantine-color-secondary-3);
- opacity: 0.2;
-}
-
-.overlay {
+// Inner flex column — CENTERED like the form
+.panelInner {
position: relative;
+ z-index: 2;
height: 100%;
display: flex;
+ flex-direction: column;
align-items: center;
justify-content: center;
- padding: 3rem;
- z-index: 1;
+ padding: 3rem 3rem 5rem;
+ gap: 2.75rem;
+
+ @include mixins.respond-below(lg) {
+ padding: 2rem 2rem 4.5rem;
+ gap: 3rem;
+ }
+}
+
+// ------- HEADING BLOCK -------
+.headingBlock {
+ text-align: center;
+ max-width: 520px;
+ animation: rise 0.9s cubic-bezier(0.2, 0.8, 0.2, 1) both;
+}
+
+@keyframes rise {
+ from { opacity: 0; transform: translateY(12px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.heroTitle {
+ margin: 0;
+ font-size: 2.75rem;
+ line-height: 1.02;
+ letter-spacing: -0.03em;
+ color: var(--mantine-color-primary-9);
@include mixins.respond-below(lg) {
- padding: 2rem;
+ font-size: 2.125rem;
}
}
-.content {
- max-width: 400px;
+.heroBold {
+ font-weight: 800;
+ display: block;
+}
+
+.heroLight {
+ font-weight: 300;
+ font-style: italic;
+ color: color-mix(in srgb, var(--mantine-color-primary-9) 70%, white);
+ display: block;
+}
+
+// ------- DASHBOARD STAGE — the centerpiece -------
+.dashStage {
+ position: relative;
width: 100%;
+ max-width: 460px;
+ aspect-ratio: 1 / 0.82;
+ animation: rise 1.1s cubic-bezier(0.2, 0.8, 0.2, 1) 0.1s both;
@include mixins.respond-below(lg) {
- max-width: 340px;
+ max-width: 380px;
}
}
-// Badge at top
-.badge {
+// Main event dashboard card
+.dashCard {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%) rotate(-1.5deg);
+ width: 100%;
+ background: white;
+ border-radius: 18px;
+ padding: 1.25rem 1.375rem 1.125rem;
+ box-shadow:
+ 0 1px 0 rgba(255, 255, 255, 0.9) inset,
+ 0 24px 48px -12px color-mix(in srgb, var(--mantine-color-primary-9) 25%, transparent),
+ 0 2px 8px -2px color-mix(in srgb, var(--mantine-color-primary-9) 15%, transparent);
+ border: 1px solid color-mix(in srgb, var(--mantine-color-primary-3) 30%, white);
+ z-index: 2;
+ animation: floatMain 8s ease-in-out infinite;
+}
+
+@keyframes floatMain {
+ 0%, 100% { transform: translate(-50%, -50%) rotate(-1.5deg); }
+ 50% { transform: translate(-50%, calc(-50% - 4px)) rotate(-1.5deg); }
+}
+
+.dashHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-bottom: 0.875rem;
+}
+
+.dashHeaderLeft {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ min-width: 0;
+}
+
+.dashCover {
+ width: 32px;
+ height: 32px;
+ border-radius: 8px;
+ background: linear-gradient(135deg, var(--mantine-color-primary-5), var(--mantine-color-secondary-5));
+ flex-shrink: 0;
+ position: relative;
+ overflow: hidden;
+
+ &::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.4), transparent 60%);
+ }
+}
+
+.dashTitle {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--mantine-color-primary-9);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.2;
+}
+
+.dashTitleSub {
+ font-size: 0.6875rem;
+ color: color-mix(in srgb, var(--mantine-color-primary-9) 50%, white);
+ margin-top: 1px;
+}
+
+.dashBadge {
display: inline-flex;
align-items: center;
- gap: 0.5rem;
- background: rgba(255, 255, 255, 0.1);
- border: 1px solid rgba(255, 255, 255, 0.15);
- padding: 0.5rem 1rem;
+ gap: 0.3125rem;
+ padding: 0.25rem 0.5rem 0.25rem 0.4375rem;
+ background: color-mix(in srgb, #16a34a 12%, white);
+ border: 1px solid color-mix(in srgb, #16a34a 25%, white);
border-radius: 9999px;
- color: white;
- font-size: 0.8125rem;
- font-weight: 500;
- margin-bottom: 2rem;
- backdrop-filter: blur(8px);
+ font-size: 0.625rem;
+ font-weight: 600;
+ color: #15803d;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ flex-shrink: 0;
+}
- @include mixins.respond-below(lg) {
- margin-bottom: 1.5rem;
- font-size: 0.75rem;
- padding: 0.375rem 0.875rem;
- }
+.dashBadgeDot {
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: #16a34a;
+ box-shadow: 0 0 6px #16a34a;
+ animation: pulse 2s ease-in-out infinite;
+}
- svg {
- width: 14px;
- height: 14px;
- opacity: 0.9;
- }
+@keyframes pulse {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.5; transform: scale(0.7); }
}
-// Feature grid
-.featureGrid {
+.dashStatRow {
+ display: flex;
+ align-items: baseline;
+ gap: 0.5rem;
+ margin-bottom: 0.125rem;
+}
+
+.dashStatBig {
+ font-size: 1.875rem;
+ font-weight: 800;
+ color: var(--mantine-color-primary-9);
+ letter-spacing: -0.02em;
+ line-height: 1;
+ font-feature-settings: "tnum";
+}
+
+.dashStatTrend {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.125rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: #15803d;
+}
+
+.dashStatLabel {
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ font-size: 0.625rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: color-mix(in srgb, var(--mantine-color-primary-9) 45%, white);
+ margin-bottom: 0.75rem;
+}
+
+.dashChart {
+ width: 100%;
+ height: 48px;
+ margin-bottom: 0.875rem;
+ overflow: visible;
+}
+
+.dashChartLine {
+ fill: none;
+ stroke: var(--mantine-color-primary-6);
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-dasharray: 500;
+ stroke-dashoffset: 500;
+ animation: drawLine 2s cubic-bezier(0.4, 0, 0.2, 1) 0.4s forwards;
+}
+
+@keyframes drawLine {
+ to { stroke-dashoffset: 0; }
+}
+
+.dashChartFill {
+ fill: url(#chartGradient);
+ opacity: 0;
+ animation: fadeIn 0.8s ease-out 1.2s forwards;
+}
+
+@keyframes fadeIn {
+ to { opacity: 1; }
+}
+
+.dashChartDot {
+ fill: var(--mantine-color-primary-6);
+ stroke: white;
+ stroke-width: 2;
+ opacity: 0;
+ animation: fadeIn 0.4s ease-out 2s forwards;
+}
+
+.dashTiers {
display: flex;
flex-direction: column;
- gap: 0.75rem;
+ gap: 0.4375rem;
+ margin-bottom: 0.875rem;
+}
- @include mixins.respond-below(lg) {
- gap: 0.5rem;
+.dashTier {
+ display: grid;
+ grid-template-columns: 64px 1fr 42px;
+ align-items: center;
+ gap: 0.625rem;
+ font-size: 0.6875rem;
+}
+
+.dashTierName {
+ color: var(--mantine-color-primary-9);
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.dashTierBar {
+ height: 5px;
+ background: color-mix(in srgb, var(--mantine-color-primary-2) 60%, white);
+ border-radius: 999px;
+ overflow: hidden;
+ position: relative;
+}
+
+.dashTierBarFill {
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(90deg, var(--mantine-color-primary-5), var(--mantine-color-primary-7));
+ border-radius: 999px;
+ transform-origin: left;
+ transform: scaleX(0);
+ animation: fillBar 1.2s cubic-bezier(0.4, 0, 0.2, 1) forwards;
+}
+
+@keyframes fillBar {
+ to { transform: scaleX(var(--fill, 0.5)); }
+}
+
+.dashTierCount {
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ font-size: 0.625rem;
+ color: color-mix(in srgb, var(--mantine-color-primary-9) 55%, white);
+ text-align: right;
+ font-feature-settings: "tnum";
+}
+
+.dashFooter {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-top: 0.75rem;
+ border-top: 1px solid color-mix(in srgb, var(--mantine-color-primary-2) 60%, white);
+}
+
+.dashAvatars {
+ display: flex;
+ align-items: center;
+}
+
+.dashAvatar {
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ border: 2px solid white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.5625rem;
+ font-weight: 700;
+ color: white;
+
+ &:not(:first-child) {
+ margin-left: -7px;
}
}
-.feature {
+.dashAvatar1 { background: linear-gradient(135deg, #f97316, #dc2626); }
+.dashAvatar2 { background: linear-gradient(135deg, #8b5cf6, #6366f1); }
+.dashAvatar3 { background: linear-gradient(135deg, #06b6d4, #0ea5e9); }
+.dashAvatar4 { background: linear-gradient(135deg, #10b981, #059669); }
+
+.dashFooterText {
+ font-size: 0.6875rem;
+ color: color-mix(in srgb, var(--mantine-color-primary-9) 55%, white);
+ font-weight: 500;
+}
+
+// Floating secondary notification cards
+.floatCard {
+ position: absolute;
+ background: white;
+ border-radius: 14px;
+ padding: 0.75rem 0.875rem;
+ box-shadow:
+ 0 18px 36px -12px color-mix(in srgb, var(--mantine-color-primary-9) 22%, transparent),
+ 0 2px 6px -1px color-mix(in srgb, var(--mantine-color-primary-9) 10%, transparent);
+ border: 1px solid color-mix(in srgb, var(--mantine-color-primary-3) 25%, white);
display: flex;
- align-items: flex-start;
- gap: 1rem;
- padding: 1rem 1.25rem;
- border-radius: 1rem;
- background: rgba(255, 255, 255, 0.06);
- border: 1px solid rgba(255, 255, 255, 0.08);
- backdrop-filter: blur(8px);
- transition: all 0.3s ease;
- cursor: default;
+ align-items: center;
+ gap: 0.625rem;
+ min-width: 0;
+ z-index: 3;
+}
+
+.floatCardTop {
+ top: 4%;
+ right: -6%;
+ width: 200px;
+ transform: rotate(3deg);
+ animation: floatA 7s ease-in-out infinite;
@include mixins.respond-below(lg) {
- padding: 0.875rem 1rem;
- gap: 0.75rem;
+ width: 180px;
+ top: 6%;
+ right: -4%;
}
+}
+
+.floatCardBottom {
+ bottom: -9%;
+ left: -8%;
+ width: 210px;
+ transform: rotate(-4deg);
+ animation: floatB 9s ease-in-out infinite;
- &:hover {
- background: rgba(255, 255, 255, 0.1);
- border-color: rgba(255, 255, 255, 0.15);
- transform: translateX(4px);
+ @include mixins.respond-below(lg) {
+ width: 190px;
+ bottom: -11%;
+ left: -6%;
}
}
-.featureIcon {
+@keyframes floatA {
+ 0%, 100% { transform: rotate(3deg) translateY(0); }
+ 50% { transform: rotate(3deg) translateY(-6px); }
+}
+
+@keyframes floatB {
+ 0%, 100% { transform: rotate(-4deg) translateY(0); }
+ 50% { transform: rotate(-4deg) translateY(-6px); }
+}
+
+.floatIcon {
+ width: 32px;
+ height: 32px;
+ border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
- width: 36px;
- height: 36px;
- min-width: 36px;
- border-radius: 10px;
- background: rgba(255, 255, 255, 0.12);
- color: white;
+ flex-shrink: 0;
+ background: color-mix(in srgb, var(--mantine-color-primary-3) 25%, white);
+ color: var(--mantine-color-primary-7);
+}
+
+.floatBody {
+ min-width: 0;
+ flex: 1;
+}
+
+.floatTitle {
+ font-size: 0.6875rem;
+ font-weight: 600;
+ color: var(--mantine-color-primary-9);
+ line-height: 1.2;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.floatSub {
+ font-size: 0.625rem;
+ color: color-mix(in srgb, var(--mantine-color-primary-9) 55%, white);
+ margin-top: 2px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+// ------- FEATURE TICKER — pinned at bottom, subtle scroll -------
+.ticker {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 3.25rem;
+ display: flex;
+ align-items: center;
+ overflow: hidden;
+ z-index: 4;
+ border-top: 1px solid color-mix(in srgb, var(--mantine-color-primary-9) 8%, transparent);
+ background: color-mix(in srgb, var(--mantine-color-primary-0) 55%, transparent);
+ backdrop-filter: blur(10px) saturate(140%);
+ -webkit-backdrop-filter: blur(10px) saturate(140%);
+ mask-image: linear-gradient(90deg, transparent, black 6%, black 94%, transparent);
+ -webkit-mask-image: linear-gradient(90deg, transparent, black 6%, black 94%, transparent);
@include mixins.respond-below(lg) {
- width: 32px;
- height: 32px;
- min-width: 32px;
+ height: 3rem;
}
+}
- svg {
- width: 18px;
- height: 18px;
+.tickerTrack {
+ display: flex;
+ align-items: center;
+ gap: 2.25rem;
+ width: max-content;
+ animation: tickerScroll 180s linear infinite;
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ font-size: 0.6875rem;
+ text-transform: uppercase;
+ letter-spacing: 0.14em;
+ color: color-mix(in srgb, var(--mantine-color-primary-9) 60%, white);
+ white-space: nowrap;
+ will-change: transform;
- @include mixins.respond-below(lg) {
- width: 16px;
- height: 16px;
- }
+ @include mixins.respond-below(lg) {
+ font-size: 0.625rem;
+ gap: 1.875rem;
}
}
-.featureText {
- flex: 1;
- min-width: 0;
+@keyframes tickerScroll {
+ from { transform: translateX(0); }
+ to { transform: translateX(-50%); }
+}
- h3 {
- margin: 0 0 0.25rem;
- font-size: 0.9375rem;
- font-weight: 600;
- color: white;
- letter-spacing: -0.01em;
+.tickerItem {
+ display: inline-flex;
+ align-items: center;
+ gap: 2.25rem;
- @include mixins.respond-below(lg) {
- font-size: 0.875rem;
- }
+ @include mixins.respond-below(lg) {
+ gap: 1.875rem;
}
+}
- p {
- margin: 0;
- font-size: 0.8125rem;
- color: rgba(255, 255, 255, 0.7);
- line-height: 1.5;
+.tickerDot {
+ width: 4px;
+ height: 4px;
+ border-radius: 50%;
+ background: var(--mantine-color-primary-5);
+ opacity: 0.55;
+ flex-shrink: 0;
+}
- @include mixins.respond-below(lg) {
- font-size: 0.75rem;
- }
+@media (prefers-reduced-motion: reduce) {
+ .headingBlock,
+ .dashStage,
+ .dashCard,
+ .dashBadgeDot,
+ .dashChartLine,
+ .dashChartFill,
+ .dashChartDot,
+ .dashTierBarFill,
+ .floatCardTop,
+ .floatCardBottom,
+ .tickerTrack {
+ animation: none;
}
+
+ .dashChartLine { stroke-dashoffset: 0; }
+ .dashChartFill,
+ .dashChartDot { opacity: 1; }
+ .dashTierBarFill { transform: scaleX(var(--fill, 0.5)); }
}
diff --git a/frontend/src/components/layouts/AuthLayout/index.tsx b/frontend/src/components/layouts/AuthLayout/index.tsx
index 88ca89099f..6a88fb0627 100644
--- a/frontend/src/components/layouts/AuthLayout/index.tsx
+++ b/frontend/src/components/layouts/AuthLayout/index.tsx
@@ -4,102 +4,152 @@ import {t} from "@lingui/macro";
import {useGetMe} from "../../../queries/useGetMe.ts";
import {PoweredByFooter} from "../../common/PoweredByFooter";
import {LanguageSwitcher} from "../../common/LanguageSwitcher";
-import {
- IconChartBar,
- IconCreditCard,
- IconDeviceMobile,
- IconPalette,
- IconQrcode,
- IconShieldCheck,
- IconSparkles,
- IconTicket,
- IconUsers,
-} from '@tabler/icons-react';
-import {useCallback, useMemo, useRef} from "react";
+import {IconBellRinging, IconUsersGroup} from "@tabler/icons-react";
+import {useCallback, useRef} from "react";
import {getConfig} from "../../../utilites/config.ts";
import {isHiEvents} from "../../../utilites/helpers.ts";
import {showInfo} from "../../../utilites/notifications.tsx";
-const allFeatures = [
- {
- icon: IconTicket,
- title: t`Flexible Ticketing`,
- description: t`Paid, free, tiered pricing, and donation-based tickets`
- },
- {
- icon: IconQrcode,
- title: t`QR Code Check-in`,
- description: t`Mobile scanner with offline support and real-time tracking`
- },
- {
- icon: IconCreditCard,
- title: t`Instant Payouts`,
- description: t`Get paid immediately via Stripe Connect`
- },
- {
- icon: IconChartBar,
- title: t`Real-Time Analytics`,
- description: t`Track sales, revenue, and attendance with detailed reports`
- },
- {
- icon: IconPalette,
- title: t`Custom Branding`,
- description: t`Your logo, colors, and style on every page`
- },
- {
- icon: IconDeviceMobile,
- title: t`Mobile Optimized`,
- description: t`Beautiful checkout experience on any device`
- },
- {
- icon: IconUsers,
- title: t`Team Management`,
- description: t`Invite unlimited team members with custom roles`
- },
- {
- icon: IconShieldCheck,
- title: t`Data Ownership`,
- description: t`You own 100% of your attendee data, always`
- },
+const tiers = [
+ {name: "VIP Pass", count: "87/100", fill: 0.87},
+ {name: "Early Bird", count: "240/240", fill: 1.0},
+ {name: "General", count: "512/750", fill: 0.68},
+];
+
+const tickerFeatures = [
+ t`Recurring events`,
+ t`Instant Stripe payouts`,
+ t`Custom branding`,
+ t`QR code check-in`,
+ t`Waitlist`,
+ t`Promo codes`,
+ t`Real-time analytics`,
+ t`Email & scheduled messages`,
+ t`Embeddable widget`,
+ t`Affiliate program`,
+ t`Team collaboration`,
+ t`Custom questions`,
+ t`Webhook integrations`,
+ t`Full data ownership`,
+ t`Multiple ticket types`,
+ t`Capacity management`,
];
const FeaturePanel = () => {
- const selectedFeatures = useMemo(() => {
- const shuffled = [...allFeatures].sort(() => 0.5 - Math.random());
- return shuffled.slice(0, 4);
- }, []);
+ const tickerLoop = [...tickerFeatures, ...tickerFeatures];
return (
-
-
-
-
-
-
-
-
-
-
-
{t`Event Management Platform`}
+
+
+
+
+
+
+ {t`Sell out your event.`}
+ {t`Keep the profit.`}
+
+
+
+
+ {/* Secondary floating card — top right */}
+
+
+
+
+
+
{t`Waitlist triggered`}
+
{t`12 tickets offered`}
+
-
- {selectedFeatures.map((feature, index) => {
- const Icon = feature.icon;
- return (
-
-
-
-
-
-
{feature.title}
-
{feature.description}
+ {/* Main event dashboard card */}
+
+
+
+
+
+
Summer Synth Festival
+
Sat, Aug 16 · Berlin
+
+
+
+
+ {t`Live`}
+
+
+
+
+
{t`Revenue today`}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {tiers.map((tier, i) => (
+
+
{tier.name}
+
+
{tier.count}
- );
- })}
+ ))}
+
+
+
+
+ {/* Secondary floating card — bottom left */}
+
+
+
+
+
+
{t`Reminder scheduled`}
+
{t`Sending in 2d 4h`}
+
+
+
+
+
+
+
+ {tickerLoop.map((item, i) => (
+
+ {item}
+
+
+ ))}
diff --git a/frontend/src/components/layouts/CheckIn/AttendeeDetailSheet.module.scss b/frontend/src/components/layouts/CheckIn/AttendeeDetailSheet.module.scss
new file mode 100644
index 0000000000..aaeea8a38c
--- /dev/null
+++ b/frontend/src/components/layouts/CheckIn/AttendeeDetailSheet.module.scss
@@ -0,0 +1,333 @@
+@use "../../../styles/mixins";
+
+.drawer {
+ border-radius: 20px 20px 0 0 !important;
+ max-height: 90vh !important;
+ // Default transition lets the sheet snap back when a partial drag is released.
+ // During an active drag we zero this out via inline style for direct 1:1 tracking.
+ transition: transform 260ms cubic-bezier(0.32, 0.72, 0, 1);
+}
+
+.body {
+ padding: 0 !important;
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ @include mixins.scrollbar();
+}
+
+.handleHitArea {
+ // Fat target for the grab handle — the visible bar is thin but the draggable
+ // area needs to comfortably catch a thumb.
+ padding: 10px 0 6px;
+ display: flex;
+ justify-content: center;
+ cursor: grab;
+ touch-action: none;
+ flex-shrink: 0;
+
+ &:active {
+ cursor: grabbing;
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--hi-primary);
+ outline-offset: 2px;
+ }
+}
+
+.handle {
+ width: 42px;
+ height: 4px;
+ border-radius: 2px;
+ background: var(--hi-color-gray-2);
+ pointer-events: none;
+}
+
+.loading {
+ padding: 40px;
+ display: flex;
+ justify-content: center;
+}
+
+.errorBlock {
+ padding: 40px 20px;
+ text-align: center;
+ color: #c92a2a;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+}
+
+.header {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 16px 20px 10px;
+}
+
+.headerMain {
+ flex: 1;
+ display: flex;
+ gap: 12px;
+ min-width: 0;
+}
+
+.avatar {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ background: var(--mantine-color-primary-1, #dcd2f0);
+ color: var(--mantine-color-primary-9, #33205a);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.headerText {
+ flex: 1;
+ min-width: 0;
+}
+
+.name {
+ font-size: 18px;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ color: var(--hi-text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.meta {
+ margin-top: 2px;
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ font-size: 12px;
+ color: var(--hi-color-gray-dark);
+ min-width: 0;
+}
+
+.code {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-weight: 600;
+ color: var(--hi-primary);
+ flex-shrink: 0;
+}
+
+.dot {
+ opacity: 0.5;
+ flex-shrink: 0;
+}
+
+.product {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.occurrenceRow {
+ margin-top: 4px;
+ display: flex;
+ gap: 5px;
+ align-items: center;
+ font-size: 12px;
+ color: var(--hi-color-gray-dark);
+ min-width: 0;
+
+ > span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
+
+.statusRow {
+ margin-top: 8px;
+}
+
+.closeBtn {
+ border: none;
+ background: var(--hi-color-gray);
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ color: var(--hi-color-gray-dark);
+ flex-shrink: 0;
+
+ &:hover {
+ background: var(--hi-color-gray-2);
+ color: var(--hi-text);
+ }
+}
+
+.contactRow {
+ padding: 0 20px 10px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.contactItem {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ color: var(--hi-color-gray-dark);
+ padding: 4px 10px;
+ background: var(--hi-color-gray);
+ border-radius: 999px;
+ max-width: 100%;
+
+ span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
+
+.section {
+ padding: 10px 20px;
+}
+
+.sectionLabel {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--hi-color-gray-dark);
+ margin-bottom: 6px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.checkInsList {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.checkInRow {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ background: color-mix(in srgb, var(--hi-color-money-green) 8%, #fff);
+ border: 1px solid color-mix(in srgb, var(--hi-color-money-green) 28%, transparent);
+ border-radius: 10px;
+ font-size: 13px;
+ color: var(--hi-text);
+}
+
+.checkInBadge {
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ background: var(--hi-color-money-green);
+ color: #fff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.notesBox {
+ padding: 12px 14px;
+ background: color-mix(in srgb, #f59f00 10%, #fff);
+ border: 1px solid color-mix(in srgb, #f59f00 32%, transparent);
+ border-radius: 10px;
+ font-size: 14px;
+ line-height: 1.5;
+ color: var(--hi-text);
+ white-space: pre-wrap;
+}
+
+.answersList {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.answerRow {
+ padding: 10px 12px;
+ background: var(--hi-color-gray);
+ border-radius: 10px;
+}
+
+.answerQ {
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--hi-color-gray-dark);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.answerA {
+ font-size: 14px;
+ color: var(--hi-text);
+ margin-top: 2px;
+ white-space: pre-wrap;
+}
+
+.orderGrid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 6px;
+}
+
+.orderCell {
+ padding: 10px 12px;
+ background: var(--hi-color-gray);
+ border-radius: 10px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.orderLabel {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ color: var(--hi-color-gray-dark);
+ text-transform: uppercase;
+}
+
+.orderValue {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--hi-text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.hiddenHint {
+ margin: 6px 20px 0;
+ padding: 10px 12px;
+ background: var(--hi-color-gray);
+ border-radius: 10px;
+ font-size: 12px;
+ color: var(--hi-color-gray-dark);
+ line-height: 1.4;
+}
+
+.footer {
+ position: sticky;
+ bottom: 0;
+ padding: 12px 20px calc(14px + env(safe-area-inset-bottom));
+ background: #fff;
+ border-top: 1px solid var(--hi-color-gray-2);
+ margin-top: 10px;
+}
diff --git a/frontend/src/components/layouts/CheckIn/AttendeeDetailSheet.tsx b/frontend/src/components/layouts/CheckIn/AttendeeDetailSheet.tsx
new file mode 100644
index 0000000000..c2684ad0ac
--- /dev/null
+++ b/frontend/src/components/layouts/CheckIn/AttendeeDetailSheet.tsx
@@ -0,0 +1,320 @@
+import {useCallback, useEffect, useMemo, useRef, useState} from "react";
+import {Badge, Button, Drawer, Loader} from "@mantine/core";
+import {t, Trans} from "@lingui/macro";
+import {
+ IconAlertTriangle,
+ IconCalendarEvent,
+ IconCheck,
+ IconClipboardText,
+ IconMail,
+ IconReceipt2,
+ IconTicket,
+ IconUser,
+ IconX,
+} from "@tabler/icons-react";
+import {AttendeeDetailPublic, EventType} from "../../../types.ts";
+import {useGetCheckInListAttendeeDetailPublic} from "../../../queries/useGetCheckInListAttendeeDetailPublic.ts";
+import {useGetMe} from "../../../queries/useGetMe.ts";
+import {formatDateWithLocale} from "../../../utilites/dates.ts";
+import classes from "./AttendeeDetailSheet.module.scss";
+
+interface Props {
+ checkInListShortId: string | undefined;
+ attendeePublicId: string | null;
+ eventType?: EventType;
+ timezone?: string;
+ onClose: () => void;
+ onCheckInToggle: (detail: AttendeeDetailPublic) => void;
+ isActionPending: boolean;
+}
+
+const formatDateTime = (iso: string | undefined | null) => {
+ if (!iso) return "";
+ const normalized = iso.includes("T") ? iso : iso.replace(" ", "T") + "Z";
+ const d = new Date(normalized);
+ if (Number.isNaN(d.getTime())) return "";
+ return d.toLocaleString([], {month: "short", day: "numeric", hour: "numeric", minute: "2-digit"});
+};
+
+const answerToString = (answer: string | string[] | null | undefined): string => {
+ if (answer == null) return "—";
+ if (Array.isArray(answer)) return answer.join(", ");
+ return answer;
+};
+
+const DRAG_DISMISS_THRESHOLD_PX = 90;
+
+export const AttendeeDetailSheet = ({
+ checkInListShortId,
+ attendeePublicId,
+ eventType,
+ timezone,
+ onClose,
+ onCheckInToggle,
+ isActionPending,
+ }: Props) => {
+ const open = attendeePublicId !== null;
+ const detailQuery = useGetCheckInListAttendeeDetailPublic(checkInListShortId, attendeePublicId);
+ const meQuery = useGetMe();
+ const isLoggedIn = !!meQuery.data?.id;
+
+ // Drag-to-dismiss: track a downward drag from the handle and close when the user
+ // passes the threshold. Smaller deltas snap back via the CSS transition.
+ const dragStartRef = useRef
(null);
+ const [dragOffset, setDragOffset] = useState(0);
+ const [dragging, setDragging] = useState(false);
+
+ useEffect(() => {
+ if (!open) {
+ setDragOffset(0);
+ setDragging(false);
+ dragStartRef.current = null;
+ }
+ }, [open]);
+
+ const onHandlePointerDown = useCallback((e: React.PointerEvent) => {
+ dragStartRef.current = e.clientY;
+ setDragging(true);
+ e.currentTarget.setPointerCapture(e.pointerId);
+ }, []);
+
+ const onHandlePointerMove = useCallback((e: React.PointerEvent) => {
+ if (dragStartRef.current === null) return;
+ // Downward drag only; upward drag is clamped to 0 so the sheet doesn't lift.
+ const delta = Math.max(0, e.clientY - dragStartRef.current);
+ setDragOffset(delta);
+ }, []);
+
+ const onHandlePointerEnd = useCallback((e: React.PointerEvent) => {
+ if (dragStartRef.current === null) return;
+ const delta = Math.max(0, e.clientY - dragStartRef.current);
+ dragStartRef.current = null;
+ setDragging(false);
+ try {
+ e.currentTarget.releasePointerCapture(e.pointerId);
+ } catch {
+ // Capture may have already been released; ignore.
+ }
+ if (delta > DRAG_DISMISS_THRESHOLD_PX) {
+ onClose();
+ } else {
+ setDragOffset(0);
+ }
+ }, [onClose]);
+
+ const detail = detailQuery.data?.data;
+ const currentCheckIn = detail?.check_ins?.[0];
+ const isCheckedIn = !!currentCheckIn;
+
+ // The server already filters based on list visibility + staff account. When logged in,
+ // the frontend might be loaded before the stats/filter resolve - so we treat the logged-in
+ // flag as authoritative for showing sensitive fields.
+ const visibility = detail?.visibility;
+ const showNotes = isLoggedIn || !!visibility?.notes;
+ const showQuestions = isLoggedIn || !!visibility?.question_answers;
+ const showOrder = isLoggedIn || !!visibility?.order_details;
+
+ const statusBadge = useMemo(() => {
+ if (!detail) return null;
+ if (detail.status === "CANCELLED") {
+ return {t`Cancelled`} ;
+ }
+ if (detail.status === "AWAITING_PAYMENT") {
+ return {t`Awaiting payment`} ;
+ }
+ if (isCheckedIn) {
+ return {t`Checked in`} ;
+ }
+ return {t`Not checked in`} ;
+ }, [detail, isCheckedIn]);
+
+ const canToggle = detail && detail.status !== "CANCELLED";
+
+ return (
+ 0 ? `translateY(${dragOffset}px)` : undefined,
+ transition: dragging ? "none" : undefined,
+ },
+ }}
+ >
+
+
+
+
+ {detailQuery.isLoading && !detail && (
+
+
+
+ )}
+
+ {detailQuery.isError && (
+
+
+ {t`Unable to load attendee details.`}
+
+ )}
+
+ {detail && (
+ <>
+
+
+
+
+
+
+
+ {detail.first_name} {detail.last_name}
+
+
+ {detail.public_id}
+ {detail.product_title && (
+ <>
+ ·
+
+ {detail.product_title}
+
+ >
+ )}
+
+ {eventType === EventType.RECURRING && detail.event_occurrence && timezone && (
+
+
+
+ {formatDateWithLocale(detail.event_occurrence.start_date, 'shortDate', timezone)}
+ {' · '}
+ {formatDateWithLocale(detail.event_occurrence.start_date, 'timeOnly', timezone)}
+ {detail.event_occurrence.label ? ` · ${detail.event_occurrence.label}` : ''}
+
+
+ )}
+
{statusBadge}
+
+
+
+
+
+
+
+
+
+ {isCheckedIn && currentCheckIn && (
+
+
{t`Check-in history`}
+
+ {detail.check_ins.map(c => (
+
+
+
{formatDateTime(c.checked_in_at)}
+
+ ))}
+
+
+ )}
+
+ {showNotes && detail.notes && (
+
+
+ {t`Notes`}
+
+
{detail.notes}
+
+ )}
+
+ {showQuestions && detail.question_answers && detail.question_answers.length > 0 && (
+
+
{t`Answers`}
+
+ {detail.question_answers.map((qa, idx) => (
+
+
{qa.title}
+
{answerToString(qa.answer)}
+
+ ))}
+
+
+ )}
+
+ {showOrder && detail.order && (
+
+
+ {t`Order`}
+
+
+
+ {t`Order #`}
+ {detail.order.public_id}
+
+
+ {t`Placed`}
+ {formatDateTime(detail.order.created_at)}
+
+ {detail.order.first_name && (
+
+ {t`Purchaser`}
+
+ {detail.order.first_name} {detail.order.last_name}
+
+
+ )}
+ {detail.order.email && (
+
+ {t`Purchaser email`}
+ {detail.order.email}
+
+ )}
+
+
+ )}
+
+ {!isLoggedIn && visibility && (!visibility.notes || !visibility.question_answers || !visibility.order_details) && (
+
+ Some details are hidden from public access. Log in to view everything.
+
+ )}
+
+
+ detail && onCheckInToggle(detail)}
+ leftSection={isCheckedIn ? : }
+ >
+ {isCheckedIn ? t`Check out` : t`Check in`}
+
+
+ >
+ )}
+
+
+ );
+};
diff --git a/frontend/src/components/layouts/CheckIn/BottomNav.module.scss b/frontend/src/components/layouts/CheckIn/BottomNav.module.scss
new file mode 100644
index 0000000000..de65a6b720
--- /dev/null
+++ b/frontend/src/components/layouts/CheckIn/BottomNav.module.scss
@@ -0,0 +1,79 @@
+.nav {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ padding: 10px 12px calc(14px + env(safe-area-inset-bottom));
+ pointer-events: none;
+ z-index: 40;
+ display: flex;
+ justify-content: center;
+}
+
+.pill {
+ pointer-events: auto;
+ display: flex;
+ gap: 4px;
+ padding: 6px;
+ background: rgba(255, 255, 255, 0.92);
+ backdrop-filter: blur(24px) saturate(1.2);
+ -webkit-backdrop-filter: blur(24px) saturate(1.2);
+ border: 1px solid rgba(0, 0, 0, 0.06);
+ border-radius: 999px;
+ box-shadow:
+ 0 10px 30px rgba(15, 10, 30, 0.18),
+ 0 2px 6px rgba(15, 10, 30, 0.08);
+ width: min(420px, calc(100vw - 24px));
+}
+
+.tab {
+ flex: 1;
+ background: transparent;
+ border: none;
+ padding: 10px 10px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 2px;
+ border-radius: 999px;
+ color: var(--hi-color-gray-dark);
+ cursor: pointer;
+ font-family: inherit;
+ font-weight: 600;
+ transition: transform 120ms ease, background 160ms ease, color 160ms ease;
+ min-height: 52px;
+
+ &:active {
+ transform: scale(0.96);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--hi-primary);
+ outline-offset: 2px;
+ }
+}
+
+.icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ line-height: 1;
+}
+
+.label {
+ font-size: 11px;
+ letter-spacing: 0.02em;
+}
+
+.active {
+ background: var(--hi-gradient);
+ color: #fff;
+ box-shadow: 0 6px 18px rgba(64, 41, 108, 0.35);
+}
+
+@media (max-width: 480px) {
+ .pill {
+ width: calc(100vw - 24px);
+ }
+}
diff --git a/frontend/src/components/layouts/CheckIn/BottomNav.tsx b/frontend/src/components/layouts/CheckIn/BottomNav.tsx
new file mode 100644
index 0000000000..9abd54849c
--- /dev/null
+++ b/frontend/src/components/layouts/CheckIn/BottomNav.tsx
@@ -0,0 +1,37 @@
+import {t} from "@lingui/macro";
+import {IconChartBar, IconQrcode, IconSearch} from "@tabler/icons-react";
+import classes from "./BottomNav.module.scss";
+
+export type CheckInTab = "scan" | "search" | "stats";
+
+interface BottomNavProps {
+ active: CheckInTab;
+ onChange: (tab: CheckInTab) => void;
+}
+
+export const BottomNav = ({active, onChange}: BottomNavProps) => {
+ const tabs: { id: CheckInTab; label: string; icon: JSX.Element }[] = [
+ {id: "scan", label: t`Scan`, icon:
},
+ {id: "search", label: t`Search`, icon: },
+ {id: "stats", label: t`Stats`, icon: },
+ ];
+
+ return (
+
+
+ {tabs.map(tab => (
+ onChange(tab.id)}
+ aria-current={active === tab.id ? "page" : undefined}
+ >
+ {tab.icon}
+ {tab.label}
+
+ ))}
+
+
+ );
+};
diff --git a/frontend/src/components/layouts/CheckIn/CheckIn.module.scss b/frontend/src/components/layouts/CheckIn/CheckIn.module.scss
index fbfb267234..0a1ce6502a 100644
--- a/frontend/src/components/layouts/CheckIn/CheckIn.module.scss
+++ b/frontend/src/components/layouts/CheckIn/CheckIn.module.scss
@@ -1,121 +1,130 @@
@use "../../../styles/mixins";
-.container {
- min-width: 300px;
-}
-
-.header {
- position: sticky;
- top: 0px;
- z-index: 2;
+.app {
+ position: fixed;
+ inset: 0;
display: flex;
flex-direction: column;
- padding: 15px;
- background-color: #fff;
- border-bottom: 1px solid #e0e0e0;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.07);
-
- .title {
- margin-bottom: 10px;
- margin-top: 0px;
- }
-
- .search {
- flex: 1;
-
- .offline {
- margin-bottom: 20px;
- }
-
- .description {
- margin-bottom: 20px;
- }
+ background: var(--hi-color-gray);
+ overflow: hidden;
+}
- .searchBar {
- display: flex;
- gap: 6px;
- align-items: center;
+.topBar {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 14px 16px 12px;
+ padding-top: calc(14px + env(safe-area-inset-top));
+ background: #fff;
+ border-bottom: 1px solid var(--hi-color-gray-2);
+ color: var(--hi-text);
+ z-index: 20;
+}
- .searchInput {
- flex: 1;
- margin-bottom: 0 !important;
- }
+.topBarMain {
+ flex: 1;
+ min-width: 0;
+}
- .scanButton {
- @include mixins.respond-below(sm) {
- display: none;
- }
- }
+.topLabel {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--hi-color-gray-dark);
+ line-height: 1;
+ margin-bottom: 4px;
+}
- .scanIcon {
- display: none;
- @include mixins.respond-below(sm) {
- display: flex;
- }
- }
- }
- }
+.topTitle {
+ font-size: 17px;
+ font-weight: 800;
+ letter-spacing: -0.01em;
+ line-height: 1.2;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
- .stats {
- flex: 1;
+.topScope {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin-top: 3px;
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--hi-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ > span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
}
-.loading,
-.noResults {
+.topRight {
display: flex;
- justify-content: center;
align-items: center;
- margin-top: 50px;
+ gap: 8px;
}
-.attendees {
- .attendee {
- display: flex;
- align-items: center;
- background-color: #fff;
- padding: 15px;
- border-bottom: 1px solid #e0e0e0;
-
- .details {
- flex: 1;
- gap: 5px;
- display: flex;
- flex-direction: column;
+.progressChip {
+ display: flex;
+ align-items: baseline;
+ gap: 1px;
+ padding: 6px 12px;
+ border-radius: 999px;
+ background: var(--hi-color-gray);
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -0.01em;
+ color: var(--hi-text);
+}
- .awaitingPayment {
- margin: 3px 0;
- color: #e09300;
- font-weight: 900;
- }
+.progressValue {
+ font-size: 14px;
+ font-weight: 800;
+}
- .product {
- color: #999;
- font-size: 1em;
- display: flex;
- align-items: center;
- gap: 5px;
- vertical-align: middle;
- }
- }
+.progressOf {
+ font-size: 12px;
+ opacity: 0.65;
+ font-weight: 600;
+}
- .actions {
- flex-grow: initial;
- }
- }
+.offlineBadge {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 5px 10px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ background: color-mix(in srgb, #e03131 12%, transparent);
+ color: #c92a2a;
}
-.infoModal {
- padding: 20px;
- padding-top: 0;
+.infoBtn {
+ color: var(--hi-color-gray-dark) !important;
+}
- .checkInCount {
- font-size: 1em;
- color: #999;
- margin-bottom: 10px;
+.content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ min-height: 0;
+ position: relative;
+}
- h4 {
- margin: 0;
- }
- }
+.occurrenceFilterBar {
+ padding: 8px 12px 0;
+ display: flex;
+ justify-content: flex-start;
+ flex-shrink: 0;
}
diff --git a/frontend/src/components/layouts/CheckIn/OccurrenceFilterPill.module.scss b/frontend/src/components/layouts/CheckIn/OccurrenceFilterPill.module.scss
new file mode 100644
index 0000000000..81fcad26e7
--- /dev/null
+++ b/frontend/src/components/layouts/CheckIn/OccurrenceFilterPill.module.scss
@@ -0,0 +1,167 @@
+.pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 10px;
+ border-radius: 999px;
+ border: 1px solid var(--hi-color-gray);
+ background: #fff;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--hi-color-gray-dark);
+ cursor: pointer;
+ max-width: 100%;
+ min-width: 0;
+
+ &:hover,
+ &:focus-visible {
+ border-color: var(--hi-primary);
+ color: var(--hi-primary);
+ }
+}
+
+.pillActive {
+ background: var(--hi-primary);
+ color: #fff;
+ border-color: var(--hi-primary);
+
+ &:hover,
+ &:focus-visible {
+ color: #fff;
+ }
+}
+
+.pillLabel {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ min-width: 0;
+}
+
+.sheetContent {
+ border-top-left-radius: 16px;
+ border-top-right-radius: 16px;
+ max-height: 80vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.sheetBody {
+ padding: 0 !important;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.handle {
+ width: 36px;
+ height: 4px;
+ background: var(--hi-color-gray);
+ border-radius: 2px;
+ margin: 8px auto 0;
+ flex-shrink: 0;
+}
+
+.sheetHeader {
+ font-size: 15px;
+ font-weight: 600;
+ padding: 16px 20px 8px;
+ flex-shrink: 0;
+}
+
+.searchWrap {
+ padding: 0 16px 8px;
+ flex-shrink: 0;
+}
+
+.options {
+ padding: 4px 12px 24px;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+.group {
+ margin-top: 10px;
+}
+
+.groupLabel {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ color: var(--hi-color-gray-dark);
+ padding: 0 12px 4px;
+}
+
+.option {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ width: 100%;
+ padding: 13px 12px;
+ border: none;
+ background: none;
+ border-radius: 8px;
+ font-size: 15px;
+ color: var(--hi-text);
+ text-align: left;
+ cursor: pointer;
+
+ &:hover,
+ &:focus-visible {
+ background: var(--hi-color-gray-light);
+ }
+}
+
+.optionSelected {
+ color: var(--hi-primary);
+ font-weight: 600;
+}
+
+.optionPast {
+ color: var(--hi-color-gray-dark);
+}
+
+.optionMain {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.optionToday {
+ font-weight: 700;
+}
+
+.todayBadge {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: var(--hi-primary);
+ color: #fff;
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ flex-shrink: 0;
+}
+
+.empty {
+ padding: 24px 12px;
+ text-align: center;
+ color: var(--hi-color-gray-dark);
+ font-size: 14px;
+}
+
+.truncationNote {
+ padding: 12px 12px 4px;
+ text-align: center;
+ color: var(--hi-color-gray-dark);
+ font-size: 12px;
+}
diff --git a/frontend/src/components/layouts/CheckIn/OccurrenceFilterPill.tsx b/frontend/src/components/layouts/CheckIn/OccurrenceFilterPill.tsx
new file mode 100644
index 0000000000..43e37e835a
--- /dev/null
+++ b/frontend/src/components/layouts/CheckIn/OccurrenceFilterPill.tsx
@@ -0,0 +1,143 @@
+import {useMemo, useState} from "react";
+import {Drawer, TextInput} from "@mantine/core";
+import {IconCalendar, IconCheck, IconChevronDown, IconSearch} from "@tabler/icons-react";
+import {t} from "@lingui/macro";
+import {EventOccurrence} from "../../../types.ts";
+import {
+ filterAndGroupOccurrences,
+ formatOccurrenceLabel,
+ isToday,
+} from "../../common/OccurrenceSelect/occurrenceSelectUtils.ts";
+import classes from "./OccurrenceFilterPill.module.scss";
+
+interface OccurrenceFilterPillProps {
+ occurrences: EventOccurrence[];
+ activeOccurrenceId: number | null;
+ timezone: string;
+ onSelect: (occurrenceId: number | null) => void;
+}
+
+const MAX_VISIBLE = 50;
+
+export const OccurrenceFilterPill = ({
+ occurrences,
+ activeOccurrenceId,
+ timezone: tz,
+ onSelect,
+ }: OccurrenceFilterPillProps) => {
+ const [sheetOpen, setSheetOpen] = useState(false);
+ const [search, setSearch] = useState('');
+
+ const activeOccurrence = useMemo(
+ () => activeOccurrenceId
+ ? occurrences.find(o => o.id === activeOccurrenceId) ?? null
+ : null,
+ [activeOccurrenceId, occurrences],
+ );
+
+ const {grouped, totalFiltered, totalAvailable, truncated} = useMemo(
+ () => filterAndGroupOccurrences(occurrences, {
+ search,
+ tz,
+ filterCancelled: true,
+ maxVisible: MAX_VISIBLE,
+ }),
+ [occurrences, search, tz],
+ );
+
+ const pillLabel = activeOccurrence ? formatOccurrenceLabel(activeOccurrence, tz) : t`All dates`;
+
+ const handleSelect = (occurrenceId: number | null) => {
+ onSelect(occurrenceId);
+ setSheetOpen(false);
+ setSearch('');
+ };
+
+ return (
+ <>
+ setSheetOpen(true)}
+ aria-label={t`Filter by date`}
+ aria-haspopup="dialog"
+ >
+
+ {pillLabel}
+
+
+
+ setSheetOpen(false)}
+ position="bottom"
+ size="auto"
+ padding={0}
+ withCloseButton={false}
+ // Bump above BottomNav (z-index 40 inside a position:fixed root).
+ zIndex={400}
+ overlayProps={{backgroundOpacity: 0.45, blur: 2}}
+ classNames={{content: classes.sheetContent, body: classes.sheetBody}}
+ >
+
+
{t`Filter by date`}
+
+
+ setSearch(e.currentTarget.value)}
+ placeholder={t`Search dates…`}
+ leftSection={}
+ size="sm"
+ autoFocus
+ />
+
+
+
+
handleSelect(null)}
+ >
+ {t`All dates`}
+ {activeOccurrenceId === null && }
+
+
+ {grouped.map(group => (
+
+
{group.label}
+ {group.items.map(occ => {
+ const selected = activeOccurrenceId === occ.id;
+ const todayOcc = isToday(occ, tz);
+ return (
+
handleSelect(occ.id as number)}
+ >
+
+ {todayOcc && {t`Today`} }
+ {formatOccurrenceLabel(occ, tz)}
+
+ {selected && }
+
+ );
+ })}
+
+ ))}
+
+ {totalFiltered === 0 && (
+
{t`No dates match your search`}
+ )}
+
+ {truncated && (
+
+ {t`Showing ${MAX_VISIBLE} of ${totalAvailable} dates. Type to search.`}
+
+ )}
+
+
+ >
+ );
+};
diff --git a/frontend/src/components/layouts/CheckIn/index.tsx b/frontend/src/components/layouts/CheckIn/index.tsx
index 357eb23169..681e7538bf 100644
--- a/frontend/src/components/layouts/CheckIn/index.tsx
+++ b/frontend/src/components/layouts/CheckIn/index.tsx
@@ -2,30 +2,37 @@ import {useParams} from "react-router";
import {useGetCheckInListPublic} from "../../../queries/useGetCheckInListPublic.ts";
import {useCallback, useEffect, useRef, useState} from "react";
import {useDebouncedValue, useDisclosure, useNetwork} from "@mantine/hooks";
-import {Attendee, QueryFilters, QueryFilterOperator} from "../../../types.ts";
-import {showError, showSuccess} from "../../../utilites/notifications.tsx";
+import {Attendee, EventOccurrenceStatus, EventType, QueryFilters, QueryFilterOperator} from "../../../types.ts";
+import {showError, showInfo, showSuccess, showSuccessWithUndo} from "../../../utilites/notifications.tsx";
import {t, Trans} from "@lingui/macro";
import {AxiosError} from "axios";
import classes from "./CheckIn.module.scss";
-import {ActionIcon, Modal} from "@mantine/core";
-import {SearchBar} from "../../common/SearchBar";
-import {IconInfoCircle, IconQrcode, IconVolume, IconVolumeOff} from "@tabler/icons-react";
-import {QRScannerComponent} from "../../common/AttendeeCheckInTable/QrScanner.tsx";
+import {ActionIcon} from "@mantine/core";
+import {IconCalendarEvent, IconInfoCircle, IconWifiOff} from "@tabler/icons-react";
+import {formatDateWithLocale} from "../../../utilites/dates.ts";
+import {useHaptics} from "../../../hooks/useHaptics.ts";
import {useGetCheckInListAttendees} from "../../../queries/useGetCheckInListAttendeesPublic.ts";
+import {useGetCheckInListStatsPublic} from "../../../queries/useGetCheckInListStatsPublic.ts";
import {useCreateCheckInPublic} from "../../../mutations/useCreateCheckInPublic.ts";
import {useDeleteCheckInPublic} from "../../../mutations/useDeleteCheckInPublic.ts";
import {NoResultsSplash} from "../../common/NoResultsSplash";
import {Countdown} from "../../common/Countdown";
import Truncate from "../../common/Truncate";
-import {Header} from "../../common/Header";
import {publicCheckInClient} from "../../../api/check-in.client.ts";
import {isSsr} from "../../../utilites/helpers.ts";
-import {AttendeeList} from "../../common/CheckIn/AttendeeList";
import {CheckInOptionsModal} from "../../common/CheckIn/CheckInOptionsModal";
-import {ScannerSelectionModal} from "../../common/CheckIn/ScannerSelectionModal";
import {CheckInInfoModal} from "../../common/CheckIn/CheckInInfoModal";
-import {HidScannerStatus} from "../../common/CheckIn/HidScannerStatus";
-import {Button} from "@mantine/core";
+import {CheckInDescriptionModal} from "../../common/CheckIn/CheckInDescriptionModal";
+import {BottomNav, CheckInTab} from "./BottomNav.tsx";
+import {ScanTab, ScanMode} from "./tabs/ScanTab.tsx";
+import {SearchTab} from "./tabs/SearchTab.tsx";
+import {StatsTab} from "./tabs/StatsTab.tsx";
+import {AttendeeDetailSheet} from "./AttendeeDetailSheet.tsx";
+import {OccurrenceFilterPill} from "./OccurrenceFilterPill.tsx";
+import {useCheckInOccurrenceFilter} from "../../../hooks/useCheckInOccurrenceFilter.ts";
+import {RecentScan, RecentScanStatus} from "./types.ts";
+
+const MAX_RECENT_SCANS = 20;
const CheckIn = () => {
const networkStatus = useNetwork();
@@ -34,41 +41,94 @@ const CheckIn = () => {
const checkInList = CheckInListQuery?.data?.data;
const event = checkInList?.event;
const eventSettings = event?.settings;
- const [searchQuery, setSearchQuery] = useState('');
+
+ const [activeTab, setActiveTab] = useState
(() => {
+ if (isSsr()) return "scan";
+ const hash = window.location.hash.replace("#", "");
+ if (hash === "search" || hash === "stats" || hash === "scan") return hash;
+ return "scan";
+ });
+ const [descriptionModalOpen, setDescriptionModalOpen] = useState(false);
+
+ useEffect(() => {
+ if (isSsr()) return;
+ if (window.location.hash !== `#${activeTab}`) {
+ window.history.replaceState(null, "", `#${activeTab}`);
+ }
+ }, [activeTab]);
+
+ useEffect(() => {
+ if (isSsr()) return;
+ const handleHashChange = () => {
+ const hash = window.location.hash.replace("#", "");
+ if (hash === "search" || hash === "stats" || hash === "scan") {
+ setActiveTab(hash);
+ }
+ };
+ window.addEventListener("hashchange", handleHashChange);
+ return () => window.removeEventListener("hashchange", handleHashChange);
+ }, []);
+ const [scanMode, setScanMode] = useState(() => {
+ if (isSsr()) return "usb";
+ const stored = localStorage.getItem("checkInScanMode");
+ return stored === "camera" ? "camera" : "usb";
+ });
+ const [recentScans, setRecentScans] = useState([]);
+ const [searchQuery, setSearchQuery] = useState("");
const [searchQueryDebounced] = useDebouncedValue(searchQuery, 200);
- const [qrScannerOpen, setQrScannerOpen] = useState(false);
- const [scannerSelectionOpen, setScannerSelectionOpen] = useState(false);
- const [hidScannerMode, setHidScannerMode] = useState(false);
- const [currentBarcode, setCurrentBarcode] = useState('');
+ const [currentBarcode, setCurrentBarcode] = useState("");
const [pageHasFocus, setPageHasFocus] = useState(true);
+
const barcodeTimeoutRef = useRef(null);
const isProcessingRef = useRef(false);
const processedBarcodesRef = useRef>(new Set());
const lastScanTimeRef = useRef(0);
const scanSuccessAudioRef = useRef(null);
const scanErrorAudioRef = useRef(null);
+
const [isSoundOn, setIsSoundOn] = useState(() => {
if (isSsr()) return true;
- // Use a unified sound setting for all scanners
const storedIsSoundOn = localStorage.getItem("scannerSoundOn");
return storedIsSoundOn === null ? true : JSON.parse(storedIsSoundOn);
});
const [selectedAttendee, setSelectedAttendee] = useState(null);
+ const [detailAttendeePublicId, setDetailAttendeePublicId] = useState(null);
const [checkInModalOpen, checkInModalHandlers] = useDisclosure(false);
+ const haptic = useHaptics();
const [infoModalOpen, infoModalHandlers] = useDisclosure(false, {
- onOpen: () => {
- CheckInListQuery.refetch();
- }
- }
- );
+ onOpen: () => {
+ CheckInListQuery.refetch();
+ },
+ });
const products = checkInList?.products;
+
+ // Prefer the list's unfiltered occurrences (includes past dates for
+ // reconciliation) over event.occurrences (future-only, customer-facing).
+ const pillOccurrences = checkInList?.event_occurrences ?? event?.occurrences;
+
+ const showOccurrenceFilter =
+ event?.type === EventType.RECURRING
+ && !checkInList?.event_occurrence_id
+ && (pillOccurrences?.length ?? 0) > 0;
+ const {occurrenceId: occurrenceFilter, setOccurrenceId: setOccurrenceFilter, didClearStale} =
+ useCheckInOccurrenceFilter(checkInListShortId, pillOccurrences);
+
+ useEffect(() => {
+ if (didClearStale) {
+ showInfo(t`Your saved date filter is no longer available — showing all dates.`);
+ }
+ }, [didClearStale]);
+
const queryFilters: QueryFilters = {
pageNumber: 1,
query: searchQueryDebounced,
perPage: 150,
filterFields: {
- status: {operator: QueryFilterOperator.Equals, value: 'ACTIVE'},
+ status: {operator: QueryFilterOperator.Equals, value: "ACTIVE"},
+ ...(showOccurrenceFilter && occurrenceFilter !== null
+ ? {event_occurrence_id: {operator: QueryFilterOperator.Equals, value: String(occurrenceFilter)}}
+ : {}),
},
};
@@ -80,63 +140,131 @@ const CheckIn = () => {
const attendees = attendeesQuery?.data?.data;
const checkInMutation = useCreateCheckInPublic(queryFilters);
const deleteCheckInMutation = useDeleteCheckInPublic(queryFilters);
- const areOfflinePaymentsEnabled = eventSettings?.payment_providers?.includes('OFFLINE');
+ const areOfflinePaymentsEnabled = eventSettings?.payment_providers?.includes("OFFLINE");
const allowOrdersAwaitingOfflinePaymentToCheckIn = areOfflinePaymentsEnabled
&& eventSettings?.allow_orders_awaiting_offline_payment_to_check_in;
+ const progressStatsQuery = useGetCheckInListStatsPublic(
+ checkInListShortId,
+ !!checkInList?.is_active && !checkInList?.is_expired && showOccurrenceFilter && occurrenceFilter !== null,
+ occurrenceFilter,
+ );
- // Save sound preference to localStorage
useEffect(() => {
if (!isSsr()) {
localStorage.setItem("scannerSoundOn", JSON.stringify(isSoundOn));
}
}, [isSoundOn]);
- // Sound helpers
+ useEffect(() => {
+ if (!isSsr()) {
+ localStorage.setItem("checkInScanMode", scanMode);
+ }
+ }, [scanMode]);
+
+ // Show description on first open; dismissal persists per list short_id.
+ useEffect(() => {
+ if (isSsr()) return;
+ if (!checkInListShortId) return;
+ if (!checkInList?.description) return;
+ const key = `checkInDescriptionSeen:${checkInListShortId}`;
+ if (!localStorage.getItem(key)) {
+ setDescriptionModalOpen(true);
+ }
+ }, [checkInListShortId, checkInList?.description]);
+
+ const dismissDescription = () => {
+ setDescriptionModalOpen(false);
+ if (!isSsr() && checkInListShortId) {
+ localStorage.setItem(`checkInDescriptionSeen:${checkInListShortId}`, "1");
+ }
+ };
+
const playSuccessSound = useCallback(() => {
if (isSoundOn && scanSuccessAudioRef.current) {
+ scanSuccessAudioRef.current.currentTime = 0;
scanSuccessAudioRef.current.play().catch(() => {
- // Ignore audio play errors (e.g., user hasn't interacted with page)
});
}
}, [isSoundOn]);
const playErrorSound = useCallback(() => {
if (isSoundOn && scanErrorAudioRef.current) {
+ scanErrorAudioRef.current.currentTime = 0;
scanErrorAudioRef.current.play().catch(() => {
- // Ignore audio play errors (e.g., user hasn't interacted with page)
});
}
}, [isSoundOn]);
- const playClickSound = useCallback(() => {
- if (isSoundOn && scanSuccessAudioRef.current) {
- // Use success sound for click feedback
- scanSuccessAudioRef.current.currentTime = 0; // Reset to start for quick successive clicks
- scanSuccessAudioRef.current.play().catch(() => {
- // Ignore audio play errors
- });
- }
- }, [isSoundOn]);
+ const pushRecentScan = useCallback((scan: Omit) => {
+ setRecentScans(prev => [
+ {...scan, id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, timestamp: Date.now()},
+ ...prev,
+ ].slice(0, MAX_RECENT_SCANS));
+ }, []);
- const handleCheckInAction = (attendee: Attendee, action: 'check-in' | 'check-in-and-mark-order-as-paid') => {
+ const recordScan = useCallback((attendee: Attendee | null, code: string, status: RecentScanStatus) => {
+ const name = attendee
+ ? `${attendee.first_name ?? ""} ${attendee.last_name ?? ""}`.trim() || code
+ : code;
+ pushRecentScan({name, code, status});
+ }, [pushRecentScan]);
+
+ const undoCheckIn = useCallback((attendee: Attendee, checkInShortId: string) => {
+ deleteCheckInMutation.mutate({
+ checkInListShortId: checkInListShortId,
+ checkInShortId: checkInShortId,
+ }, {
+ onSuccess: () => {
+ showSuccess(Check-in for {attendee.first_name} was undone );
+ playSuccessSound();
+ haptic("tap");
+ },
+ onError: () => {
+ showError(t`Unable to undo check-in`);
+ playErrorSound();
+ haptic("error");
+ },
+ });
+ }, [deleteCheckInMutation, checkInListShortId, playSuccessSound, playErrorSound, haptic]);
+
+ const handleCheckInAction = (attendee: Attendee, action: "check-in" | "check-in-and-mark-order-as-paid") => {
checkInMutation.mutate({
checkInListShortId: checkInListShortId,
attendeePublicId: attendee.public_id,
action: action,
}, {
- onSuccess: ({errors}) => {
+ onSuccess: (response) => {
+ const {errors, data} = response;
if (errors && errors[attendee.public_id]) {
showError(errors[attendee.public_id]);
playErrorSound();
+ haptic("error");
+ recordScan(attendee, attendee.public_id, "error");
return;
}
- showSuccess({attendee.first_name} checked in successfully );
playSuccessSound();
+ haptic("success");
+ recordScan(attendee, attendee.public_id, "success");
checkInModalHandlers.close();
setSelectedAttendee(null);
+
+ const createdCheckIn = data?.find((c: any) => c.attendee_id === attendee.id);
+ const message = {attendee.first_name} checked in ;
+
+ if (createdCheckIn) {
+ showSuccessWithUndo(
+ message,
+ () => undoCheckIn(attendee, String(createdCheckIn.short_id)),
+ {undoLabel: t`Undo`},
+ );
+ } else {
+ showSuccess(message);
+ }
},
onError: (error) => {
playErrorSound();
+ haptic("error");
+ recordScan(attendee, attendee.public_id, "error");
if (!networkStatus.online) {
showError(t`You are offline`);
return;
@@ -145,7 +273,7 @@ const CheckIn = () => {
if (error instanceof AxiosError) {
showError(error?.response?.data?.message || t`Unable to check in attendee`);
}
- }
+ },
});
};
@@ -158,9 +286,11 @@ const CheckIn = () => {
onSuccess: () => {
showSuccess({attendee.first_name} checked out successfully );
playSuccessSound();
+ haptic("tap");
},
onError: (error) => {
playErrorSound();
+ haptic("error");
if (!networkStatus.online) {
showError(t`You are offline`);
return;
@@ -171,12 +301,12 @@ const CheckIn = () => {
} else {
showError(t`Unable to check out attendee`);
}
- }
+ },
});
return;
}
- const isAttendeeAwaitingPayment = attendee.status === 'AWAITING_PAYMENT';
+ const isAttendeeAwaitingPayment = attendee.status === "AWAITING_PAYMENT";
if (allowOrdersAwaitingOfflinePaymentToCheckIn && isAttendeeAwaitingPayment) {
setSelectedAttendee(attendee);
@@ -189,16 +319,14 @@ const CheckIn = () => {
return;
}
- handleCheckInAction(attendee, 'check-in');
+ handleCheckInAction(attendee, "check-in");
};
const handleQrCheckIn = useCallback(async (attendeePublicId: string) => {
- // Prevent processing if already handling a request
if (isProcessingRef.current) {
return;
}
- // Check if this barcode was recently processed (within last 3 seconds)
const now = Date.now();
if (processedBarcodesRef.current.has(attendeePublicId) &&
now - lastScanTimeRef.current < 3000) {
@@ -210,7 +338,6 @@ const CheckIn = () => {
isProcessingRef.current = true;
lastScanTimeRef.current = now;
- // Find the attendee in the current list or fetch them
let attendee = attendees?.find(a => a.public_id === attendeePublicId);
if (!attendee) {
@@ -220,6 +347,7 @@ const CheckIn = () => {
} catch (error) {
showError(t`Unable to fetch attendee`);
playErrorSound();
+ recordScan(null, attendeePublicId, "error");
isProcessingRef.current = false;
return;
}
@@ -227,21 +355,23 @@ const CheckIn = () => {
if (!attendee) {
showError(t`Attendee not found`);
playErrorSound();
+ recordScan(null, attendeePublicId, "error");
isProcessingRef.current = false;
return;
}
}
- // Check if already checked in
if (attendee.check_in) {
showError({attendee.first_name} {attendee.last_name} is already checked in );
playErrorSound();
+ haptic("warning");
+ recordScan(attendee, attendeePublicId, "duplicate");
processedBarcodesRef.current.add(attendeePublicId);
isProcessingRef.current = false;
return;
}
- const isAttendeeAwaitingPayment = attendee.status === 'AWAITING_PAYMENT';
+ const isAttendeeAwaitingPayment = attendee.status === "AWAITING_PAYMENT";
if (allowOrdersAwaitingOfflinePaymentToCheckIn && isAttendeeAwaitingPayment) {
setSelectedAttendee(attendee);
@@ -253,78 +383,71 @@ const CheckIn = () => {
if (!allowOrdersAwaitingOfflinePaymentToCheckIn && isAttendeeAwaitingPayment) {
showError(t`You cannot check in attendees with unpaid orders. This setting can be changed in the event settings.`);
playErrorSound();
+ recordScan(attendee, attendeePublicId, "error");
isProcessingRef.current = false;
return;
}
- // Add to processed set before making the request
processedBarcodesRef.current.add(attendeePublicId);
-
- // Clear old entries from the set after 10 seconds
setTimeout(() => {
processedBarcodesRef.current.delete(attendeePublicId);
}, 10000);
- await handleCheckInAction(attendee, 'check-in');
+ await handleCheckInAction(attendee, "check-in");
isProcessingRef.current = false;
- }, [attendees, checkInListShortId, allowOrdersAwaitingOfflinePaymentToCheckIn, checkInModalHandlers, handleCheckInAction, playErrorSound]);
-
+ }, [attendees, checkInListShortId, allowOrdersAwaitingOfflinePaymentToCheckIn, checkInModalHandlers, handleCheckInAction, playErrorSound, recordScan]);
- // Process completed barcode
const processBarcode = useCallback((barcode: string) => {
- if (barcode.startsWith('A-') && barcode.length > 3) {
+ if (barcode.startsWith("A-") && barcode.length > 3) {
handleQrCheckIn(barcode);
}
}, [handleQrCheckIn]);
- // Track page focus
useEffect(() => {
const handleFocus = () => setPageHasFocus(true);
const handleBlur = () => setPageHasFocus(false);
- window.addEventListener('focus', handleFocus);
- window.addEventListener('blur', handleBlur);
+ window.addEventListener("focus", handleFocus);
+ window.addEventListener("blur", handleBlur);
return () => {
- window.removeEventListener('focus', handleFocus);
- window.removeEventListener('blur', handleBlur);
+ window.removeEventListener("focus", handleFocus);
+ window.removeEventListener("blur", handleBlur);
};
}, []);
- // Global keyboard listener for HID scanner mode
+ // HID scanner listener — only active on Scan tab in USB mode. Ignores key
+ // events while focus is in an input so manual search still works.
useEffect(() => {
- if (!hidScannerMode) return;
+ const usbListeningActive = activeTab === "scan" && scanMode === "usb";
+ if (!usbListeningActive) {
+ setCurrentBarcode("");
+ return;
+ }
const handleKeyPress = (e: KeyboardEvent) => {
- // Ignore if user is typing in an input field
- if (e.target instanceof HTMLInputElement ||
- e.target instanceof HTMLTextAreaElement) {
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
- if (e.key === 'Enter') {
- // Process the accumulated barcode on Enter
+ if (e.key === "Enter") {
if (currentBarcode.length > 0) {
processBarcode(currentBarcode);
- setCurrentBarcode('');
+ setCurrentBarcode("");
}
} else if (e.key.length === 1) {
- // Accumulate characters
setCurrentBarcode(prev => {
const newBarcode = prev + e.key;
- // Clear any existing timeout
if (barcodeTimeoutRef.current) {
clearTimeout(barcodeTimeoutRef.current);
}
- // Set timeout to clear barcode if no more input (scanner stopped)
barcodeTimeoutRef.current = setTimeout(() => {
- // Auto-process if it looks like a complete barcode
- if (newBarcode.startsWith('A-') && newBarcode.length > 3) {
+ if (newBarcode.startsWith("A-") && newBarcode.length > 3) {
processBarcode(newBarcode);
}
- setCurrentBarcode('');
+ setCurrentBarcode("");
}, 100);
return newBarcode;
@@ -332,21 +455,21 @@ const CheckIn = () => {
}
};
- window.addEventListener('keypress', handleKeyPress);
+ window.addEventListener("keypress", handleKeyPress);
return () => {
- window.removeEventListener('keypress', handleKeyPress);
+ window.removeEventListener("keypress", handleKeyPress);
if (barcodeTimeoutRef.current) {
clearTimeout(barcodeTimeoutRef.current);
}
};
- }, [hidScannerMode, currentBarcode, processBarcode]);
+ }, [activeTab, scanMode, currentBarcode, processBarcode]);
if (CheckInListQuery.error && (CheckInListQuery.error as any).response?.status === 404) {
return (
@@ -354,14 +477,14 @@ const CheckIn = () => {
>
)}
- />)
+ />);
}
if (checkInList?.is_expired) {
return (
@@ -371,21 +494,45 @@ const CheckIn = () => {
>
)}
- />)
+ />);
+ }
+
+ // Scoped lists become unusable when their occurrence is cancelled — the
+ // occurrence no longer exists for attendees to be checked in against.
+ if (checkInList?.event_occurrence?.status === EventOccurrenceStatus.CANCELLED) {
+ return (
+
+
+
+ This check-in list is scoped to a session that has been cancelled, so it can no longer be used for check-ins.
+
+
+
+
+ Create a new check-in list for an active session, or contact the organizer if you think this is a mistake.
+
+
+ >
+ )}
+ />);
}
if (checkInList && !checkInList?.is_active) {
return (
{t`This check-in list is not yet active and is not available for check-ins.`}
- Check-in list will activate in{' '}
+ Check-in list will activate in{" "}
{
>
)}
- />)
+ />);
}
+ // Filtered stats drive the progress chip when an occurrence is selected;
+ // otherwise the list's own totals (pre-computed server-side) are used.
+ const filteredStats = progressStatsQuery.data?.data;
+ const totalAttendees = filteredStats?.total_attendees ?? checkInList?.total_attendees ?? 0;
+ const checkedInCount = filteredStats?.checked_in_attendees ?? checkInList?.checked_in_attendees ?? 0;
+
return (
-
-
- {!networkStatus.online && (
-
- )}
-
infoModalHandlers.open()}
- >
-
-
- >
- )}/>
-
setHidScannerMode(false)}
- />
-
-
-
-
-
-
-
-
-
setSearchQuery(event.target.value)}
- onClear={() => setSearchQuery('')}
- placeholder={t`Search by name, order #, attendee # or email...`}
- />
- setScannerSelectionOpen(true)} leftSection={ }>
- {t`Scan`}
-
- setIsSoundOn(!isSoundOn)}
- >
- {isSoundOn ? : }
-
- setScannerSelectionOpen(true)}>
-
-
+
+
+
+
{t`Check-in`}
+
+
+ {/* Subtitle for scoped lists so staff know which session they're on. */}
+ {checkInList?.event_occurrence && event?.timezone && (
+
+
+
+ {formatDateWithLocale(checkInList.event_occurrence.start_date, 'shortDate', event.timezone)}
+ {' · '}
+ {formatDateWithLocale(checkInList.event_occurrence.start_date, 'timeOnly', event.timezone)}
+ {checkInList.event_occurrence.label ? ` · ${checkInList.event_occurrence.label}` : ''}
+
+
+ )}
-
-
+
+ {totalAttendees > 0 && (
+
+ {checkedInCount}
+ /{totalAttendees}
+
+ )}
+ {!networkStatus.online && (
+
+
+ {t`Offline`}
+
+ )}
+
infoModalHandlers.open()}
+ aria-label={t`Check-in list info`}
+ className={classes.infoBtn}
+ >
+
+
+
+
+
+ {/* Persistent across tabs. Hidden for scoped lists (header shows it) and single events. */}
+ {showOccurrenceFilter && event?.timezone && (
+
+
+
+ )}
+
+
+ {activeTab === "scan" && (
+ setIsSoundOn(!isSoundOn)}
+ onAttendeeScanned={handleQrCheckIn}
+ onOpenRecentScan={setDetailAttendeePublicId}
+ recentScans={recentScans}
+ />
+ )}
+ {activeTab === "search" && (
+ <>
+
+ >
+ )}
+ {activeTab === "stats" && (
+
+ )}
+
+
+
+
{
}}
onCheckIn={(action) => selectedAttendee && handleCheckInAction(selectedAttendee, action)}
/>
- setScannerSelectionOpen(false)}
- onCameraSelect={() => {
- setScannerSelectionOpen(false);
- setQrScannerOpen(true);
- }}
- onHidScannerSelect={() => {
- setScannerSelectionOpen(false);
- if (!hidScannerMode) {
- setHidScannerMode(true);
- }
- }}
- />
- {qrScannerOpen && (
- setQrScannerOpen(false)}
- fullScreen
- radius={0}
- transitionProps={{transition: 'fade', duration: 200}}
- padding={'none'}
- >
-
-
- setQrScannerOpen(false)}
- isSoundOn={isSoundOn}
- />
-
-
- )}
- {/* Audio elements for HID scanner sounds */}
+
+ setDetailAttendeePublicId(null)}
+ isActionPending={checkInMutation.isPending || deleteCheckInMutation.isPending}
+ onCheckInToggle={(detail) => {
+ const attendee: Attendee = {
+ id: detail.id,
+ product_id: detail.product_id,
+ product_price_id: 0,
+ order_id: detail.order?.id ?? 0,
+ status: detail.status,
+ first_name: detail.first_name,
+ last_name: detail.last_name,
+ email: detail.email,
+ public_id: detail.public_id,
+ short_id: detail.public_id,
+ check_in: detail.check_ins?.[0] ? {
+ id: detail.check_ins[0].id,
+ attendee_id: detail.check_ins[0].attendee_id,
+ check_in_list_id: detail.check_ins[0].check_in_list_id,
+ product_id: detail.product_id,
+ event_id: 0,
+ short_id: detail.check_ins[0].short_id,
+ order_id: detail.check_ins[0].order_id,
+ created_at: detail.check_ins[0].checked_in_at,
+ } : undefined,
+ };
+ handleCheckInToggle(attendee);
+ }}
+ />
);
-}
+};
export default CheckIn;
diff --git a/frontend/src/components/layouts/CheckIn/tabs/InlineCameraScanner.module.scss b/frontend/src/components/layouts/CheckIn/tabs/InlineCameraScanner.module.scss
new file mode 100644
index 0000000000..42f4d56cbd
--- /dev/null
+++ b/frontend/src/components/layouts/CheckIn/tabs/InlineCameraScanner.module.scss
@@ -0,0 +1,92 @@
+.scanner {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ background: #1a1625;
+ overflow: hidden;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.video {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.reticle {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ width: min(260px, 70%);
+ aspect-ratio: 1;
+ pointer-events: none;
+}
+
+.corner {
+ position: absolute;
+ width: 26px;
+ height: 26px;
+ border: 3px solid rgba(255, 255, 255, 0.9);
+}
+
+.tl {
+ top: 0;
+ left: 0;
+ border-right: none;
+ border-bottom: none;
+ border-top-left-radius: 8px;
+}
+
+.tr {
+ top: 0;
+ right: 0;
+ border-left: none;
+ border-bottom: none;
+ border-top-right-radius: 8px;
+}
+
+.bl {
+ bottom: 0;
+ left: 0;
+ border-right: none;
+ border-top: none;
+ border-bottom-left-radius: 8px;
+}
+
+.br {
+ bottom: 0;
+ right: 0;
+ border-left: none;
+ border-top: none;
+ border-bottom-right-radius: 8px;
+}
+
+.controls {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ display: flex;
+ gap: 6px;
+ z-index: 2;
+}
+
+.control {
+ background: rgba(255, 255, 255, 0.92) !important;
+ color: var(--hi-text) !important;
+ border: none !important;
+}
+
+.permissionDenied {
+ padding: 32px 20px;
+ text-align: center;
+ color: var(--hi-color-gray-dark);
+
+ p {
+ margin: 0;
+ font-size: 14px;
+ line-height: 1.5;
+ }
+}
diff --git a/frontend/src/components/layouts/CheckIn/tabs/InlineCameraScanner.tsx b/frontend/src/components/layouts/CheckIn/tabs/InlineCameraScanner.tsx
new file mode 100644
index 0000000000..4ed598f717
--- /dev/null
+++ b/frontend/src/components/layouts/CheckIn/tabs/InlineCameraScanner.tsx
@@ -0,0 +1,172 @@
+import {useEffect, useRef, useState} from "react";
+import QrScanner from "qr-scanner";
+import {useDebouncedValue} from "@mantine/hooks";
+import {t, Trans} from "@lingui/macro";
+import {Anchor, Button, Menu} from "@mantine/core";
+import {IconBulb, IconBulbOff, IconCameraRotate} from "@tabler/icons-react";
+import classes from "./InlineCameraScanner.module.scss";
+
+interface Props {
+ onAttendeeScanned: (attendeePublicId: string) => void;
+}
+
+export const InlineCameraScanner = ({onAttendeeScanned}: Props) => {
+ const videoRef = useRef
(null);
+ const qrScannerRef = useRef(null);
+ const [permissionDenied, setPermissionDenied] = useState(false);
+ const [isFlashAvailable, setIsFlashAvailable] = useState(false);
+ const [isFlashOn, setIsFlashOn] = useState(false);
+ const [cameraList, setCameraList] = useState();
+ const [processed, setProcessed] = useState([]);
+ const latestProcessedRef = useRef([]);
+
+ const [currentId, setCurrentId] = useState(null);
+ const [debouncedId] = useDebouncedValue(currentId, 1000);
+
+ useEffect(() => {
+ latestProcessedRef.current = processed;
+ }, [processed]);
+
+ const startScanner = async () => {
+ try {
+ await navigator.mediaDevices.getUserMedia({video: true});
+ if (videoRef.current) {
+ qrScannerRef.current = new QrScanner(videoRef.current, (result) => {
+ setCurrentId(result.data);
+ }, {
+ maxScansPerSecond: 1,
+ highlightScanRegion: false,
+ highlightCodeOutline: false,
+ });
+ qrScannerRef.current.start();
+ }
+ } catch (error) {
+ setPermissionDenied(true);
+ console.error(error);
+ }
+ };
+
+ const stopScanner = () => {
+ if (qrScannerRef.current) {
+ qrScannerRef.current.stop();
+ qrScannerRef.current.destroy();
+ qrScannerRef.current = null;
+ }
+ };
+
+ useEffect(() => {
+ startScanner().then(async () => {
+ if (qrScannerRef.current) {
+ const hasFlash = await qrScannerRef.current.hasFlash();
+ setIsFlashAvailable(hasFlash);
+ }
+ const cameras = await QrScanner.listCameras(true);
+ setCameraList(cameras);
+ });
+ // stopScanner reads qrScannerRef, which outlives the state captured at mount time.
+ // Don't gate on `permissionGranted` state — that was a closure bug that left the
+ // camera running after unmount.
+ return () => {
+ stopScanner();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ if (!debouncedId) return;
+ if (latestProcessedRef.current.includes(debouncedId)) return;
+ onAttendeeScanned(debouncedId);
+ setProcessed(prev => [...prev, debouncedId]);
+ setCurrentId(null);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [debouncedId]);
+
+ const handleFlashToggle = () => {
+ if (!qrScannerRef.current || !isFlashAvailable) return;
+ if (isFlashOn) {
+ qrScannerRef.current.turnFlashOff();
+ } else {
+ qrScannerRef.current.turnFlashOn();
+ }
+ setIsFlashOn(!isFlashOn);
+ };
+
+ const handleCameraSelect = (camera: QrScanner.Camera) => {
+ qrScannerRef.current?.setCamera(camera.id)
+ .then(async () => {
+ if (qrScannerRef.current) {
+ const hasFlash = await qrScannerRef.current.hasFlash();
+ setIsFlashAvailable(hasFlash);
+ }
+ });
+ };
+
+ const requestPermission = async () => {
+ setPermissionDenied(false);
+ await startScanner();
+ };
+
+ if (permissionDenied) {
+ return (
+
+
+
+ Camera permission was denied. Request permission again,
+ or grant this page camera access in your browser settings.
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {isFlashAvailable && (
+ : }
+ >
+ {isFlashOn ? t`Flash on` : t`Flash off`}
+
+ )}
+ {cameraList && cameraList.length > 1 && (
+
+
+ }
+ >
+ {t`Camera`}
+
+
+
+ {t`Select camera`}
+ {cameraList.map((camera, index) => (
+ handleCameraSelect(camera)}>
+ {camera.label}
+
+ ))}
+
+
+ )}
+
+
+ );
+};
diff --git a/frontend/src/components/layouts/CheckIn/tabs/ScanTab.module.scss b/frontend/src/components/layouts/CheckIn/tabs/ScanTab.module.scss
new file mode 100644
index 0000000000..d38abcb88c
--- /dev/null
+++ b/frontend/src/components/layouts/CheckIn/tabs/ScanTab.module.scss
@@ -0,0 +1,326 @@
+@use "../../../../styles/mixins";
+
+.wrap {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ background: var(--hi-color-gray);
+ min-height: 0;
+ overflow: hidden;
+}
+
+.scanArea {
+ margin: 14px 16px 0;
+ flex: 0 0 auto;
+ height: min(46vh, 380px);
+ border-radius: 14px;
+ overflow: hidden;
+ background: #1a1625;
+ border: 1px solid var(--hi-color-gray-2);
+ position: relative;
+}
+
+.usbPane {
+ height: 100%;
+ width: 100%;
+ background: #fff;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ gap: 14px;
+}
+
+.usbStatusRow {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 14px;
+ border-radius: 999px;
+ background: var(--hi-color-gray);
+}
+
+.statusDot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ display: inline-block;
+}
+
+.dotActive {
+ background: var(--hi-color-money-green);
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--hi-color-money-green) 25%, transparent);
+}
+
+.dotPaused {
+ background: #f59f00;
+}
+
+.statusText {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--hi-text);
+ letter-spacing: 0.02em;
+}
+
+.usbInstruction {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--hi-text);
+ text-align: center;
+ letter-spacing: -0.01em;
+ max-width: 340px;
+}
+
+.buffer {
+ margin-top: 4px;
+ min-width: 240px;
+ max-width: 100%;
+ padding: 10px 14px;
+ border-radius: 10px;
+ background: var(--hi-color-gray);
+ border: 1px dashed var(--hi-color-gray-2);
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 14px;
+ letter-spacing: 0.08em;
+ color: var(--hi-text);
+ text-align: center;
+ min-height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.bufferText {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 100%;
+}
+
+.bufferPlaceholder {
+ color: var(--hi-color-gray-dark);
+ font-family: inherit;
+ letter-spacing: 0.02em;
+ font-style: italic;
+}
+
+.toolbar {
+ margin: 12px 16px 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.modeToggle {
+ display: flex;
+ background: #fff;
+ border: 1px solid var(--hi-color-gray-2);
+ border-radius: 999px;
+ padding: 3px;
+ flex: 0 0 auto;
+}
+
+.modeBtn {
+ border: none;
+ background: transparent;
+ color: var(--hi-color-gray-dark);
+ padding: 7px 14px;
+ border-radius: 999px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-family: inherit;
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 140ms ease, color 140ms ease, transform 120ms ease;
+
+ &:active {
+ transform: scale(0.96);
+ }
+}
+
+.modeActive {
+ background: var(--hi-primary);
+ color: #fff;
+}
+
+.soundBtn {
+ margin-left: auto;
+}
+
+.recentSection {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ padding: 14px 16px 0;
+}
+
+.sectionHeader {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--hi-color-gray-dark);
+ margin-bottom: 8px;
+ padding-left: 4px;
+}
+
+.empty {
+ font-size: 13px;
+ color: var(--hi-color-gray-dark);
+ padding: 14px;
+ text-align: center;
+ background: #fff;
+ border: 1px dashed var(--hi-color-gray-2);
+ border-radius: 12px;
+ flex-shrink: 0;
+}
+
+.recentList {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ // Reserve space so the last row sits above the floating bottom nav; without this
+ // the list's scroll floor sits behind the pill and the final item is unreachable.
+ padding-bottom: calc(110px + env(safe-area-inset-bottom));
+ @include mixins.scrollbar();
+}
+
+.recentItem {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ background: #fff;
+ border: 1px solid var(--hi-color-gray-2);
+ border-radius: 12px;
+ animation: recent-in 220ms ease-out;
+ width: 100%;
+ text-align: left;
+ font-family: inherit;
+ cursor: pointer;
+ transition: border-color 140ms ease, transform 120ms ease;
+
+ &:hover:not(:disabled) {
+ border-color: var(--hi-color-gray-dark);
+ }
+
+ &:active:not(:disabled) {
+ transform: scale(0.99);
+ }
+
+ &:disabled {
+ cursor: default;
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--hi-primary);
+ outline-offset: 2px;
+ }
+}
+
+@keyframes recent-in {
+ from {
+ opacity: 0;
+ transform: translateY(6px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.recentDuplicate {
+ background: color-mix(in srgb, #f59f00 8%, #fff);
+ border-color: color-mix(in srgb, #f59f00 30%, transparent);
+}
+
+.recentError {
+ background: color-mix(in srgb, #e03131 8%, #fff);
+ border-color: color-mix(in srgb, #e03131 30%, transparent);
+}
+
+.indicator {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ color: #fff;
+}
+
+.indicator_success {
+ background: var(--hi-color-money-green);
+}
+
+.indicator_duplicate {
+ background: #f59f00;
+}
+
+.indicator_error {
+ background: #e03131;
+}
+
+.recentMain {
+ flex: 1;
+ min-width: 0;
+}
+
+.recentName {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--hi-text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ letter-spacing: -0.01em;
+}
+
+.recentMeta {
+ margin-top: 2px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ color: var(--hi-color-gray-dark);
+}
+
+.recentCode {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ color: var(--hi-primary);
+ font-weight: 600;
+}
+
+.dot {
+ opacity: 0.5;
+}
+
+.tagWarn,
+.tagError {
+ font-size: 10px;
+ font-weight: 700;
+ padding: 3px 8px;
+ border-radius: 999px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ flex-shrink: 0;
+}
+
+.tagWarn {
+ background: color-mix(in srgb, #f59f00 16%, transparent);
+ color: #a87600;
+}
+
+.tagError {
+ background: color-mix(in srgb, #e03131 16%, transparent);
+ color: #c92a2a;
+}
diff --git a/frontend/src/components/layouts/CheckIn/tabs/ScanTab.tsx b/frontend/src/components/layouts/CheckIn/tabs/ScanTab.tsx
new file mode 100644
index 0000000000..0ed961992d
--- /dev/null
+++ b/frontend/src/components/layouts/CheckIn/tabs/ScanTab.tsx
@@ -0,0 +1,147 @@
+import {t, Trans} from "@lingui/macro";
+import {IconCamera, IconCheck, IconScan, IconVolume, IconVolumeOff, IconX} from "@tabler/icons-react";
+import {ActionIcon} from "@mantine/core";
+import {InlineCameraScanner} from "./InlineCameraScanner.tsx";
+import classes from "./ScanTab.module.scss";
+import {RecentScan} from "../types.ts";
+
+export type ScanMode = "usb" | "camera";
+
+interface ScanTabProps {
+ mode: ScanMode;
+ onModeChange: (mode: ScanMode) => void;
+ hidPageHasFocus: boolean;
+ hidBuffer: string;
+ isSoundOn: boolean;
+ onSoundToggle: () => void;
+ onAttendeeScanned: (attendeePublicId: string) => void;
+ onOpenRecentScan?: (attendeePublicId: string) => void;
+ recentScans: RecentScan[];
+}
+
+const relativeTime = (timestamp: number) => {
+ const diffSec = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
+ if (diffSec < 5) return t`just now`;
+ if (diffSec < 60) return t`${diffSec}s ago`;
+ const diffMin = Math.floor(diffSec / 60);
+ if (diffMin < 60) return t`${diffMin}m ago`;
+ const diffHr = Math.floor(diffMin / 60);
+ return t`${diffHr}h ago`;
+};
+
+export const ScanTab = ({
+ mode,
+ onModeChange,
+ hidPageHasFocus,
+ hidBuffer,
+ isSoundOn,
+ onSoundToggle,
+ onAttendeeScanned,
+ onOpenRecentScan,
+ recentScans,
+ }: ScanTabProps) => {
+ return (
+
+
+ {mode === "camera" ? (
+
+ ) : (
+
+
+
+
+ {hidPageHasFocus ? t`USB scanner listening` : t`USB scanner paused`}
+
+
+
+ {hidPageHasFocus
+ ? t`Scan a ticket to check in an attendee`
+ : t`Tap this screen to resume scanning`}
+
+
+ {hidBuffer
+ ? {hidBuffer}
+ : {t`Waiting for scan…`} }
+
+
+ )}
+
+
+
+
+ onModeChange("usb")}
+ >
+
+ {t`USB`}
+
+ onModeChange("camera")}
+ >
+
+ {t`Camera`}
+
+
+
+ {isSoundOn ? : }
+
+
+
+
+
{t`Recent check-ins`}
+ {recentScans.length === 0 ? (
+
+ Scanned tickets will appear here
+
+ ) : (
+
+ {recentScans.slice(0, 8).map(scan => (
+
onOpenRecentScan?.(scan.code)}
+ disabled={!onOpenRecentScan || !scan.code.startsWith("A-")}
+ >
+
+ {scan.status === "success" && }
+ {scan.status === "duplicate" && }
+ {scan.status === "error" && }
+
+
+
{scan.name}
+
+ {scan.code}
+ ·
+ {relativeTime(scan.timestamp)}
+
+
+ {scan.status === "duplicate" && (
+ {t`Already in`}
+ )}
+ {scan.status === "error" && (
+ {t`Failed`}
+ )}
+
+ ))}
+
+ )}
+
+
+ );
+};
diff --git a/frontend/src/components/layouts/CheckIn/tabs/SearchTab.module.scss b/frontend/src/components/layouts/CheckIn/tabs/SearchTab.module.scss
new file mode 100644
index 0000000000..8841518d6c
--- /dev/null
+++ b/frontend/src/components/layouts/CheckIn/tabs/SearchTab.module.scss
@@ -0,0 +1,355 @@
+@use "../../../../styles/mixins";
+
+.wrap {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ background: var(--hi-color-gray);
+ min-height: 0;
+ overflow: hidden;
+}
+
+.searchField {
+ margin: 14px 16px 10px;
+ background: #fff;
+ border: 1.5px solid var(--hi-color-gray-2);
+ border-radius: 14px;
+ padding: 12px 14px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ transition: border-color 160ms ease, box-shadow 160ms ease;
+
+ &:focus-within {
+ border-color: var(--mantine-color-primary-8);
+ box-shadow: 0 0 0 3px var(--mantine-color-primary-1);
+ }
+
+ input {
+ flex: 1;
+ border: none;
+ outline: none;
+ background: transparent;
+ font-family: inherit;
+ font-size: 15px;
+ color: var(--hi-text);
+
+ &::placeholder {
+ color: var(--hi-color-gray-dark);
+ }
+ }
+
+ svg {
+ color: var(--hi-color-gray-dark);
+ flex-shrink: 0;
+ }
+}
+
+.clearBtn {
+ border: none;
+ background: transparent;
+ width: 24px;
+ height: 24px;
+ border-radius: 6px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ color: var(--hi-color-gray-dark);
+ flex-shrink: 0;
+ transition: color 140ms ease;
+
+ &:hover {
+ color: var(--hi-text);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--hi-primary);
+ outline-offset: 1px;
+ }
+}
+
+.statRow {
+ margin: 0 16px 10px;
+ display: flex;
+ gap: 8px;
+}
+
+.statCell {
+ flex: 1;
+ background: #fff;
+ border: 1px solid var(--hi-color-gray-2);
+ border-radius: 12px;
+ padding: 10px 12px;
+}
+
+.statValue {
+ font-size: 20px;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ color: var(--hi-text);
+ line-height: 1.1;
+}
+
+.statOf {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--hi-color-gray-dark);
+ margin-left: 2px;
+}
+
+.statLabel {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--hi-color-gray-dark);
+ margin-top: 2px;
+}
+
+.chipRow {
+ padding: 0 16px 12px;
+ display: flex;
+ gap: 8px;
+ overflow-x: auto;
+ scrollbar-width: none;
+ flex-shrink: 0;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+}
+
+.chip {
+ flex-shrink: 0;
+ padding: 7px 12px;
+ border-radius: 999px;
+ border: 1px solid var(--hi-color-gray-2);
+ background: #fff;
+ color: var(--hi-text);
+ font-family: inherit;
+ font-size: 13px;
+ font-weight: 600;
+ white-space: nowrap;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ transition: all 160ms ease;
+
+ &:active {
+ transform: scale(0.96);
+ }
+}
+
+.chipActive {
+ background: var(--hi-gradient);
+ border-color: transparent;
+ color: #fff;
+
+ .chipCount {
+ background: rgba(255, 255, 255, 0.22);
+ color: #fff;
+ }
+}
+
+.chipCount {
+ background: var(--hi-color-gray);
+ color: var(--hi-color-gray-dark);
+ padding: 1px 6px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 700;
+ min-width: 18px;
+ text-align: center;
+}
+
+.list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0 16px calc(120px + env(safe-area-inset-bottom));
+ @include mixins.scrollbar();
+}
+
+.loader {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 60px 0;
+}
+
+.empty {
+ text-align: center;
+ padding: 40px 20px;
+ color: var(--hi-color-gray-dark);
+
+ p {
+ margin: 0 0 4px;
+ font-size: 14px;
+ }
+}
+
+.emptySub {
+ font-size: 12px !important;
+ opacity: 0.8;
+}
+
+.row {
+ background: #fff;
+ border: 1px solid var(--hi-color-gray-2);
+ border-radius: 14px;
+ padding: 12px;
+ margin-bottom: 8px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ transition: transform 120ms ease, border-color 140ms ease;
+ text-align: left;
+ width: 100%;
+ font-family: inherit;
+ cursor: pointer;
+
+ &:active {
+ transform: scale(0.99);
+ }
+
+ &:hover {
+ border-color: var(--hi-color-gray-dark);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--hi-primary);
+ outline-offset: 2px;
+ }
+}
+
+.rowDone {
+ background: color-mix(in srgb, var(--hi-color-money-green) 8%, #fff);
+ border-color: color-mix(in srgb, var(--hi-color-money-green) 28%, transparent);
+}
+
+.rowCancelled {
+ opacity: 0.6;
+}
+
+.avatar {
+ width: 42px;
+ height: 42px;
+ border-radius: 50%;
+ background: var(--mantine-color-primary-1, #dcd2f0);
+ color: var(--mantine-color-primary-9, #33205a);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 700;
+ font-size: 14px;
+ flex-shrink: 0;
+ letter-spacing: 0.02em;
+}
+
+.avatarDone {
+ background: var(--hi-color-money-green);
+ color: #fff;
+}
+
+.rowMain {
+ flex: 1;
+ min-width: 0;
+}
+
+.rowName {
+ font-size: 15px;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+ color: var(--hi-text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.rowMeta {
+ font-size: 12px;
+ color: var(--hi-color-gray-dark);
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ margin-top: 2px;
+ white-space: nowrap;
+ overflow: hidden;
+}
+
+.rowCode {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-weight: 600;
+ color: var(--hi-primary);
+ letter-spacing: 0.04em;
+}
+
+.rowProduct {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ // Flex children need min-width:0 to let ellipsis kick in instead of overflowing.
+ min-width: 0;
+}
+
+.rowOccurrence {
+ margin-top: 4px;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: var(--hi-primary-light, rgba(99, 91, 255, 0.08));
+ color: var(--hi-primary);
+ font-size: 11px;
+ font-weight: 600;
+ max-width: 100%;
+ overflow: hidden;
+
+ > span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ min-width: 0;
+ }
+}
+
+.dot {
+ opacity: 0.5;
+}
+
+.rowTags {
+ margin-top: 4px;
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.tagWarn {
+ font-size: 10px;
+ font-weight: 700;
+ padding: 3px 8px;
+ border-radius: 999px;
+ background: color-mix(in srgb, #f59f00 16%, transparent);
+ color: #a87600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.tagDanger {
+ font-size: 10px;
+ font-weight: 700;
+ padding: 3px 8px;
+ border-radius: 999px;
+ background: color-mix(in srgb, #e03131 16%, transparent);
+ color: #c92a2a;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.rowAction {
+ flex-shrink: 0;
+}
diff --git a/frontend/src/components/layouts/CheckIn/tabs/SearchTab.tsx b/frontend/src/components/layouts/CheckIn/tabs/SearchTab.tsx
new file mode 100644
index 0000000000..3ec067febd
--- /dev/null
+++ b/frontend/src/components/layouts/CheckIn/tabs/SearchTab.tsx
@@ -0,0 +1,255 @@
+import {useMemo, useState} from "react";
+import {t, Trans} from "@lingui/macro";
+import {IconCalendarEvent, IconCheck, IconSearch, IconTicket, IconX} from "@tabler/icons-react";
+import {Button, Loader} from "@mantine/core";
+import {Attendee, EventType} from "../../../../types.ts";
+import {formatDateWithLocale} from "../../../../utilites/dates.ts";
+import classes from "./SearchTab.module.scss";
+
+type FilterKey = "all" | "pending" | "checked_in" | "awaiting_payment";
+
+interface SearchTabProps {
+ attendees: Attendee[] | undefined;
+ products: { id: number; title: string }[] | undefined;
+ searchQuery: string;
+ onSearchChange: (value: string) => void;
+ onCheckInToggle: (attendee: Attendee) => void;
+ onOpenDetail: (attendeePublicId: string) => void;
+ isLoading: boolean;
+ isCheckInPending: boolean;
+ isDeletePending: boolean;
+ allowOrdersAwaitingOfflinePaymentToCheckIn: boolean;
+ /**
+ * Shown next to the product on each row when the event is recurring and
+ * the check-in list covers multiple occurrences. Suppressed on single
+ * events (the hidden implicit occurrence would just be noise).
+ */
+ eventType?: EventType;
+ timezone?: string;
+ showRowOccurrences?: boolean;
+}
+
+const getInitials = (attendee: Attendee) =>
+ `${(attendee.first_name || "").charAt(0)}${(attendee.last_name || "").charAt(0)}`.toUpperCase() || "?";
+
+export const SearchTab = ({
+ attendees,
+ products,
+ searchQuery,
+ onSearchChange,
+ onCheckInToggle,
+ onOpenDetail,
+ isLoading,
+ isCheckInPending,
+ isDeletePending,
+ allowOrdersAwaitingOfflinePaymentToCheckIn,
+ eventType,
+ timezone,
+ showRowOccurrences,
+ }: SearchTabProps) => {
+ const showOccurrence = showRowOccurrences && eventType === EventType.RECURRING && !!timezone;
+ const [filter, setFilter] = useState("all");
+
+ const stats = useMemo(() => {
+ if (!attendees) return {total: 0, checkedIn: 0, pending: 0, awaiting: 0};
+ return attendees.reduce(
+ (acc, a) => {
+ acc.total += 1;
+ if (a.check_in) acc.checkedIn += 1;
+ else if (a.status === "AWAITING_PAYMENT") acc.awaiting += 1;
+ else if (a.status === "ACTIVE") acc.pending += 1;
+ return acc;
+ },
+ {total: 0, checkedIn: 0, pending: 0, awaiting: 0}
+ );
+ }, [attendees]);
+
+ const filtered = useMemo(() => {
+ if (!attendees) return [];
+ return attendees.filter(a => {
+ if (filter === "checked_in") return !!a.check_in;
+ if (filter === "pending") return !a.check_in && a.status === "ACTIVE";
+ if (filter === "awaiting_payment") return a.status === "AWAITING_PAYMENT";
+ return true;
+ });
+ }, [attendees, filter]);
+
+ const getButtonColor = (attendee: Attendee) => {
+ if (attendee.check_in || attendee.status === "CANCELLED") return "red";
+ if (attendee.status === "AWAITING_PAYMENT" && !allowOrdersAwaitingOfflinePaymentToCheckIn) return "gray";
+ return "teal";
+ };
+
+ const checkInLabel = (attendee: Attendee) => {
+ if (!allowOrdersAwaitingOfflinePaymentToCheckIn && attendee.status === "AWAITING_PAYMENT") return t`Can't check in`;
+ if (attendee.status === "CANCELLED") return t`Cancelled`;
+ if (attendee.check_in) return t`Check out`;
+ return t`Check in`;
+ };
+
+ return (
+
+
+
+ onSearchChange(e.target.value)}
+ placeholder={t`Search by name, order #, ticket # or email`}
+ aria-label={t`Search attendees`}
+ />
+ {searchQuery && (
+ onSearchChange("")}
+ >
+
+
+ )}
+
+
+
+
+
{stats.checkedIn}/{stats.total}
+
{t`Checked in`}
+
+
+
{stats.pending}
+
{t`Remaining`}
+
+ {stats.awaiting > 0 && (
+
+
{stats.awaiting}
+
{t`Awaiting pay`}
+
+ )}
+
+
+
+ setFilter("all")}
+ >
+ {t`All`} {stats.total}
+
+ setFilter("pending")}
+ >
+ {t`Remaining`} {stats.pending}
+
+ setFilter("checked_in")}
+ >
+ {t`Checked in`} {stats.checkedIn}
+
+ {stats.awaiting > 0 && (
+ setFilter("awaiting_payment")}
+ >
+ {t`Awaiting`} {stats.awaiting}
+
+ )}
+
+
+
+ {isLoading || !attendees || !products ? (
+
+
+
+ ) : filtered.length === 0 ? (
+
+
No attendees to show
+ {searchQuery && (
+
+ Try a different search term or filter
+
+ )}
+
+ ) : (
+ filtered.map(attendee => {
+ const product = products.find(p => p.id === attendee.product_id);
+ const isCheckedIn = !!attendee.check_in;
+ const isAwaiting = attendee.status === "AWAITING_PAYMENT";
+ const isCancelled = attendee.status === "CANCELLED";
+ return (
+
onOpenDetail(attendee.public_id)}
+ aria-label={t`View details for ${attendee.first_name} ${attendee.last_name}`}
+ >
+
+ {isCheckedIn ? : getInitials(attendee)}
+
+
+
+ {attendee.first_name} {attendee.last_name}
+
+
+ {attendee.public_id}
+ {product && (
+ <>
+ •
+
+ {product.title}
+
+ >
+ )}
+
+ {showOccurrence && attendee.event_occurrence && (
+
+
+
+ {formatDateWithLocale(attendee.event_occurrence.start_date, 'shortDate', timezone!)}
+ {' · '}
+ {formatDateWithLocale(attendee.event_occurrence.start_date, 'timeOnly', timezone!)}
+ {attendee.event_occurrence.label ? ` · ${attendee.event_occurrence.label}` : ''}
+
+
+ )}
+ {(isAwaiting || isCancelled) && (
+
+ {isAwaiting && {t`Awaiting payment`} }
+ {isCancelled && {t`Cancelled`} }
+
+ )}
+
+ {
+ e.stopPropagation();
+ onCheckInToggle(attendee);
+ }}
+ className={classes.rowAction}
+ >
+ {checkInLabel(attendee)}
+
+
+ );
+ })
+ )}
+
+
+ );
+};
diff --git a/frontend/src/components/layouts/CheckIn/tabs/StatsTab.module.scss b/frontend/src/components/layouts/CheckIn/tabs/StatsTab.module.scss
new file mode 100644
index 0000000000..147b5ec3dd
--- /dev/null
+++ b/frontend/src/components/layouts/CheckIn/tabs/StatsTab.module.scss
@@ -0,0 +1,322 @@
+@use "../../../../styles/mixins";
+
+.wrap {
+ flex: 1;
+ background: var(--hi-color-gray);
+ overflow-y: auto;
+ padding: 16px 16px calc(120px + env(safe-area-inset-bottom));
+ @include mixins.scrollbar();
+ min-height: 0;
+}
+
+.hero {
+ background: var(--hi-gradient);
+ border-radius: 20px;
+ padding: 22px 20px;
+ color: #fff;
+ margin-bottom: 14px;
+ box-shadow: 0 10px 30px rgba(64, 41, 108, 0.28);
+}
+
+.heroCount {
+ display: flex;
+ align-items: baseline;
+ gap: 6px;
+ letter-spacing: -0.03em;
+}
+
+.heroBig {
+ font-size: 56px;
+ font-weight: 800;
+ line-height: 1;
+}
+
+.heroOf {
+ font-size: 24px;
+ font-weight: 600;
+ opacity: 0.75;
+}
+
+.heroLabel {
+ margin-top: 4px;
+ font-size: 14px;
+ opacity: 0.85;
+ letter-spacing: 0.02em;
+}
+
+.heroProgress {
+ margin-top: 16px;
+ background: rgba(255, 255, 255, 0.18) !important;
+
+ :global(.mantine-Progress-section) {
+ background: #fff !important;
+ }
+}
+
+.heroPercent {
+ margin-top: 10px;
+ font-size: 13px;
+ opacity: 0.85;
+
+ strong {
+ font-size: 18px;
+ font-weight: 800;
+ }
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 8px;
+ margin-bottom: 4px;
+}
+
+.card {
+ background: #fff;
+ border: 1px solid var(--hi-color-gray-2);
+ border-radius: 14px;
+ padding: 12px;
+}
+
+.cardIcon {
+ width: 26px;
+ height: 26px;
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--hi-color-money-green) 16%, transparent);
+ color: var(--hi-color-money-green);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 6px;
+}
+
+.cardIconWarn {
+ background: color-mix(in srgb, #f59f00 16%, transparent);
+ color: #a87600;
+}
+
+.cardIconMuted {
+ background: var(--hi-color-gray);
+ color: var(--hi-color-gray-dark);
+}
+
+.cardLabel {
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--hi-color-gray-dark);
+}
+
+.cardValue {
+ font-size: 24px;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ color: var(--hi-text);
+ margin-top: 2px;
+}
+
+.throughput {
+ margin-top: 10px;
+ padding: 12px 14px;
+ background: #fff;
+ border: 1px solid var(--hi-color-gray-2);
+ border-radius: 14px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.throughputIcon {
+ width: 36px;
+ height: 36px;
+ border-radius: 10px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: color-mix(in srgb, var(--mantine-color-primary-8) 12%, transparent);
+ color: var(--mantine-color-primary-8);
+ flex-shrink: 0;
+}
+
+.throughputBody {
+ flex: 1;
+ min-width: 0;
+}
+
+.throughputLabel {
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--hi-color-gray-dark);
+}
+
+.throughputValue {
+ font-size: 14px;
+ color: var(--hi-text);
+ font-variant-numeric: tabular-nums;
+
+ strong {
+ font-size: 20px;
+ font-weight: 800;
+ letter-spacing: -0.01em;
+ margin-right: 2px;
+ }
+}
+
+.throughputUnit {
+ font-weight: 600;
+ color: var(--hi-color-gray-dark);
+}
+
+.throughputRate {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ line-height: 1.1;
+
+ strong {
+ font-size: 20px;
+ font-weight: 800;
+ color: var(--hi-primary);
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -0.01em;
+ }
+
+ span {
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--hi-color-gray-dark);
+ margin-top: 2px;
+ }
+}
+
+.sectionLabel {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--hi-color-gray-dark);
+ margin: 18px 0 8px;
+ padding-left: 4px;
+}
+
+.productList {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.productRow {
+ background: #fff;
+ border: 1px solid var(--hi-color-gray-2);
+ border-radius: 14px;
+ padding: 12px 14px;
+}
+
+.productTop {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+}
+
+.productName {
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--hi-text);
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.productCount {
+ font-size: 14px;
+ font-weight: 800;
+ color: var(--hi-text);
+ letter-spacing: -0.01em;
+ flex-shrink: 0;
+}
+
+.productOf {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--hi-color-gray-dark);
+ margin-left: 2px;
+}
+
+.empty {
+ background: #fff;
+ border: 1px dashed var(--hi-color-gray-2);
+ border-radius: 14px;
+ padding: 20px;
+ text-align: center;
+ color: var(--hi-color-gray-dark);
+ font-size: 13px;
+}
+
+.recentList {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.recent {
+ background: #fff;
+ border: 1px solid var(--hi-color-gray-2);
+ border-radius: 12px;
+ padding: 10px 12px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.recentCheck {
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: var(--hi-color-money-green);
+ color: #fff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.recentMain {
+ flex: 1;
+ min-width: 0;
+}
+
+.recentName {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--hi-text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ letter-spacing: -0.01em;
+}
+
+.recentCode {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 11px;
+ color: var(--hi-color-gray-dark);
+ margin-top: 1px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.recentTime {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--hi-color-gray-dark);
+ flex-shrink: 0;
+}
diff --git a/frontend/src/components/layouts/CheckIn/tabs/StatsTab.tsx b/frontend/src/components/layouts/CheckIn/tabs/StatsTab.tsx
new file mode 100644
index 0000000000..1b6c6ec8d5
--- /dev/null
+++ b/frontend/src/components/layouts/CheckIn/tabs/StatsTab.tsx
@@ -0,0 +1,186 @@
+import {useEffect, useMemo, useState} from "react";
+import {t, Trans} from "@lingui/macro";
+import {IconBolt, IconCheck, IconClock, IconTicket, IconUsers} from "@tabler/icons-react";
+import {Progress} from "@mantine/core";
+import {IdParam} from "../../../../types.ts";
+import {useGetCheckInListStatsPublic} from "../../../../queries/useGetCheckInListStatsPublic.ts";
+import classes from "./StatsTab.module.scss";
+
+interface StatsTabProps {
+ checkInListShortId: IdParam;
+ enabled: boolean;
+ /**
+ * When the staff has narrowed an unscoped check-in list via the filter pill,
+ * pass the selected occurrence id so the stats endpoint returns counts for
+ * that session instead of the whole list.
+ */
+ eventOccurrenceId?: number | null;
+}
+
+const formatTime = (iso?: string) => {
+ if (!iso) return "";
+ const normalized = iso.includes("T") ? iso : iso.replace(" ", "T") + "Z";
+ const d = new Date(normalized);
+ if (Number.isNaN(d.getTime())) return "";
+ return d.toLocaleTimeString([], {hour: "numeric", minute: "2-digit"});
+};
+
+const THROUGHPUT_WINDOW_MINUTES = 5;
+
+const parseCheckInTime = (iso: string): number | null => {
+ if (!iso) return null;
+ const normalized = iso.includes("T") ? iso : iso.replace(" ", "T") + "Z";
+ const t = new Date(normalized).getTime();
+ return Number.isNaN(t) ? null : t;
+};
+
+export const StatsTab = ({checkInListShortId, enabled, eventOccurrenceId}: StatsTabProps) => {
+ const statsQuery = useGetCheckInListStatsPublic(checkInListShortId, enabled, eventOccurrenceId);
+ const stats = statsQuery.data?.data;
+ const [now, setNow] = useState(() => Date.now());
+
+ // Recompute throughput every 15s so the window stays accurate without a refetch.
+ useEffect(() => {
+ const id = setInterval(() => setNow(Date.now()), 15_000);
+ return () => clearInterval(id);
+ }, []);
+
+ const total = stats?.total_attendees ?? 0;
+ const checkedIn = stats?.checked_in_attendees ?? 0;
+ const remaining = Math.max(0, total - checkedIn);
+ const percent = total > 0 ? Math.round((checkedIn / total) * 100) : 0;
+
+ const throughput = useMemo(() => {
+ if (!stats?.recent_check_ins?.length) {
+ return {inWindow: 0, perMinute: 0, windowMinutes: THROUGHPUT_WINDOW_MINUTES};
+ }
+ const windowMs = THROUGHPUT_WINDOW_MINUTES * 60_000;
+ const cutoff = now - windowMs;
+ const inWindow = stats.recent_check_ins.filter(c => {
+ const ts = parseCheckInTime(c.checked_in_at);
+ return ts !== null && ts >= cutoff;
+ }).length;
+ return {
+ inWindow,
+ perMinute: +(inWindow / THROUGHPUT_WINDOW_MINUTES).toFixed(1),
+ windowMinutes: THROUGHPUT_WINDOW_MINUTES,
+ };
+ }, [stats?.recent_check_ins, now]);
+
+ return (
+
+
+
+ {checkedIn}
+ / {total}
+
+
+ attendees checked in
+
+
+
+ {percent}% complete
+
+
+
+
+
+
+
{t`Checked in`}
+
{checkedIn}
+
+
+
+
{t`Remaining`}
+
{remaining}
+
+
+
+
{t`Total`}
+
{total}
+
+
+
+
+
+
+
{t`Throughput`}
+
+ {throughput.inWindow} {" "}
+
+ in last {throughput.windowMinutes} min
+
+
+
+
+ {throughput.perMinute}
+ {t`per min`}
+
+
+
+ {stats && stats.per_product.length > 1 && (
+ <>
+
{t`By ticket type`}
+
+ {stats.per_product.map(p => {
+ const pct = p.total_attendees > 0
+ ? Math.round((p.checked_in_attendees / p.total_attendees) * 100)
+ : 0;
+ return (
+
+
+
+ {p.product_title}
+
+
+ {p.checked_in_attendees}/{p.total_attendees}
+
+
+
+
+ );
+ })}
+
+ >
+ )}
+
+
{t`Latest check-ins`}
+ {!stats || stats.recent_check_ins.length === 0 ? (
+
+ No check-ins yet
+
+ ) : (
+
+ {stats.recent_check_ins.map(checkIn => (
+
+
+
+
+ {checkIn.first_name} {checkIn.last_name}
+
+
+ {checkIn.attendee_public_id}
+ {checkIn.product_title && (
+ <> · {checkIn.product_title}>
+ )}
+
+
+
{formatTime(checkIn.checked_in_at)}
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/frontend/src/components/layouts/CheckIn/types.ts b/frontend/src/components/layouts/CheckIn/types.ts
new file mode 100644
index 0000000000..fa9ee1f514
--- /dev/null
+++ b/frontend/src/components/layouts/CheckIn/types.ts
@@ -0,0 +1,9 @@
+export type RecentScanStatus = "success" | "duplicate" | "error";
+
+export interface RecentScan {
+ id: string;
+ name: string;
+ code: string;
+ status: RecentScanStatus;
+ timestamp: number;
+}
diff --git a/frontend/src/components/layouts/Checkout/index.tsx b/frontend/src/components/layouts/Checkout/index.tsx
index 6599b4e3b7..9ad56df715 100644
--- a/frontend/src/components/layouts/Checkout/index.tsx
+++ b/frontend/src/components/layouts/Checkout/index.tsx
@@ -31,7 +31,14 @@ const Checkout = () => {
const {eventId, orderShortId} = useParams();
const {data: order} = useGetOrderPublic(eventId, orderShortId, ['event']);
const event = order?.event;
- const {data: publicEvent} = useGetEventPublic(eventId, !!eventId);
+ // Derive a single occurrence id from order items only when they all agree —
+ // a multi-occurrence order falls back to the event-wide view so the cache
+ // entry matches what would be loaded without occurrence scoping.
+ const orderOccurrenceIds = Array.from(new Set(
+ (order?.order_items ?? []).map(item => item.event_occurrence_id).filter((id): id is number => id != null)
+ ));
+ const orderOccurrenceId = orderOccurrenceIds.length === 1 ? orderOccurrenceIds[0] : null;
+ const {data: publicEvent} = useGetEventPublic(eventId, !!eventId, false, null, orderOccurrenceId);
const navigate = useNavigate();
const location = useLocation();
const orderIsCompleted = order?.status === 'COMPLETED';
diff --git a/frontend/src/components/layouts/Event/index.tsx b/frontend/src/components/layouts/Event/index.tsx
index 95fef67ec5..b141af0da4 100644
--- a/frontend/src/components/layouts/Event/index.tsx
+++ b/frontend/src/components/layouts/Event/index.tsx
@@ -1,5 +1,6 @@
import {
IconArrowLeft,
+ IconCalendarRepeat,
IconChartPie,
IconChevronRight,
IconDashboard,
@@ -46,8 +47,11 @@ import {useWindowWidth} from "../../../hooks/useWindowWidth.ts";
import {SidebarCallout} from "../../common/SidebarCallout";
import {useGetMe} from "../../../queries/useGetMe.ts";
import {useResendEmailConfirmation} from "../../../mutations/useResendEmailConfirmation.ts";
-import {useState} from "react";
+import {useMemo, useState} from "react";
import {eventHomepageUrl} from "../../../utilites/urlHelper.ts";
+import {EventType} from "../../../types.ts";
+import {useGetEventOccurrence} from "../../../queries/useGetEventOccurrence.ts";
+import {prettyDate} from "../../../utilites/dates.ts";
const EventLayout = () => {
const location = useLocation();
@@ -66,6 +70,12 @@ const EventLayout = () => {
const resendEmailConfirmationMutation = useResendEmailConfirmation();
const [emailConfirmationResent, setEmailConfirmationResent] = useState(false);
+ const occurrenceIdFromUrl = useMemo(() => {
+ const match = location.pathname.match(/\/occurrences\/(\d+)$/);
+ return match ? match[1] : undefined;
+ }, [location.pathname]);
+ const {data: occurrence} = useGetEventOccurrence(eventId, occurrenceIdFromUrl);
+
const handleEmailConfirmationResend = () => {
resendEmailConfirmationMutation.mutate({
userId: me?.id
@@ -101,6 +111,12 @@ const EventLayout = () => {
// 2. EVENT SETUP
{label: t`Setup & Design`},
+ {
+ link: 'occurrences',
+ label: t`Occurrence Schedule`,
+ icon: IconCalendarRepeat,
+ showWhen: () => event?.type === EventType.RECURRING,
+ },
{link: 'settings', label: t`Event Settings`, icon: IconSettings},
{link: 'homepage-designer', label: t`Homepage Designer`, icon: IconPaint},
{link: 'ticket-designer', label: t`Ticket Designer`, icon: IconTicket},
@@ -119,7 +135,12 @@ const EventLayout = () => {
{link: 'check-in', label: t`Check-In Lists`, icon: IconQrcode},
{link: 'messages', label: t`Messages`, icon: IconSend},
{link: 'sold-out-waitlist', label: t`Waitlist`, icon: IconListCheck},
- {link: 'capacity-assignments', label: t`Capacity Management`, icon: IconUsersGroup},
+ {
+ link: 'capacity-assignments',
+ label: t`Capacity Management`,
+ icon: IconUsersGroup,
+ showWhen: () => event?.type !== EventType.RECURRING,
+ },
// 5. INTEGRATIONS
{label: t`Integrations`},
@@ -143,7 +164,15 @@ const EventLayout = () => {
{
link: `/manage/event/${event?.id}`,
content:
- }
+ },
+ ...(occurrenceIdFromUrl && occurrence ? [{
+ link: `/manage/event/${event?.id}/occurrences/${occurrenceIdFromUrl}`,
+ content:
+ }] : []),
] : [
{link: '#', content: '...'}
];
diff --git a/frontend/src/components/layouts/EventHomepage/index.tsx b/frontend/src/components/layouts/EventHomepage/index.tsx
index 223f400a79..bdb1d21c48 100644
--- a/frontend/src/components/layouts/EventHomepage/index.tsx
+++ b/frontend/src/components/layouts/EventHomepage/index.tsx
@@ -4,13 +4,14 @@ import "../../../styles/widget/default.scss";
import React, {useEffect, useRef, useState} from "react";
import {EventDocumentHead} from "../../common/EventDocumentHead";
import {eventCoverImage, eventHomepageUrl, imageUrl, organizerHomepageUrl} from "../../../utilites/urlHelper.ts";
-import {Event, OrganizerStatus} from "../../../types.ts";
+import {Event, EventOccurrenceStatus, EventType, OrganizerStatus} from "../../../types.ts";
import {EventNotAvailable} from "./EventNotAvailable";
import {
IconArrowUpRight,
IconCalendar,
IconCalendarOff,
IconCalendarPlus,
+ IconCalendarRepeat,
IconExternalLink,
IconMail,
IconMapPin,
@@ -46,10 +47,11 @@ interface EventHomepageProps {
event?: Event;
promoCodeValid?: boolean;
promoCode?: string;
+ initialOccurrenceId?: number | null;
}
const EventHomepage = ({...loaderData}: EventHomepageProps) => {
- const {event, promoCodeValid, promoCode} = loaderData;
+ const {event, promoCodeValid, promoCode, initialOccurrenceId} = loaderData;
const [showScrollButton, setShowScrollButton] = useState(false);
const [contactModalOpen, setContactModalOpen] = useState(false);
const ticketsSectionRef = useRef(null);
@@ -304,8 +306,8 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => {
@@ -332,25 +334,55 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => {
+ {event.type === EventType.RECURRING && (
+
+
+ {t`Recurring Event`}
+
+ )}
-
-
-
- {t`Add to Calendar`}
-
-
+ {(() => {
+ const nextOccurrence = event.type === EventType.RECURRING
+ ? (event.occurrences || [])
+ .filter(o => o.status === EventOccurrenceStatus.ACTIVE && !o.is_past)
+ .sort((a, b) => a.start_date.localeCompare(b.start_date))[0]
+ : undefined;
+ if (event.type === EventType.RECURRING && !nextOccurrence) return null;
+ return (
+
+
+
+ {t`Add to Calendar`}
+
+
+ );
+ })()}
{/* Event Ended */}
- {event.end_date && isDateInPast(event.end_date) && (
-
-
-
+ {event.type === EventType.RECURRING ? (
+ (event.occurrences || []).filter(o => o.status === EventOccurrenceStatus.ACTIVE && !o.is_past).length === 0 &&
+ (event.occurrences || []).length > 0 && (
+
+
+
+
+
+
{t`No upcoming dates`}
+
-
-
{t`This event has ended`}
+ )
+ ) : (
+ event.end_date && isDateInPast(event.end_date) && (
+
+
+
+
+
+
{t`This event has ended`}
+
-
+ )
)}
{/* Online Event */}
@@ -502,6 +534,7 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => {
promoCodeValid={promoCodeValid}
promoCode={promoCode}
showPoweredBy={false}
+ initialOccurrenceId={initialOccurrenceId}
/>
diff --git a/frontend/src/components/layouts/OrganizerLayout/index.tsx b/frontend/src/components/layouts/OrganizerLayout/index.tsx
index ca4d585a83..f65e1c92cc 100644
--- a/frontend/src/components/layouts/OrganizerLayout/index.tsx
+++ b/frontend/src/components/layouts/OrganizerLayout/index.tsx
@@ -5,7 +5,6 @@ import {
IconCalendarPlus,
IconChartPie,
IconChevronRight,
- IconCreditCard,
IconDashboard,
IconExternalLink,
IconEye,
@@ -33,7 +32,6 @@ import { SwitchOrganizerModal } from "../../modals/SwitchOrganizerModal";
import { CreateOrganizerModal } from "../../modals/CreateOrganizerModal";
import { useGetOrganizers } from "../../../queries/useGetOrganizers.ts";
import { useGetAccount } from "../../../queries/useGetAccount.ts";
-import { StripeConnectButton } from "../../common/StripeConnectButton";
import { ShareModal } from "../../modals/ShareModal";
import { organizerHomepageUrl } from "../../../utilites/urlHelper";
import { useUpdateOrganizerStatus } from "../../../mutations/useUpdateOrganizerStatus.ts";
@@ -66,6 +64,9 @@ const OrganizerLayout = () => {
const statusToggleMutation = useUpdateOrganizerStatus();
+ const isStripeConnected = !!organizer?.stripe_connect_setup_complete;
+ const showPayoutsSection = !!account?.is_saas_mode_enabled;
+
const navItems: NavItem[] = [
{
label: t`Switch Organizer`,
@@ -74,6 +75,15 @@ const OrganizerLayout = () => {
isActive: () => false,
showWhen: () => organizers && organizers.length > 1,
},
+ ...(showPayoutsSection && !isStripeConnected ? [
+ { label: t`Get Paid` },
+ {
+ link: 'settings#payouts',
+ label: t`Set up payouts`,
+ icon: IconBrandStripe,
+ isActive: () => false,
+ },
+ ] as NavItem[] : []),
{ label: 'Overview' },
{ link: 'dashboard', label: t`Organizer Dashboard`, icon: IconDashboard },
{
@@ -175,22 +185,6 @@ const OrganizerLayout = () => {
},
];
- if (account && !account?.stripe_connect_setup_complete) {
- callouts.unshift({
- icon:
,
- heading: t`Connect Stripe`,
- description: t`Connect your Stripe account to accept payments for tickets and products.`,
- storageKey: `stripe-callout-dismissed`,
- customButton:
-
}
- buttonText={t`Connect Stripe`}
- className={classes.calloutButton}
- />
- });
- }
return (
<>
diff --git a/frontend/src/components/layouts/ProductWidget/index.tsx b/frontend/src/components/layouts/ProductWidget/index.tsx
index f7ac7a9dca..2b69b03327 100644
--- a/frontend/src/components/layouts/ProductWidget/index.tsx
+++ b/frontend/src/components/layouts/ProductWidget/index.tsx
@@ -8,7 +8,19 @@ import {Loader} from "@mantine/core";
const ProductWidget = () => {
const {eventId} = useParams();
const location = useLocation();
- const eventQuery = useGetEventPublic(eventId);
+
+ // Forward `?occurrence_id=` from the embed URL so the public payload
+ // filters products by that occurrence and the storefront preselects it.
+ // Without this, embed share links to a specific date show ambiguous
+ // pickers and recurring products get the event-wide visibility set.
+ const eventOccurrenceId = useMemo(() => {
+ const raw = new URLSearchParams(location.search).get('occurrence_id');
+ if (raw === null) return null;
+ const parsed = Number(raw);
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
+ }, [location.search]);
+
+ const eventQuery = useGetEventPublic(eventId, true, false, null, eventOccurrenceId);
const settings = useMemo(() => {
const searchParams = new URLSearchParams(location.search);
diff --git a/frontend/src/components/layouts/PublicEvent/index.tsx b/frontend/src/components/layouts/PublicEvent/index.tsx
index f0de0377b6..8cf09c4a78 100644
--- a/frontend/src/components/layouts/PublicEvent/index.tsx
+++ b/frontend/src/components/layouts/PublicEvent/index.tsx
@@ -5,10 +5,11 @@ import {Event} from "../../../types";
export const PublicEvent = () => {
const loaderData = useLoaderData();
- const {event, promoCodeValid, promoCode} = loaderData as {
+ const {event, promoCodeValid, promoCode, occurrenceId} = loaderData as {
event?: Event;
promoCodeValid?: boolean;
promoCode?: string;
+ occurrenceId?: number | null;
};
return (
@@ -16,6 +17,7 @@ export const PublicEvent = () => {
event={event}
promoCodeValid={promoCodeValid}
promoCode={promoCode}
+ initialOccurrenceId={occurrenceId}
/>
);
};
diff --git a/frontend/src/components/modals/CreateAttendeeModal/index.tsx b/frontend/src/components/modals/CreateAttendeeModal/index.tsx
index 9cde645a36..18698e4d69 100644
--- a/frontend/src/components/modals/CreateAttendeeModal/index.tsx
+++ b/frontend/src/components/modals/CreateAttendeeModal/index.tsx
@@ -1,16 +1,17 @@
import {Modal} from "../../common/Modal";
-import {GenericModalProps, IdParam, ProductCategory, ProductType} from "../../../types.ts";
+import {EventOccurrenceStatus, EventType, GenericModalProps, ProductCategory, ProductType, QueryFilters} from "../../../types.ts";
import {Button} from "../../common/Button";
import {useNavigate, useParams} from "react-router";
import {useFormErrorResponseHandler} from "../../../hooks/useFormErrorResponseHandler.tsx";
import {useForm} from "@mantine/form";
-import {LoadingOverlay, NumberInput, Select, Switch, TextInput} from "@mantine/core";
+import {Alert, LoadingOverlay, NumberInput, Select, Switch, TextInput} from "@mantine/core";
+import {IconAlertTriangle} from "@tabler/icons-react";
import {useGetEvent} from "../../../queries/useGetEvent.ts";
import {CreateAttendeeRequest} from "../../../api/attendee.client.ts";
import {useCreateAttendee} from "../../../mutations/useCreateAttendee.ts";
import {showSuccess} from "../../../utilites/notifications.tsx";
import {t, Trans} from "@lingui/macro";
-import {useEffect} from "react";
+import {useEffect, useMemo} from "react";
import {InputGroup} from "../../common/InputGroup";
import {
getClientLocale,
@@ -21,6 +22,11 @@ import {
} from "../../../locales.ts";
import {ProductSelector} from "../../common/ProductSelector";
import {getProductsFromEvent} from "../../../utilites/helpers.ts";
+import {useGetEventOccurrences} from "../../../queries/useGetEventOccurrences.ts";
+import {useGetPriceOverrides} from "../../../queries/useGetPriceOverrides.ts";
+import {prettyDate} from "../../../utilites/dates.ts";
+import {BouncingEmoji} from "../../common/BouncingEmoji";
+import {Stack, Text} from "@mantine/core";
export const CreateAttendeeModal = ({onClose}: GenericModalProps) => {
const {eventId} = useParams();
@@ -30,6 +36,22 @@ export const CreateAttendeeModal = ({onClose}: GenericModalProps) => {
const navigate = useNavigate();
const eventProducts = getProductsFromEvent(event);
const eventHasProducts = eventProducts && eventProducts?.length > 0;
+ const isRecurring = event?.type === EventType.RECURRING;
+ const {data: occurrencesData} = useGetEventOccurrences(
+ eventId,
+ {pageNumber: 1, perPage: 1200} as QueryFilters,
+ );
+
+ const occurrenceOptions = useMemo(() => {
+ if (!isRecurring || !occurrencesData?.data) return [];
+ return occurrencesData.data
+ .filter(occ => occ.status !== 'CANCELLED')
+ .map(occ => ({
+ label: prettyDate(occ.start_date, event?.timezone || 'UTC')
+ + (occ.label ? ` (${occ.label})` : ''),
+ value: String(occ.id),
+ }));
+ }, [isRecurring, occurrencesData, event?.timezone]);
const form = useForm
({
initialValues: {
@@ -41,9 +63,41 @@ export const CreateAttendeeModal = ({onClose}: GenericModalProps) => {
send_confirmation_email: true,
taxes_and_fees: [],
locale: getClientLocale() as SupportedLocales,
+ event_occurrence_id: null,
+ override_capacity: false,
},
});
+ // Find the selected occurrence so we can show the override-capacity opt-in
+ // when the occurrence is at/over its limit. Without this control, the new
+ // backend eligibility check blocks manual attendee creation on full
+ // recurring sessions even though the override flag exists for that case.
+ const selectedOccurrence = useMemo(() => {
+ if (!isRecurring || !occurrencesData?.data || form.values.event_occurrence_id == null) return undefined;
+ const targetId = Number(form.values.event_occurrence_id);
+ return occurrencesData.data.find(occ => Number(occ.id) === targetId);
+ }, [isRecurring, occurrencesData, form.values.event_occurrence_id]);
+
+ const occurrenceIsFull = useMemo(() => {
+ if (!selectedOccurrence) return false;
+ if (selectedOccurrence.status === EventOccurrenceStatus.SOLD_OUT) return true;
+ if (selectedOccurrence.capacity == null) return false;
+ return (selectedOccurrence.used_capacity ?? 0) >= selectedOccurrence.capacity;
+ }, [selectedOccurrence]);
+
+ // Reset the opt-in whenever the user switches occurrences so it never
+ // silently carries over from a previous full-occurrence selection.
+ useEffect(() => {
+ if (form.values.override_capacity && !occurrenceIsFull) {
+ form.setFieldValue('override_capacity', false);
+ }
+ }, [occurrenceIsFull]);
+
+ const {data: priceOverrides} = useGetPriceOverrides(
+ eventId,
+ isRecurring ? form.values.event_occurrence_id ?? undefined : undefined,
+ );
+
useEffect(() => {
if (event?.product_categories) {
form.setFieldValue(
@@ -74,15 +128,21 @@ export const CreateAttendeeModal = ({onClose}: GenericModalProps) => {
}, [form.values.product_id]);
useEffect(() => {
- if (form.values.product_price_id && !form.values.amount_paid) {
- form.setFieldValue(
- 'amount_paid',
- Number(eventProducts
- ?.find(product => product.id == form.values.product_id)?.prices
- ?.find(productPrice => (productPrice.id as IdParam) = form.values.product_price_id)?.price)
+ if (form.values.product_price_id) {
+ const override = priceOverrides?.find(
+ o => String(o.product_price_id) === String(form.values.product_price_id)
);
+
+ if (override) {
+ form.setFieldValue('amount_paid', Number(override.price));
+ } else {
+ const basePrice = eventProducts
+ ?.find(product => product.id == form.values.product_id)?.prices
+ ?.find(productPrice => String(productPrice.id) === String(form.values.product_price_id))?.price;
+ form.setFieldValue('amount_paid', Number(basePrice) || 0);
+ }
}
- }, [form.values.product_price_id]);
+ }, [form.values.product_price_id, priceOverrides]);
const handleSubmit = (values: CreateAttendeeRequest) => {
mutation.mutate({
@@ -120,6 +180,32 @@ export const CreateAttendeeModal = ({onClose}: GenericModalProps) => {
)
}
+ if (isEventFetched && isRecurring && occurrencesData && occurrenceOptions.length === 0) {
+ return (
+
+
+
+
+ {t`No occurrences available`}
+
+
+ {t`You need to create at least one occurrence before you can add attendees to this recurring event.`}
+
+ {
+ onClose();
+ navigate(`/manage/event/${eventId}/occurrences`);
+ }}
+ >
+ {t`Go to Schedule`}
+
+
+
+ );
+ }
+
return (
-
-
+ {
- form.setFieldValue('start_date', value);
-
- // Auto-adjust end date if it's before new start date
- if (form.values.end_date && value && dayjs(form.values.end_date).isBefore(dayjs(value))) {
- form.setFieldValue('end_date', dayjs(value).add(2, 'hours').toISOString());
+ form.setFieldValue('type', value as EventType);
+ if (value === EventType.RECURRING) {
+ form.setFieldValue('start_date', undefined);
+ form.setFieldValue('end_date', undefined);
+ } else {
+ form.setFieldValue('start_date', dayjs().add(1, 'day').hour(21).minute(0).second(0).toISOString());
}
}}
+ data={[
+ {
+ value: EventType.SINGLE,
+ label: (
+
+
+ {t`Single Event`}
+
+ ),
+ },
+ {
+ value: EventType.RECURRING,
+ label: (
+
+
+ {t`Recurring Event`}
+
+ ),
+ },
+ ]}
+ mb="md"
/>
- {
- if (!form.values.end_date && form.values.start_date) {
- // Set default end date to 2 hours after start date
- form.setFieldValue('end_date', dayjs(form.values.start_date).add(2, 'hours').toISOString());
+
+
+ {form.values.type === EventType.RECURRING ? (
+
}
+ title={t`Occurrences can be configured after creation`}
+ children={t`You'll be able to set up dates, schedules, and recurrence rules in the next step.`}
+ className={classes.recurringNotice}
+ />
+ ) : (
+
+ {
+ form.setFieldValue('start_date', value);
+
+ // Auto-adjust end date if it's before new start date
+ if (form.values.end_date && value && dayjs(form.values.end_date).isBefore(dayjs(value))) {
+ form.setFieldValue('end_date', dayjs(value).add(2, 'hours').toISOString());
+ }
+ }}
+ />
+ {
+ if (!form.values.end_date && form.values.start_date) {
+ // Set default end date to 2 hours after start date
+ form.setFieldValue('end_date', dayjs(form.values.start_date).add(2, 'hours').toISOString());
+ }
}
}
- }
- />
-
+ />
+
+ )}
void;
@@ -20,9 +20,9 @@ export const CreateOrganizerModal = ({onClose}: CreateOrganizerModalProps) => {
size={'lg'}
modalHeader={'branded'}
>
- } variant="light" color="blue" mb="md">
+ } variant="info">
{t`Create additional organizers to manage separate brands, departments, or event series under one account. Each organizer has its own events, settings, and public page.`}
-
+
{
onClose();
navigate(`/manage/organizer/${organizer.id}`);
diff --git a/frontend/src/components/modals/DuplicateEventModal/index.tsx b/frontend/src/components/modals/DuplicateEventModal/index.tsx
index f14587a073..ee6d79ae29 100644
--- a/frontend/src/components/modals/DuplicateEventModal/index.tsx
+++ b/frontend/src/components/modals/DuplicateEventModal/index.tsx
@@ -1,5 +1,5 @@
import {t} from "@lingui/macro";
-import {EventDuplicatePayload, GenericModalProps, IdParam} from "../../../types.ts";
+import {EventDuplicatePayload, EventType, GenericModalProps, IdParam} from "../../../types.ts";
import {Button, Switch, TextInput, Group, ActionIcon, Tooltip, Grid} from "@mantine/core";
import {Modal} from "../../common/Modal";
import {useForm} from "@mantine/form";
@@ -20,19 +20,6 @@ interface DuplicateEventModalProps extends GenericModalProps {
}
export const DuplicateEventModal = ({onClose, eventId}: DuplicateEventModalProps) => {
- const duplicateOptions = [
- { key: 'duplicate_products', label: t`Products` },
- { key: 'duplicate_questions', label: t`Questions` },
- { key: 'duplicate_settings', label: t`Settings` },
- { key: 'duplicate_promo_codes', label: t`Promo Codes` },
- { key: 'duplicate_capacity_assignments', label: t`Capacity Assignments` },
- { key: 'duplicate_check_in_lists', label: t`Check-In Lists` },
- { key: 'duplicate_event_cover_image', label: t`Event Cover Image` },
- { key: 'duplicate_ticket_logo', label: t`Ticket Logo` },
- { key: 'duplicate_webhooks', label: t`Webhooks` },
- { key: 'duplicate_affiliates', label: t`Affiliates` },
- ];
-
const form = useForm({
initialValues: {
title: '',
@@ -49,13 +36,29 @@ export const DuplicateEventModal = ({onClose, eventId}: DuplicateEventModalProps
duplicate_ticket_logo: true,
duplicate_webhooks: true,
duplicate_affiliates: true,
+ duplicate_occurrences: true,
}
});
const mutation = useDuplicateEvent();
const eventQuery = useGetEvent(eventId);
+ const isRecurring = eventQuery?.data?.type === EventType.RECURRING;
const nav = useNavigate();
const errorHandler = useFormErrorResponseHandler();
+ const duplicateOptions: { key: keyof typeof form.values; label: string; description?: string }[] = [
+ { key: 'duplicate_products', label: t`Products` },
+ { key: 'duplicate_questions', label: t`Questions` },
+ { key: 'duplicate_settings', label: t`Settings` },
+ { key: 'duplicate_promo_codes', label: t`Promo Codes` },
+ { key: 'duplicate_capacity_assignments', label: t`Capacity Assignments` },
+ { key: 'duplicate_check_in_lists', label: t`Check-In Lists` },
+ { key: 'duplicate_event_cover_image', label: t`Event Cover Image` },
+ { key: 'duplicate_ticket_logo', label: t`Ticket Logo` },
+ { key: 'duplicate_webhooks', label: t`Webhooks` },
+ { key: 'duplicate_affiliates', label: t`Affiliates` },
+ ...(isRecurring ? [{ key: 'duplicate_occurrences' as const, label: t`Occurrences (future only)`, description: t`Future dates will be copied with capacity reset to zero` }] : []),
+ ];
+
const handleSelectAll = () => {
duplicateOptions.forEach(option => {
form.setFieldValue(option.key, true);
@@ -118,17 +121,19 @@ export const DuplicateEventModal = ({onClose, eventId}: DuplicateEventModalProps
error={form.errors?.description as string}
/>
-
-
-
-
+ {!isRecurring && (
+
+
+
+
+ )}
@@ -166,6 +171,7 @@ export const DuplicateEventModal = ({onClose, eventId}: DuplicateEventModalProps
diff --git a/frontend/src/components/modals/EditAccountConfigurationModal/index.tsx b/frontend/src/components/modals/EditAccountConfigurationModal/index.tsx
deleted file mode 100644
index 5a165c1031..0000000000
--- a/frontend/src/components/modals/EditAccountConfigurationModal/index.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-import {Button, NumberInput, Stack} from "@mantine/core";
-import {GenericModalProps, IdParam} from "../../../types";
-import {useForm} from "@mantine/form";
-import {Modal} from "../../common/Modal";
-import {showSuccess, showError} from "../../../utilites/notifications";
-import {t} from "@lingui/macro";
-import {useUpdateAccountConfiguration} from "../../../mutations/useUpdateAccountConfiguration";
-import {useFormErrorResponseHandler} from "../../../hooks/useFormErrorResponseHandler";
-import {AccountConfiguration} from "../../../api/admin.client";
-import {useEffect} from "react";
-
-interface EditAccountConfigurationModalProps extends GenericModalProps {
- accountId: IdParam;
- configuration?: AccountConfiguration;
- currencyCode?: string;
-}
-
-interface FormValues {
- fixed_fee: number;
- percentage_fee: number;
-}
-
-export const EditAccountConfigurationModal = ({
- onClose,
- accountId,
- configuration,
- currencyCode = 'USD',
-}: EditAccountConfigurationModalProps) => {
- const updateMutation = useUpdateAccountConfiguration(accountId);
- const formErrorHandler = useFormErrorResponseHandler();
-
- const form = useForm({
- initialValues: {
- fixed_fee: 0,
- percentage_fee: 0,
- },
- validate: {
- fixed_fee: (value) => {
- if (value < 0) {
- return t`Fixed fee must be 0 or greater`;
- }
- return null;
- },
- percentage_fee: (value) => {
- if (value < 0 || value > 100) {
- return t`Percentage fee must be between 0 and 100`;
- }
- return null;
- },
- },
- });
-
- useEffect(() => {
- if (configuration?.application_fees) {
- form.setValues({
- fixed_fee: configuration.application_fees.fixed / 100,
- percentage_fee: configuration.application_fees.percentage,
- });
- }
- }, [configuration]);
-
- const handleSubmit = (values: FormValues) => {
- updateMutation.mutate(
- {
- fixed_fee: Math.round(values.fixed_fee * 100),
- percentage_fee: values.percentage_fee,
- },
- {
- onSuccess: () => {
- showSuccess(t`Account configuration updated successfully`);
- onClose();
- },
- onError: (error: any) => {
- formErrorHandler(form, error);
- showError(
- error?.response?.data?.message ||
- t`Failed to update account configuration`
- );
- }
- }
- );
- };
-
- return (
-
-
-
-
-
-
-
-
- {t`Save Configuration`}
-
-
-
-
- );
-};
diff --git a/frontend/src/components/modals/EditAccountVatSettingsModal/index.tsx b/frontend/src/components/modals/EditAccountVatSettingsModal/index.tsx
deleted file mode 100644
index cbc5f0ee28..0000000000
--- a/frontend/src/components/modals/EditAccountVatSettingsModal/index.tsx
+++ /dev/null
@@ -1,174 +0,0 @@
-import {Button, Select, Stack, Switch, Text, TextInput} from "@mantine/core";
-import {GenericModalProps, IdParam} from "../../../types";
-import {useForm} from "@mantine/form";
-import {Modal} from "../../common/Modal";
-import {showSuccess, showError} from "../../../utilites/notifications";
-import {t} from "@lingui/macro";
-import {useUpdateAdminAccountVatSettings} from "../../../mutations/useUpdateAdminAccountVatSettings";
-import {useFormErrorResponseHandler} from "../../../hooks/useFormErrorResponseHandler";
-import {AccountVatSetting} from "../../../api/admin.client";
-import {useEffect} from "react";
-
-interface EditAccountVatSettingsModalProps extends GenericModalProps {
- accountId: IdParam;
- vatSetting?: AccountVatSetting;
-}
-
-interface FormValues {
- vat_registered: boolean;
- vat_number: string;
- business_name: string;
- business_address: string;
- vat_country_code: string;
-}
-
-const EU_COUNTRIES = [
- { value: 'AT', label: 'Austria' },
- { value: 'BE', label: 'Belgium' },
- { value: 'BG', label: 'Bulgaria' },
- { value: 'HR', label: 'Croatia' },
- { value: 'CY', label: 'Cyprus' },
- { value: 'CZ', label: 'Czech Republic' },
- { value: 'DK', label: 'Denmark' },
- { value: 'EE', label: 'Estonia' },
- { value: 'FI', label: 'Finland' },
- { value: 'FR', label: 'France' },
- { value: 'DE', label: 'Germany' },
- { value: 'GR', label: 'Greece' },
- { value: 'HU', label: 'Hungary' },
- { value: 'IE', label: 'Ireland' },
- { value: 'IT', label: 'Italy' },
- { value: 'LV', label: 'Latvia' },
- { value: 'LT', label: 'Lithuania' },
- { value: 'LU', label: 'Luxembourg' },
- { value: 'MT', label: 'Malta' },
- { value: 'NL', label: 'Netherlands' },
- { value: 'PL', label: 'Poland' },
- { value: 'PT', label: 'Portugal' },
- { value: 'RO', label: 'Romania' },
- { value: 'SK', label: 'Slovakia' },
- { value: 'SI', label: 'Slovenia' },
- { value: 'ES', label: 'Spain' },
- { value: 'SE', label: 'Sweden' },
-];
-
-export const EditAccountVatSettingsModal = ({
- onClose,
- accountId,
- vatSetting,
-}: EditAccountVatSettingsModalProps) => {
- const updateMutation = useUpdateAdminAccountVatSettings(accountId);
- const formErrorHandler = useFormErrorResponseHandler();
-
- const form = useForm({
- initialValues: {
- vat_registered: false,
- vat_number: '',
- business_name: '',
- business_address: '',
- vat_country_code: '',
- },
- });
-
- useEffect(() => {
- if (vatSetting) {
- form.setValues({
- vat_registered: vatSetting.vat_registered ?? false,
- vat_number: vatSetting.vat_number || '',
- business_name: vatSetting.business_name || '',
- business_address: vatSetting.business_address || '',
- vat_country_code: vatSetting.vat_country_code || '',
- });
- }
- }, [vatSetting]);
-
- const handleSubmit = (values: FormValues) => {
- updateMutation.mutate(
- {
- vat_registered: values.vat_registered,
- vat_number: values.vat_registered ? values.vat_number.trim() : null,
- business_name: values.vat_registered ? values.business_name.trim() : null,
- business_address: values.vat_registered ? values.business_address.trim() : null,
- vat_country_code: values.vat_registered ? values.vat_country_code : null,
- },
- {
- onSuccess: () => {
- showSuccess(t`VAT settings updated successfully`);
- onClose();
- },
- onError: (error: any) => {
- formErrorHandler(form, error);
- showError(
- error?.response?.data?.message ||
- t`Failed to update VAT settings`
- );
- }
- }
- );
- };
-
- return (
-
-
-
-
-
- {form.values.vat_registered && (
- <>
-
-
-
-
-
-
-
-
- {vatSetting?.vat_validated !== undefined && (
-
- {vatSetting.vat_validated
- ? t`Current VAT number is validated`
- : t`Current VAT number validation failed`}
-
- )}
- >
- )}
-
-
- {t`Save VAT Settings`}
-
-
-
-
- );
-};
diff --git a/frontend/src/components/modals/EditCheckInListModal/index.tsx b/frontend/src/components/modals/EditCheckInListModal/index.tsx
index 3a2496f46c..f35bcefb90 100644
--- a/frontend/src/components/modals/EditCheckInListModal/index.tsx
+++ b/frontend/src/components/modals/EditCheckInListModal/index.tsx
@@ -1,4 +1,4 @@
-import {CheckInListRequest, GenericModalProps, IdParam, ProductCategory} from "../../../types.ts";
+import {CheckInListRequest, EventType, GenericModalProps, IdParam, ProductCategory} from "../../../types.ts";
import {Modal} from "../../common/Modal";
import {t} from "@lingui/macro";
import {CheckInListForm} from "../../forms/CheckInListForm";
@@ -35,6 +35,10 @@ export const EditCheckInListModal = ({
activates_at: '',
description: '',
product_ids: [],
+ event_occurrence_id: null,
+ public_show_attendee_notes: true,
+ public_show_question_answers: true,
+ public_show_order_details: true,
}
});
const editMutation = useEditCheckInList();
@@ -61,6 +65,10 @@ export const EditCheckInListModal = ({
expires_at: utcToTz(checkInList.expires_at, event.timezone),
activates_at: utcToTz(checkInList.activates_at, event.timezone),
product_ids: checkInList.products?.map(product => String(product.id)),
+ event_occurrence_id: checkInList.event_occurrence_id ?? null,
+ public_show_attendee_notes: checkInList.public_show_attendee_notes ?? true,
+ public_show_question_answers: checkInList.public_show_question_answers ?? true,
+ public_show_order_details: checkInList.public_show_order_details ?? true,
});
}
}, [checkInList]);
@@ -85,6 +93,10 @@ export const EditCheckInListModal = ({
void;
}
-export const JoinWaitlistModal = ({onClose, product, event, productPriceId, priceLabel, onSuccess}: JoinWaitlistModalProps) => {
+export const JoinWaitlistModal = ({onClose, product, event, productPriceId, eventOccurrenceId, priceLabel, onSuccess}: JoinWaitlistModalProps) => {
const errorHandler = useFormErrorResponseHandler();
const mutation = useJoinWaitlist();
const [status, setStatus] = useState<'form' | 'success' | 'error'>('form');
@@ -56,6 +57,7 @@ export const JoinWaitlistModal = ({onClose, product, event, productPriceId, pric
data: {
...values,
product_price_id: Number(productPriceId),
+ event_occurrence_id: eventOccurrenceId ? Number(eventOccurrenceId) : undefined,
},
}, {
onSuccess: () => {
diff --git a/frontend/src/components/modals/ManageOccurrenceModal/ManageOccurrenceModal.module.scss b/frontend/src/components/modals/ManageOccurrenceModal/ManageOccurrenceModal.module.scss
new file mode 100644
index 0000000000..ebb26ba1c8
--- /dev/null
+++ b/frontend/src/components/modals/ManageOccurrenceModal/ManageOccurrenceModal.module.scss
@@ -0,0 +1,100 @@
+.container {
+ padding: 0.5rem;
+}
+
+.header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ margin-bottom: 1rem;
+ padding: 0 0.5rem;
+}
+
+.occurrenceInfo {
+ display: flex;
+ flex-direction: column;
+}
+
+.dateTime {
+ font-size: 1.1rem;
+ font-weight: 600;
+ line-height: 1.3;
+}
+
+.titleSuffix {
+ font-size: 0.875rem;
+ color: var(--mantine-color-dimmed);
+ margin-top: 2px;
+}
+
+.statusBadge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 10px;
+ border-radius: 12px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+ white-space: nowrap;
+
+ &[data-status="ACTIVE"] {
+ background: var(--mantine-color-green-light);
+ color: var(--mantine-color-green-filled);
+ }
+
+ &[data-status="CANCELLED"] {
+ background: var(--mantine-color-red-light);
+ color: var(--mantine-color-red-filled);
+ }
+
+ &[data-status="SOLD_OUT"] {
+ background: var(--mantine-color-orange-light);
+ color: var(--mantine-color-orange-filled);
+ }
+}
+
+.statsGrid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+ margin-bottom: 1rem;
+}
+
+.statCard {
+ background: var(--mantine-color-gray-0);
+ border-radius: 8px;
+ padding: 12px 14px;
+}
+
+.statValue {
+ font-size: 1.25rem;
+ font-weight: 700;
+ line-height: 1.2;
+}
+
+.statLabel {
+ font-size: 0.75rem;
+ color: var(--mantine-color-dimmed);
+ margin-top: 2px;
+}
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 1rem;
+}
+
+.sectionLink {
+ display: block;
+ text-align: center;
+ padding: 8px 0 4px;
+ font-size: 0.8rem;
+}
+
+.emptyText {
+ text-align: center;
+ padding: 1.5rem 0;
+}
diff --git a/frontend/src/components/modals/ManageOccurrenceModal/index.tsx b/frontend/src/components/modals/ManageOccurrenceModal/index.tsx
new file mode 100644
index 0000000000..c9c051e16d
--- /dev/null
+++ b/frontend/src/components/modals/ManageOccurrenceModal/index.tsx
@@ -0,0 +1,220 @@
+import {EventOccurrence, GenericModalProps, IdParam, MessageType} from "../../../types.ts";
+import {useNavigate, useParams} from "react-router";
+import {useGetEvent} from "../../../queries/useGetEvent.ts";
+import {useGetEventOccurrence} from "../../../queries/useGetEventOccurrence.ts";
+import {useGetEventCheckInLists} from "../../../queries/useGetCheckInLists.ts";
+import {t} from "@lingui/macro";
+import {useCallback, useMemo, useState} from "react";
+import {Progress, Skeleton, Stack, Text} from "@mantine/core";
+import {OccurrenceAttendeesAndOrders} from "../../common/OccurrenceAttendeesAndOrders";
+import {SideDrawer} from "../../common/SideDrawer";
+import {SendMessageModal} from "../SendMessageModal";
+import {ShareModal} from "../ShareModal";
+import {OccurrenceEditModal} from "../../routes/event/OccurrencesTab/OccurrenceEditModal";
+import {CreateCheckInListModal} from "../CreateCheckInListModal";
+import {OccurrenceActionBar, OccurrenceMenuActions} from "../../routes/event/OccurrencesTab/OccurrenceMenu";
+import {statusLabel} from "../../routes/event/OccurrencesTab/OccurrenceMenu";
+import {formatDateWithLocale} from "../../../utilites/dates.ts";
+import {formatCurrency} from "../../../utilites/currency.ts";
+import {showError, showSuccess} from "../../../utilites/notifications.tsx";
+import {confirmationDialog} from "../../../utilites/confirmationDialog.tsx";
+import {useCancelOccurrence} from "../../../mutations/useCancelOccurrence.ts";
+import {useDeleteEventOccurrence} from "../../../mutations/useDeleteEventOccurrence.ts";
+import {useReactivateOccurrence} from "../../../mutations/useReactivateOccurrence.ts";
+import {eventHomepageUrl} from "../../../utilites/urlHelper.ts";
+import {openCancelOccurrenceDialog} from "../../routes/event/OccurrencesTab/cancelOccurrenceDialog";
+import {launchCheckInForOccurrence} from "../../routes/event/OccurrencesTab/checkInLaunch";
+import classes from './ManageOccurrenceModal.module.scss';
+
+interface ManageOccurrenceModalProps {
+ occurrenceId: IdParam;
+}
+
+export const ManageOccurrenceModal = ({onClose, occurrenceId}: GenericModalProps & ManageOccurrenceModalProps) => {
+ const {eventId} = useParams();
+ const navigate = useNavigate();
+ const {data: occurrence} = useGetEventOccurrence(eventId, occurrenceId);
+ const {data: event} = useGetEvent(eventId);
+ const checkInListsQuery = useGetEventCheckInLists(eventId);
+ const checkInLists = checkInListsQuery?.data?.data;
+
+ const [showMessageModal, setShowMessageModal] = useState(false);
+ const [showEditModal, setShowEditModal] = useState(false);
+ const [showShareOccurrence, setShowShareOccurrence] = useState();
+ const [createCheckInForOccurrenceId, setCreateCheckInForOccurrenceId] = useState();
+
+ const cancelMutation = useCancelOccurrence();
+ const deleteMutation = useDeleteEventOccurrence();
+ const reactivateMutation = useReactivateOccurrence();
+
+ const handleCheckIn = useCallback((occurrenceId: number) => {
+ launchCheckInForOccurrence({
+ occurrenceId,
+ checkInLists,
+ onCreateForOccurrence: setCreateCheckInForOccurrenceId,
+ });
+ }, [checkInLists]);
+
+ const handleCancel = useCallback((occId: number) => {
+ openCancelOccurrenceDialog({
+ eventId,
+ occurrenceId: occId,
+ orderCount: occurrence?.statistics?.orders_created ?? 0,
+ mutation: cancelMutation,
+ });
+ }, [occurrence, eventId, cancelMutation]);
+
+ const handleDelete = useCallback((occId: number) => {
+ confirmationDialog(t`Are you sure you want to delete this date? This action cannot be undone.`, () => {
+ deleteMutation.mutate({eventId, occurrenceId: occId}, {
+ onSuccess: () => {
+ showSuccess(t`Date deleted`);
+ onClose();
+ },
+ onError: (error: any) => showError(error?.response?.data?.message || t`Failed to delete date`),
+ });
+ });
+ }, [eventId, onClose]);
+
+ const handleReactivate = useCallback((occ: EventOccurrence) => {
+ confirmationDialog(t`Reactivate this date? It will be reopened for future sales.`, () => {
+ reactivateMutation.mutate({
+ eventId,
+ occurrenceId: occ.id,
+ }, {
+ onSuccess: () => showSuccess(t`Date reactivated`),
+ onError: (error: any) => showError(error?.response?.data?.message || t`Failed to reactivate date`),
+ });
+ });
+ }, [eventId]);
+
+ const menuActions: OccurrenceMenuActions = useMemo(() => ({
+ eventId: eventId!,
+ onEdit: () => setShowEditModal(true),
+ onCancel: handleCancel,
+ onDelete: handleDelete,
+ onNavigate: (path: string) => {
+ onClose();
+ navigate(path);
+ },
+ onMessage: () => setShowMessageModal(true),
+ onCheckIn: handleCheckIn,
+ onReactivate: handleReactivate,
+ onShare: (occ: EventOccurrence) => setShowShareOccurrence(occ),
+ }), [eventId, handleCheckIn, handleCancel, handleDelete, handleReactivate, onClose, navigate]);
+
+ if (!occurrence || !event) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ const startFormatted = formatDateWithLocale(occurrence.start_date, 'fullDateTime', event.timezone);
+ const endFormatted = occurrence.end_date
+ ? formatDateWithLocale(occurrence.end_date, 'timeOnly', event.timezone)
+ : null;
+
+ const usedCapacity = occurrence.used_capacity ?? 0;
+ const hasCapacityLimit = occurrence.capacity != null;
+ const soldLabel = hasCapacityLimit
+ ? `${usedCapacity} / ${occurrence.capacity}`
+ : `${usedCapacity}`;
+ const capacityPercent = hasCapacityLimit && occurrence.capacity
+ ? Math.min(100, Math.round((usedCapacity / occurrence.capacity) * 100))
+ : 0;
+
+ return (
+
+
+
+
+
+ {startFormatted}{endFormatted && <> — {endFormatted}>}
+
+ {occurrence.label && (
+ {occurrence.label}
+ )}
+
+
+ {statusLabel(occurrence.status)}
+
+
+
+
+
+
{occurrence.statistics?.attendees_registered ?? 0}
+
{t`Attendees`}
+
+
+
{occurrence.statistics?.orders_created ?? 0}
+
{t`Orders`}
+
+
+
{formatCurrency(occurrence.statistics?.total_gross_sales ?? 0, event.currency)}
+
{t`Gross Sales`}
+
+
+
{soldLabel}
+
{t`Sold`}
+ {hasCapacityLimit && (
+
= 90 ? 'red' : capacityPercent >= 70 ? 'orange' : 'blue'}
+ />
+ )}
+
+
+
+
+
+
+
+
+ {showMessageModal && (
+ setShowMessageModal(false)}
+ messageType={MessageType.AllAttendees}
+ eventOccurrenceId={occurrenceId}
+ />
+ )}
+
+ {showEditModal && (
+ setShowEditModal(false)}
+ occurrenceId={occurrenceId}
+ />
+ )}
+
+ {createCheckInForOccurrenceId && (
+ setCreateCheckInForOccurrenceId(undefined)}
+ initialOccurrenceId={createCheckInForOccurrenceId}
+ />
+ )}
+
+ {showShareOccurrence && (
+ setShowShareOccurrence(undefined)}
+ url={`${eventHomepageUrl(event)}?occurrence_id=${showShareOccurrence.id}`}
+ title={event.title}
+ shareText={`${event.title} — ${formatDateWithLocale(showShareOccurrence.start_date, 'shortDateTime', event.timezone)}`}
+ />
+ )}
+
+ );
+};
diff --git a/frontend/src/components/modals/OfferWaitlistModal/index.tsx b/frontend/src/components/modals/OfferWaitlistModal/index.tsx
index ffea418d4e..16ede789ea 100644
--- a/frontend/src/components/modals/OfferWaitlistModal/index.tsx
+++ b/frontend/src/components/modals/OfferWaitlistModal/index.tsx
@@ -14,6 +14,7 @@ interface OfferWaitlistModalProps extends GenericModalProps {
eventId: IdParam;
eventSettings?: EventSettings;
stats?: WaitlistStats;
+ eventOccurrenceId?: IdParam | null;
}
const getDefaultQuantity = (product: WaitlistProductStats): number => {
@@ -27,7 +28,7 @@ const getMaxQuantity = (product: WaitlistProductStats): number => {
return Math.min(product.waiting, product.available);
};
-export const OfferWaitlistModal = ({onClose, eventId, eventSettings, stats}: OfferWaitlistModalProps) => {
+export const OfferWaitlistModal = ({onClose, eventId, eventSettings, stats, eventOccurrenceId}: OfferWaitlistModalProps) => {
const mutation = useOfferWaitlistEntry();
const [loadingProductId, setLoadingProductId] = useState(null);
@@ -54,6 +55,7 @@ export const OfferWaitlistModal = ({onClose, eventId, eventSettings, stats}: Off
eventId,
productPriceId: product.product_price_id,
quantity: qty,
+ eventOccurrenceId,
}, {
onSuccess: (response) => {
const count = response?.data?.length ?? qty;
diff --git a/frontend/src/components/modals/SendMessageModal/SendMessageModal.module.scss b/frontend/src/components/modals/SendMessageModal/SendMessageModal.module.scss
index bbd152e554..3453b361d5 100644
--- a/frontend/src/components/modals/SendMessageModal/SendMessageModal.module.scss
+++ b/frontend/src/components/modals/SendMessageModal/SendMessageModal.module.scss
@@ -182,3 +182,23 @@
font-size: 0.7rem;
color: var(--mantine-color-gray-5);
}
+
+.occurrenceList {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ max-height: 180px;
+ overflow-y: auto;
+ padding: 4px 0;
+}
+
+.occurrenceChip {
+ font-size: 0.82rem;
+ line-height: 1.3;
+ padding: 2px 0;
+ color: var(--mantine-color-dark-7);
+}
+
+.occurrenceChipLabel {
+ color: var(--mantine-color-gray-7);
+}
diff --git a/frontend/src/components/modals/SendMessageModal/index.tsx b/frontend/src/components/modals/SendMessageModal/index.tsx
index f33cc8f5f4..a5eb2aaf27 100644
--- a/frontend/src/components/modals/SendMessageModal/index.tsx
+++ b/frontend/src/components/modals/SendMessageModal/index.tsx
@@ -1,5 +1,5 @@
-import {Event, GenericModalProps, IdParam, MessageType, ProductType} from "../../../types.ts";
-import {useParams} from "react-router";
+import {Event, EventOccurrence, EventType, GenericModalProps, IdParam, MessageType, ProductType, QueryFilters} from "../../../types.ts";
+import {NavLink, useParams} from "react-router";
import {useGetEvent} from "../../../queries/useGetEvent.ts";
import {useGetOrder} from "../../../queries/useGetOrder.ts";
import {Modal} from "../../common/Modal";
@@ -13,10 +13,12 @@ import {
Menu,
MultiSelect,
Select,
+ Text,
TextInput
} from "@mantine/core";
import {
IconAlertCircle,
+ IconBrandStripe,
IconCheck,
IconChevronDown,
IconClock,
@@ -35,9 +37,9 @@ import {useSendEventMessage} from "../../../mutations/useSendEventMessage.ts";
import {ProductSelector} from "../../common/ProductSelector";
import {useEffect, useMemo, useState} from "react";
import {useGetAccount} from "../../../queries/useGetAccount.ts";
-import {StripeConnectButton} from "../../common/StripeConnectButton";
import {getConfig} from "../../../utilites/config";
-import {utcToTz} from "../../../utilites/dates.ts";
+import {utcToTz, prettyDate} from "../../../utilites/dates.ts";
+import {useGetEventOccurrences} from "../../../queries/useGetEventOccurrences.ts";
import dayjs from "dayjs";
import classes from "./SendMessageModal.module.scss";
@@ -46,6 +48,18 @@ interface EventMessageModalProps extends GenericModalProps {
productId?: IdParam,
messageType: MessageType,
attendeeId?: IdParam,
+ eventOccurrenceId?: IdParam,
+ /**
+ * Multi-occurrence targeting (e.g. after a bulk reschedule). When set,
+ * the occurrence dropdown is hidden and the modal sends the array
+ * through to the backend for whereIn-style filtering. Mutually exclusive
+ * with eventOccurrenceId.
+ */
+ eventOccurrenceIds?: IdParam[],
+ /** Pre-fill the subject field (organizer can still edit before sending). */
+ initialSubject?: string,
+ /** Pre-fill the message body. */
+ initialMessage?: string,
}
const OrderField = ({orderId, eventId}: { orderId: IdParam, eventId: IdParam }) => {
@@ -101,10 +115,12 @@ const AttendeeField = ({orderId, eventId, attendeeId, form}: {
const CUSTOM_PRESET = 'custom';
-const getSchedulePresets = (event: Event) => {
+const getSchedulePresets = (event: Event, occurrence?: EventOccurrence) => {
const now = dayjs.utc();
- const startDate = dayjs.utc(event.start_date);
- const endDate = event.end_date ? dayjs.utc(event.end_date) : null;
+ const startDate = occurrence ? dayjs.utc(occurrence.start_date) : dayjs.utc(event.start_date);
+ const endDate = occurrence?.end_date
+ ? dayjs.utc(occurrence.end_date)
+ : event.end_date ? dayjs.utc(event.end_date) : null;
const presets: { value: string; label: string; utcDate: dayjs.Dayjs }[] = [
{value: '1_week_before', label: t`1 week before event`, utcDate: startDate.subtract(1, 'week')},
@@ -125,9 +141,45 @@ const getSchedulePresets = (event: Event) => {
};
export const SendMessageModal = (props: EventMessageModalProps) => {
- const {onClose, orderId, productId, messageType, attendeeId} = props;
+ const {
+ onClose, orderId, productId, messageType, attendeeId,
+ eventOccurrenceId: rawEventOccurrenceId, eventOccurrenceIds,
+ initialSubject, initialMessage,
+ } = props;
+ // Normalize: a single-item array targets exactly one occurrence, so fall
+ // back to the scalar path (dropdown UI works as usual, backend uses the
+ // single FK column). Multi ≥ 2 keeps the array and the whereIn backend path.
+ const isMultiOccurrence = !!eventOccurrenceIds && eventOccurrenceIds.length > 1;
+ const eventOccurrenceId = rawEventOccurrenceId
+ ?? (eventOccurrenceIds?.length === 1 ? eventOccurrenceIds[0] : undefined);
const {eventId} = useParams();
const {data: event, data: {product_categories} = {}} = useGetEvent(eventId);
+ const isRecurring = event?.type === EventType.RECURRING;
+ const {data: occurrencesData} = useGetEventOccurrences(
+ eventId,
+ {pageNumber: 1, perPage: 100} as QueryFilters,
+ );
+ const occurrenceOptions = useMemo(() => {
+ if (!isRecurring || !occurrencesData?.data) return [];
+ return occurrencesData.data
+ .filter(occ => occ.status !== 'CANCELLED')
+ .map(occ => ({
+ label: prettyDate(occ.start_date, event?.timezone || 'UTC')
+ + (occ.label ? ` (${occ.label})` : ''),
+ value: String(occ.id),
+ }));
+ }, [isRecurring, occurrencesData, event?.timezone]);
+
+ // Resolve the targeted occurrences for the multi-session case so we can
+ // render a verifiable list in the banner. Sorted chronologically — matches
+ // how they were selected and how attendees will see them on their tickets.
+ const targetedOccurrences = useMemo(() => {
+ if (!isMultiOccurrence || !occurrencesData?.data || !eventOccurrenceIds) return [];
+ const ids = new Set(eventOccurrenceIds.map(id => Number(id)));
+ return occurrencesData.data
+ .filter(occ => ids.has(Number(occ.id)))
+ .sort((a, b) => a.start_date.localeCompare(b.start_date));
+ }, [isMultiOccurrence, eventOccurrenceIds, occurrencesData]);
const {data: me} = useGetMe();
const errorHandler = useFormErrorResponseHandler();
const isPreselectedRecipient = !!(orderId || attendeeId || productId);
@@ -140,19 +192,12 @@ export const SendMessageModal = (props: EventMessageModalProps) => {
const [isScheduled, setIsScheduled] = useState(false);
const [selectedPreset, setSelectedPreset] = useState(null);
- const presets = useMemo(() => event ? getSchedulePresets(event) : [], [event]);
-
- const resolvedPreset = useMemo(() => {
- if (!selectedPreset || selectedPreset === CUSTOM_PRESET) return null;
- return presets.find(p => p.value === selectedPreset) ?? null;
- }, [selectedPreset, presets]);
-
const sendMessageMutation = useSendEventMessage();
const form = useForm({
initialValues: {
- subject: '',
- message: '',
+ subject: initialSubject ?? '',
+ message: initialMessage ?? '',
message_type: messageType,
attendee_ids: attendeeId ? [String(attendeeId)] : [],
product_ids: productId ? [String(productId)] : [],
@@ -163,6 +208,10 @@ export const SendMessageModal = (props: EventMessageModalProps) => {
acknowledgement: false,
order_statuses: ['COMPLETED'],
scheduled_at: '',
+ event_occurrence_id: eventOccurrenceId ? Number(eventOccurrenceId) : null as number | null,
+ event_occurrence_ids: isMultiOccurrence
+ ? eventOccurrenceIds!.map(id => Number(id))
+ : null as number[] | null,
},
validate: {
acknowledgement: (value) => value === true ? null : t`You must acknowledge that this email is not promotional`,
@@ -176,6 +225,18 @@ export const SendMessageModal = (props: EventMessageModalProps) => {
}
});
+ const selectedOccurrence = useMemo(() => {
+ if (!form.values.event_occurrence_id || !occurrencesData?.data) return undefined;
+ return occurrencesData.data.find(occ => occ.id === form.values.event_occurrence_id);
+ }, [form.values.event_occurrence_id, occurrencesData]);
+
+ const presets = useMemo(() => event ? getSchedulePresets(event, selectedOccurrence) : [], [event, selectedOccurrence]);
+
+ const resolvedPreset = useMemo(() => {
+ if (!selectedPreset || selectedPreset === CUSTOM_PRESET) return null;
+ return presets.find(p => p.value === selectedPreset) ?? null;
+ }, [selectedPreset, presets]);
+
const handleSend = (values: any) => {
setTierLimitError(null);
const submitData = {...values};
@@ -240,9 +301,18 @@ export const SendMessageModal = (props: EventMessageModalProps) => {
title={t`Connect Stripe to enable messaging`}>
{t`Due to the high risk of spam, you must connect a Stripe account before you can send messages to attendees.
This is to ensure that all event organizers are verified and accountable.`}
-
-
-
+ {event?.organizer_id && (
+
+ }
+ variant="light"
+ >
+ {t`Connect Stripe`}
+
+
+ )}
)}
@@ -278,6 +348,42 @@ export const SendMessageModal = (props: EventMessageModalProps) => {
{!formIsDisabled && (
+ {isMultiOccurrence && (
+
}>
+
+ {t`Targeting attendees across ${eventOccurrenceIds!.length} selected sessions.`}
+
+ {targetedOccurrences.length > 0 && (
+
+ {targetedOccurrences.map(occ => (
+
+ {prettyDate(occ.start_date, event?.timezone || 'UTC')}
+ {occ.label && · {occ.label} }
+
+ ))}
+
+ )}
+ {eventOccurrenceIds!.length > targetedOccurrences.length && (
+
+ {t`Showing the first ${targetedOccurrences.length} — the remaining ${eventOccurrenceIds!.length - targetedOccurrences.length} session(s) will still be targeted when the message is sent.`}
+
+ )}
+
+ )}
+
+ {isRecurring && !isPreselectedRecipient && !isMultiOccurrence && occurrenceOptions.length > 0 && (
+
form.setFieldValue('event_occurrence_id', val ? Number(val) : null)}
+ clearable={!eventOccurrenceId}
+ disabled={!!eventOccurrenceId}
+ />
+ )}
+
{!isPreselectedRecipient && (
{
},
{
value: 'ALL_ATTENDEES',
- label: t`All attendees of this event`,
+ label: isMultiOccurrence
+ ? t`All attendees of the selected sessions`
+ : form.values.event_occurrence_id
+ ? t`All attendees of this occurrence`
+ : t`All attendees of this event`,
},
{
value: 'ORDER_OWNERS_WITH_PRODUCT',
diff --git a/frontend/src/components/routes/account/ManageAccount/index.tsx b/frontend/src/components/routes/account/ManageAccount/index.tsx
index 67602ddcbe..082a3c89ac 100644
--- a/frontend/src/components/routes/account/ManageAccount/index.tsx
+++ b/frontend/src/components/routes/account/ManageAccount/index.tsx
@@ -1,18 +1,16 @@
import {Card} from "../../../common/Card";
import {Tabs} from "@mantine/core";
import classes from "./ManageAccount.module.scss";
-import {IconAdjustmentsCog, IconCreditCard, IconReceiptTax, IconUsers} from "@tabler/icons-react";
+import {IconAdjustmentsCog, IconReceiptTax, IconUsers} from "@tabler/icons-react";
import {Outlet, useLocation, useNavigate} from "react-router";
import {t} from "@lingui/macro";
import {useIsCurrentUserAdmin} from "../../../../hooks/useIsCurrentUserAdmin.ts";
-import { useGetAccount } from "../../../../queries/useGetAccount.ts";
export const ManageAccount = () => {
const navigate = useNavigate();
const location = useLocation();
const tabValue = location.pathname.split('/').pop() || 'settings';
const isUserAdmin = useIsCurrentUserAdmin();
- const {data: account} = useGetAccount();
return (
@@ -32,12 +30,6 @@ export const ManageAccount = () => {
{t`Users`}
)}
-
- {(isUserAdmin && account && account.is_saas_mode_enabled) && (
-
}>
- {t`Payment & Plan`}
-
- )}
diff --git a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/PaymentSettings.module.scss b/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/PaymentSettings.module.scss
deleted file mode 100644
index 99d7c58a01..0000000000
--- a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/PaymentSettings.module.scss
+++ /dev/null
@@ -1,45 +0,0 @@
-.stripeInfo {
- padding: 10px;
- h2 {
- margin: 0;
- }
-
- p {
- color: var(--hi-color-gray-dark);
-
- &:last-child {
- margin-bottom: 0;
- }
- }
-}
-
-.migrationNotice {
- background: var(--mantine-color-blue-0);
- border: 1px solid var(--mantine-color-blue-2);
- border-radius: 8px;
- margin-bottom: 20px;
-}
-
-.migrationBanner {
- border: 2px solid var(--mantine-color-blue-4);
- margin-bottom: 16px;
-}
-
-.platformPanel {
- margin-bottom: 16px;
- transition: all 0.2s ease;
-
- &.activePlatform {
- border: 2px solid var(--mantine-color-green-5);
- background: var(--mantine-color-green-0);
-
- &.ca {
- border-color: var(--mantine-color-orange-5);
- background: var(--mantine-color-orange-0);
- }
- }
-
- &:not(.activePlatform) {
- opacity: 0.85;
- }
-}
diff --git a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/VatNotice/index.tsx b/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/VatNotice/index.tsx
deleted file mode 100644
index b2f1bebb7b..0000000000
--- a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/VatNotice/index.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import {t} from '@lingui/macro';
-import {Text} from '@mantine/core';
-
-const EU_COUNTRIES = [
- 'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR',
- 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL',
- 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE'
-];
-
-interface VatNoticeProps {
- stripeCountry?: string;
-}
-
-export const getVatInfo = (stripeCountry?: string) => {
- if (!stripeCountry || !EU_COUNTRIES.includes(stripeCountry.toUpperCase())) {
- return {
- isEU: false,
- isIreland: false,
- showVatForm: false,
- };
- }
-
- const isIreland = stripeCountry.toUpperCase() === 'IE';
-
- return {
- isEU: true,
- isIreland,
- showVatForm: !isIreland,
- };
-};
-
-export const VatNotice = ({stripeCountry}: VatNoticeProps) => {
- const vatInfo = getVatInfo(stripeCountry);
-
- if (!vatInfo.isEU) {
- return null;
- }
-
- if (vatInfo.isIreland) {
- return (
-
- {t`Irish VAT at 23% will be applied to platform fees (domestic supply).`}
-
- );
- }
-
- // Other EU countries
- return (
-
- {t`VAT may be applied to platform fees depending on your VAT registration status. Please complete the VAT information section below.`}
-
- );
-};
diff --git a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/VatSettings/VatSettings.module.scss b/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/VatSettings/VatSettings.module.scss
deleted file mode 100644
index 6e9c5af870..0000000000
--- a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/VatSettings/VatSettings.module.scss
+++ /dev/null
@@ -1,3 +0,0 @@
-.vatSettings {
- margin-top: 2rem;
-}
diff --git a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/VatSettings/VatSettingsModal.tsx b/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/VatSettings/VatSettingsModal.tsx
deleted file mode 100644
index 7d3c2f544a..0000000000
--- a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/VatSettings/VatSettingsModal.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import {t} from '@lingui/macro';
-import {Text} from '@mantine/core';
-import {Modal} from '../../../../../../common/Modal';
-import {Account} from '../../../../../../../types.ts';
-import {VatSettingsForm} from './VatSettingsForm.tsx';
-
-interface VatSettingsModalProps {
- account: Account;
- opened: boolean;
- onClose: () => void;
-}
-
-export const VatSettingsModal = ({account, opened, onClose}: VatSettingsModalProps) => {
- return (
-
-
- {t`As your business is based in the EU, we need to determine the correct VAT treatment for our platform fees:`}
-
-
- • {t`EU VAT-registered businesses: Reverse charge mechanism applies (0% - Article 196 of VAT Directive 2006/112/EC)`}
- • {t`Non-VAT registered businesses or individuals: Irish VAT at 23% applies`}
-
-
-
-
- );
-};
diff --git a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/VatSettings/index.tsx b/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/VatSettings/index.tsx
deleted file mode 100644
index 811ed0a6b5..0000000000
--- a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/VatSettings/index.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import {t} from '@lingui/macro';
-import {Alert, Text, Title} from '@mantine/core';
-import {IconAlertCircle, IconInfoCircle} from '@tabler/icons-react';
-import {Account} from '../../../../../../../types.ts';
-import {getVatInfo} from '../VatNotice';
-import {VatSettingsForm} from './VatSettingsForm.tsx';
-import {useGetAccountVatSetting} from '../../../../../../../queries/useGetAccountVatSetting.ts';
-import classes from './VatSettings.module.scss';
-
-interface VatSettingsProps {
- account: Account;
- stripeCountry?: string;
-}
-
-export const VatSettings = ({account, stripeCountry}: VatSettingsProps) => {
- const vatSettingQuery = useGetAccountVatSetting(account.id);
- const vatInfo = getVatInfo(stripeCountry);
-
- if (!vatInfo.isEU) {
- return null;
- }
-
- const existingSettings = vatSettingQuery.data;
- const needsVatInfo = !existingSettings || existingSettings.vat_registered === null || existingSettings.vat_registered === undefined;
-
- // Irish customers: Show informational message only, no form
- if (vatInfo.isIreland) {
- return (
-
-
{t`VAT Information`}
-
} mb="lg">
-
{t`VAT Treatment for Platform Fees`}
-
- {t`As your business is based in Ireland, Irish VAT at 23% applies automatically to all platform fees.`}
-
-
-
- );
- }
-
- // Other EU customers: Show warning banner and form
- return (
-
-
{t`VAT Registration Information`}
-
- {needsVatInfo && (
-
}
- mb="lg"
- styles={{
- root: {
- borderLeft: '4px solid var(--mantine-color-orange-6)',
- }
- }}
- >
-
{t`Action Required: VAT Information Needed`}
-
- {t`As your business is based in the EU, we need to determine the correct VAT treatment for our platform fees:`}
-
-
- • {t`EU VAT-registered businesses: Reverse charge mechanism applies (0% - Article 196 of VAT Directive 2006/112/EC)`}
- • {t`Non-VAT registered businesses or individuals: Irish VAT at 23% applies`}
-
-
- {t`What you need to do:`}
- • {t`Indicate whether you're VAT-registered in the EU`}
- • {t`If registered, provide your VAT number for validation`}
-
-
- )}
-
- {!needsVatInfo && (
-
- {t`VAT treatment for platform fees: EU VAT-registered businesses can use the reverse charge mechanism (0% - Article 196 of VAT Directive 2006/112/EC). Non-VAT registered businesses are charged Irish VAT at 23%.`}
-
- )}
-
-
-
- );
-};
diff --git a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/index.tsx b/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/index.tsx
deleted file mode 100644
index ea46f69df1..0000000000
--- a/frontend/src/components/routes/account/ManageAccount/sections/PaymentSettings/index.tsx
+++ /dev/null
@@ -1,866 +0,0 @@
-import {t} from "@lingui/macro";
-import {HeadingCard} from "../../../../../common/HeadingCard";
-import {useCreateOrGetStripeConnectDetails} from "../../../../../../queries/useCreateOrGetStripeConnectDetails.ts";
-import {useGetAccount} from "../../../../../../queries/useGetAccount.ts";
-import {useGetStripeConnectAccounts} from "../../../../../../queries/useGetStripeConnectAccounts.ts";
-import {LoadingMask} from "../../../../../common/LoadingMask";
-import {Anchor, Button, Grid, Group, Text, ThemeIcon, Title} from "@mantine/core";
-import {Account, StripeConnectAccountsResponse} from "../../../../../../types.ts";
-import paymentClasses from "./PaymentSettings.module.scss";
-import classes from "../../ManageAccount.module.scss";
-import {useEffect, useRef, useState} from "react";
-import {IconAlertCircle, IconBrandStripe, IconCheck, IconExternalLink, IconInfoCircle} from '@tabler/icons-react';
-import {Card} from "../../../../../common/Card";
-import {formatCurrency} from "../../../../../../utilites/currency.ts";
-import {showSuccess} from "../../../../../../utilites/notifications.tsx";
-import {getConfig} from "../../../../../../utilites/config.ts";
-import {isHiEvents} from "../../../../../../utilites/helpers.ts";
-import {VatSettings} from './VatSettings';
-import {VatSettingsModal} from './VatSettings/VatSettingsModal.tsx';
-import {VatNotice, getVatInfo} from './VatNotice';
-import {useGetAccountVatSetting} from '../../../../../../queries/useGetAccountVatSetting.ts';
-import {trackEvent, AnalyticsEvents} from "../../../../../../utilites/analytics.ts";
-
-interface FeePlanDisplayProps {
- configuration?: {
- name: string;
- application_fees: {
- percentage: number;
- fixed: number;
- currency: string;
- };
- is_system_default: boolean;
- };
- stripeCountry?: string;
-}
-
-const formatPercentage = (value: number) => {
- return new Intl.NumberFormat('en-US', {
- style: 'percent',
- minimumFractionDigits: 2,
- maximumFractionDigits: 2
- }).format(value / 100);
-};
-
-const MigrationNotice = ({stripeData}: { stripeData: StripeConnectAccountsResponse }) => {
- const caAccount = stripeData.stripe_connect_accounts.find(acc => acc.platform === 'ca');
- const ieAccount = stripeData.stripe_connect_accounts.find(acc => acc.platform === 'ie');
-
- // Only show if Hi.Events user has CA account but no completed IE account
- if (!isHiEvents() || !caAccount || (ieAccount && ieAccount.is_setup_complete)) {
- return null;
- }
-
- return (
-
-
-
-
-
-
-
-
{t`Action Required: Reconnect Your Stripe Account`}
-
-
- {t`We've officially moved our headquarters to Ireland 🇮🇪. As part of this transition, we're now using Stripe Ireland instead of Stripe Canada. To keep your payouts running smoothly, you'll need to reconnect your Stripe account.`}
-
-
-
- {t`Here's what to expect:`}
- • {t`Takes just a few minutes`}
- • {t`No impact on your current or past transactions`}
- • {t`Payments will continue to flow without interruption`}
-
-
-
- {t`Thanks for your support as we continue to grow and improve Hi.Events!`}
-
-
-
-
- );
-};
-
-const MigrationBanner = ({stripeData}: { stripeData: StripeConnectAccountsResponse }) => {
- const caAccount = stripeData.stripe_connect_accounts.find(acc => acc.platform === 'ca');
- const ieAccount = stripeData.stripe_connect_accounts.find(acc => acc.platform === 'ie');
-
- // Only show if user has CA account but no completed IE account
- if (!isHiEvents() || !caAccount || (ieAccount && ieAccount.is_setup_complete)) {
- return null;
- }
-
- return (
-
-
-
-
-
-
-
{t`Complete the setup below to continue`}
-
-
-
-
- {t`Just click the button below to reconnect your Stripe account.`}
-
-
- );
-};
-
-const PlatformPanel = ({
- platform,
- account,
- isActive,
- onSetupStripe,
- hideLabels = false,
- isMigrationComplete = false,
- }: {
- platform: 'ca' | 'ie';
- account: any;
- isActive: boolean;
- onSetupStripe: () => void;
- hideLabels?: boolean;
- isMigrationComplete?: boolean;
-}) => {
- const platformColors = {
- ca: 'orange',
- ie: 'green'
- };
-
- return (
-
-
-
-
- {account?.is_setup_complete ? : }
-
-
-
{t`Stripe Connect`}
- {isActive && !hideLabels && !isMigrationComplete && (
- {t`Current payment processor`}
- )}
-
-
- {!hideLabels && platform === 'ca' && isActive && (
-
- {t`Upgrade Available`}
-
- )}
-
-
- {account?.is_setup_complete ? (
- <>
-
- {hideLabels
- ? t`Your Stripe account is connected and processing payments.`
- : (platform === 'ca'
- ? (isActive
- ? t`You're all set! Your payments are being processed smoothly.`
- : t`Still handling refunds for your older transactions.`
- )
- : t`All done! You're now using our upgraded payment system.`
- )
- }
-
-
-
-
- {t`Open Stripe Dashboard`}
-
-
-
-
- >
- ) : account ? (
- <>
-
- {t`Almost there! Finish connecting your Stripe account to start accepting payments.`}
-
- }
- onClick={onSetupStripe}
- color={platformColors[platform]}
- >
- {t`Finish Setup`}
-
- >
- ) : (
- <>
-
- {hideLabels
- ? t`Connect your Stripe account to start accepting payments.`
- : (platform === 'ca'
- ? t`Connect your Stripe account to accept payments.`
- : t`Ready to upgrade? This takes only a few minutes.`
- )
- }
-
- }
- onClick={onSetupStripe}
- color={platformColors[platform]}
- >
- {platform === 'ie' && !hideLabels ? t`Connect & Upgrade` : t`Connect with Stripe`}
-
- >
- )}
-
- );
-};
-
-const FeePlanDisplay = ({configuration, stripeCountry}: FeePlanDisplayProps) => {
- if (!configuration) return null;
-
- return (
-
-
{t`Platform Fees`}
-
-
- {getConfig("VITE_APP_NAME", "Hi.Events")} charges platform fees to maintain and improve our services.
- These fees are automatically deducted from each transaction.
-
-
-
-
-
- {configuration.name}
-
- {configuration.application_fees.percentage > 0 && (
-
-
-
- {t`Transaction Fee:`}{' '}
-
- {formatPercentage(configuration.application_fees.percentage)}
-
-
-
-
- )}
- {configuration.application_fees.fixed > 0 && (
-
-
-
- {t`Fixed Fee:`}{' '}
-
- {formatCurrency(configuration.application_fees.fixed, configuration.application_fees.currency || 'USD')}
-
-
-
-
- )}
-
-
-
-
-
-
- {t`Fees are subject to change. You will be notified of any changes to your fee structure.`}
-
-
-
- );
-};
-
-// Hi.Events Cloud Multi-Platform Component
-const HiEventsConnectStatus = ({account}: { account: Account }) => {
- const [fetchStripeDetails, setFetchStripeDetails] = useState(false);
- const [platformToSetup, setPlatformToSetup] = useState
();
-
- const stripeAccountsQuery = useGetStripeConnectAccounts(account.id);
- const stripeDetailsQuery = useCreateOrGetStripeConnectDetails(
- account.id,
- fetchStripeDetails,
- platformToSetup
- );
-
- const stripeData = stripeAccountsQuery.data;
- const stripeDetails = stripeDetailsQuery.data;
- const error = stripeDetailsQuery.error as any;
-
- // Check if this is a new user (no platforms set up yet)
- const isNewUser = stripeData &&
- stripeData.stripe_connect_accounts.length === 0 &&
- !stripeData.account.stripe_platform;
-
- const handleSetupStripe = (platform: 'ca' | 'ie') => {
- setPlatformToSetup(platform);
- if (!stripeDetails) {
- setFetchStripeDetails(true);
- return;
- } else if (stripeDetails.connect_url) {
- if (typeof window !== 'undefined') {
- showSuccess(t`Redirecting to Stripe...`);
- window.location.href = stripeDetails.connect_url;
- }
- } else {
- // Setup is already complete, refresh the accounts data
- stripeAccountsQuery.refetch();
- }
- };
-
- useEffect(() => {
- if (fetchStripeDetails && !stripeDetailsQuery.isLoading) {
- setFetchStripeDetails(false);
- if (stripeDetails?.connect_url) {
- showSuccess(t`Redirecting to Stripe...`);
- window.location.href = stripeDetails.connect_url;
- } else if (stripeDetails) {
- if (stripeDetails.is_connect_setup_complete) {
- showSuccess(t`Account already connected!`);
- }
- // Refresh the stripe accounts data to get the new account
- stripeAccountsQuery.refetch();
- }
- }
- }, [fetchStripeDetails, stripeDetailsQuery.isFetched, stripeDetails, stripeAccountsQuery]);
-
- if (error?.response?.status === 403) {
- return (
-
-
-
-
-
- {t`Access Denied`}
-
-
- {error?.response?.data?.message}
-
-
- );
- }
-
- if (!stripeData) {
- return ;
- }
-
- const caAccount = stripeData.stripe_connect_accounts.find(acc => acc.platform === 'ca');
- const ieAccount = stripeData.stripe_connect_accounts.find(acc => acc.platform === 'ie');
- const activePlatform = stripeData.account.stripe_platform;
-
- // For new Hi.Events users with no CA platform (either new or only IE)
- // Show simple setup without migration messaging
- if (isNewUser || (!caAccount && ieAccount)) {
- const hasIrelandAccount = !!ieAccount;
- const isIrelandComplete = ieAccount?.is_setup_complete === true;
-
- let content;
-
- if (isIrelandComplete) {
- // CASE 1: Ireland account exists and is fully set up
- content = (
- <>
-
-
-
-
-
- {t`Connected to Stripe`}
-
-
-
- {t`Your Stripe account is connected and ready to process payments.`}
-
-
-
-
- {t`Open Stripe Dashboard`}
-
-
-
-
- >
- );
- } else if (hasIrelandAccount && !isIrelandComplete) {
- // CASE 2: Ireland account exists but setup is incomplete
- content = (
- <>
-
- {t`Almost there! Finish connecting your Stripe account to start accepting payments.`}
-
- }
- onClick={() => handleSetupStripe('ie')}
- >
- {t`Finish Stripe Setup`}
-
-
-
-
- {t`About Stripe Connect`}
-
-
-
-
- >
- );
- } else {
- // CASE 3: No account exists yet - completely new user
- content = (
- <>
-
- {t`Connect your Stripe account to start accepting payments for your events.`}
-
- }
- onClick={() => handleSetupStripe((account?.stripe_hi_events_primary_platform || 'ie') as 'ca' | 'ie')}
- >
- {t`Connect with Stripe`}
-
-
-
-
- {t`About Stripe Connect`}
-
-
-
-
- >
- );
- }
-
- return (
-
-
{t`Payment Processing`}
- {content}
-
- );
- }
-
- // Migration logic for users with CA account
- const isMigrationComplete = ieAccount?.is_setup_complete === true;
- const shouldShowCaAccount = caAccount?.is_setup_complete === true; // Only show CA if it's complete
-
- return (
-
-
{t`Payment Processing`}
-
-
-
- {/* Show active platform first */}
- {activePlatform === 'ie' ? (
- <>
- {/* Ireland Platform (Active) */}
- handleSetupStripe('ie')}
- hideLabels={isMigrationComplete && !shouldShowCaAccount}
- isMigrationComplete={isMigrationComplete}
- />
-
- {/* Canada Platform (Legacy) - Only show if complete and migration not done */}
- {shouldShowCaAccount && !isMigrationComplete && (
- handleSetupStripe('ca')}
- hideLabels={false}
- />
- )}
- >
- ) : (
- <>
- {/* Canada Platform (Active) - Only show if complete */}
- {shouldShowCaAccount && (
- handleSetupStripe('ca')}
- isMigrationComplete={isMigrationComplete}
- />
- )}
-
- {/* Ireland Platform - Always show if CA is active (for migration) */}
- {shouldShowCaAccount && !isMigrationComplete && (
- handleSetupStripe('ie')}
- />
- )}
-
- {/* If no complete CA account, just show IE setup */}
- {!shouldShowCaAccount && (
- handleSetupStripe('ie')}
- hideLabels={true}
- />
- )}
- >
- )}
-
- {/* Helpful note during migration only */}
- {shouldShowCaAccount && ieAccount && !isMigrationComplete && (
-
- {t`Once you complete the upgrade, your old account will only be used for refunds.`}
-
- )}
-
- );
-};
-
-// Open-Source Simple Component (like original)
-const OpenSourceConnectStatus = ({account}: { account: Account }) => {
- const [fetchStripeDetails, setFetchStripeDetails] = useState(false);
- const [isReturningFromStripe, setIsReturningFromStripe] = useState(false);
- const stripeDetailsQuery = useCreateOrGetStripeConnectDetails(
- account.id,
- !!account?.stripe_account_id || fetchStripeDetails,
- undefined // No platform for open-source
- );
-
- const stripeDetails = stripeDetailsQuery.data;
- const error = stripeDetailsQuery.error as any;
-
- const handleSetupStripe = () => {
- if (!stripeDetails) {
- setFetchStripeDetails(true);
- return;
- } else {
- if (typeof window !== 'undefined') {
- showSuccess(t`Redirecting to Stripe...`);
- window.location.href = String(stripeDetails?.connect_url);
- }
- }
- };
-
- useEffect(() => {
- if (typeof window === 'undefined') {
- return;
- }
- setIsReturningFromStripe(
- window.location.search.includes('is_return') || window.location.search.includes('is_refresh')
- );
- }, []);
-
- useEffect(() => {
- if (fetchStripeDetails && !stripeDetailsQuery.isLoading) {
- setFetchStripeDetails(false);
- showSuccess(t`Redirecting to Stripe...`);
- window.location.href = String(stripeDetails?.connect_url);
- }
- }, [fetchStripeDetails, stripeDetailsQuery.isFetched]);
-
- if (error?.response?.status === 403) {
- return (
-
-
-
-
-
- {t`Access Denied`}
-
-
- {error?.response?.data?.message}
-
-
- );
- }
-
- return (
-
-
{t`Payment Processing`}
-
- {stripeDetails?.is_connect_setup_complete ? (
- <>
-
-
-
-
-
- {t`Connected to Stripe`}
-
-
-
- {t`Your Stripe account is connected and ready to process payments.`}
-
-
-
-
- {t`Open Stripe Dashboard`}
-
-
-
- •
-
-
- {t`Connect Documentation`}
-
-
-
-
- >
- ) : (
- <>
-
- {t`To receive credit card payments, you need to connect your Stripe account. Stripe is our payment processing partner that ensures secure transactions and timely payouts.`}
-
-
- }
- onClick={handleSetupStripe}
- >
- {(!isReturningFromStripe && !account?.stripe_account_id) && t`Connect with Stripe`}
- {(isReturningFromStripe || account?.stripe_account_id) && t`Finish Stripe Setup`}
-
-
-
-
- {t`About Stripe Connect`}
-
-
-
- •
-
-
- {t`Documentation`}
-
-
-
-
-
- >
- )}
-
- );
-};
-
-// Main Component that decides which to show
-const ConnectStatus = ({account}: { account: Account }) => {
- if (isHiEvents()) {
- return ;
- } else {
- return ;
- }
-};
-
-const PaymentSettings = () => {
- const accountQuery = useGetAccount();
- const stripeAccountsQuery = useGetStripeConnectAccounts(
- accountQuery.data?.id || 0,
- {
- enabled: !!accountQuery.data?.id
- }
- );
- const vatSettingQuery = useGetAccountVatSetting(
- accountQuery.data?.id || 0,
- {
- enabled: !!accountQuery.data?.id && isHiEvents()
- }
- );
-
- const [showVatModal, setShowVatModal] = useState(false);
- const [hasCheckedVatModal, setHasCheckedVatModal] = useState(false);
- const hasTrackedStripeConnection = useRef(false);
-
- // Track Stripe connection when user returns with completed setup
- useEffect(() => {
- if (typeof window === 'undefined') return;
- if (hasTrackedStripeConnection.current) return;
- if (!stripeAccountsQuery.data) return;
-
- const urlParams = new URLSearchParams(window.location.search);
- const isReturn = urlParams.get('is_return') === '1';
- if (!isReturn) return;
-
- const completedAccount = stripeAccountsQuery.data.stripe_connect_accounts.find(
- acc => acc.is_setup_complete
- );
-
- if (completedAccount) {
- hasTrackedStripeConnection.current = true;
- trackEvent(AnalyticsEvents.STRIPE_CONNECTED);
- }
- }, [stripeAccountsQuery.data]);
-
- // Check if user is returning from Stripe and needs to fill VAT info
- // Only for Hi.Events Cloud - open-source doesn't have VAT handling
- useEffect(() => {
- if (typeof window === 'undefined') return;
- if (hasCheckedVatModal) return;
- if (!isHiEvents()) {
- setHasCheckedVatModal(true);
- return;
- }
- if (!accountQuery.data || !stripeAccountsQuery.data || vatSettingQuery.isLoading) return;
-
- const urlParams = new URLSearchParams(window.location.search);
- const isReturn = urlParams.get('is_return') === '1';
-
- if (!isReturn) {
- setHasCheckedVatModal(true);
- return;
- }
-
- // Check if Stripe onboarding is complete
- const completedAccount = stripeAccountsQuery.data.stripe_connect_accounts.find(
- acc => acc.is_setup_complete
- );
-
- if (!completedAccount) {
- setHasCheckedVatModal(true);
- return;
- }
-
- // Check if user is in an EU country (not Ireland - they don't need the form)
- const vatInfo = getVatInfo(completedAccount.country);
- if (!vatInfo.isEU || vatInfo.isIreland) {
- setHasCheckedVatModal(true);
- return;
- }
-
- // Check if VAT info is already filled out
- const existingSettings = vatSettingQuery.data;
- if (existingSettings && existingSettings.vat_registered !== null && existingSettings.vat_registered !== undefined) {
- setHasCheckedVatModal(true);
- return;
- }
-
- // All conditions met - show the modal
- setShowVatModal(true);
- setHasCheckedVatModal(true);
- }, [accountQuery.data, stripeAccountsQuery.data, vatSettingQuery.data, vatSettingQuery.isLoading, hasCheckedVatModal]);
-
- const handleVatModalClose = () => {
- setShowVatModal(false);
- vatSettingQuery.refetch();
- };
-
- return (
- <>
- {isHiEvents() && accountQuery.data && (
-
- )}
-
-
-
- {/* Migration Notice - Show at the top for Hi.Events users who need to migrate */}
- {isHiEvents() && stripeAccountsQuery.data && }
-
-
-
- {(accountQuery.data) && (
-
-
-
- {accountQuery.isFetched && (
-
- )}
-
-
- {accountQuery.data?.configuration && (
- acc.is_setup_complete
- )?.country
- }
- />
- )}
-
- {isHiEvents() && (
-
- {accountQuery.data && stripeAccountsQuery.data && (
- acc.is_setup_complete
- )?.country
- }
- />
- )}
-
- )}
-
- )}
-
- >
- );
-};
-
-export default PaymentSettings;
diff --git a/frontend/src/components/routes/admin/Accounts/AccountDetail/OrganizerAdminModal.tsx b/frontend/src/components/routes/admin/Accounts/AccountDetail/OrganizerAdminModal.tsx
new file mode 100644
index 0000000000..60ba794094
--- /dev/null
+++ b/frontend/src/components/routes/admin/Accounts/AccountDetail/OrganizerAdminModal.tsx
@@ -0,0 +1,209 @@
+import {useEffect, useState} from "react";
+import {t} from "@lingui/macro";
+import {Modal, Stack, Group, Select, Button, NumberInput, TextInput, Switch, Tabs, Text, Badge, Divider} from "@mantine/core";
+import {showError, showSuccess} from "../../../../../utilites/notifications";
+import {AdminOrganizerSummary} from "../../../../../api/admin.client";
+import {useGetAllConfigurations} from "../../../../../queries/useGetAllConfigurations";
+import {useAssignOrganizerConfiguration} from "../../../../../mutations/useAssignOrganizerConfiguration";
+import {useUpdateOrganizerConfigurationOverride} from "../../../../../mutations/useUpdateOrganizerConfigurationOverride";
+import {useUpdateAdminOrganizerVatSetting} from "../../../../../mutations/useUpdateAdminOrganizerVatSetting";
+
+interface Props {
+ organizer: AdminOrganizerSummary;
+ onClose: () => void;
+}
+
+export const OrganizerAdminModal = ({organizer, onClose}: Props) => {
+ const {data: configsData} = useGetAllConfigurations();
+ const configurations = configsData?.data ?? [];
+
+ const assignMutation = useAssignOrganizerConfiguration(organizer.id);
+ const overrideMutation = useUpdateOrganizerConfigurationOverride(organizer.id);
+ const vatMutation = useUpdateAdminOrganizerVatSetting(organizer.id);
+
+ const [selectedConfigId, setSelectedConfigId] = useState(
+ organizer.configuration ? String(organizer.configuration.id) : null,
+ );
+ const [overridePercentage, setOverridePercentage] = useState(
+ organizer.configuration?.application_fees?.percentage ?? 0,
+ );
+ const [overrideFixed, setOverrideFixed] = useState(
+ organizer.configuration?.application_fees?.fixed ?? 0,
+ );
+
+ const [vatRegistered, setVatRegistered] = useState(
+ !!organizer.vat_setting?.vat_registered,
+ );
+ const [vatNumber, setVatNumber] = useState(organizer.vat_setting?.vat_number ?? "");
+ const [vatValidated, setVatValidated] = useState(!!organizer.vat_setting?.vat_validated);
+ const [businessName, setBusinessName] = useState(organizer.vat_setting?.business_name ?? "");
+ const [businessAddress, setBusinessAddress] = useState(organizer.vat_setting?.business_address ?? "");
+ const [vatCountryCode, setVatCountryCode] = useState(organizer.vat_setting?.vat_country_code ?? "");
+
+ useEffect(() => {
+ setSelectedConfigId(organizer.configuration ? String(organizer.configuration.id) : null);
+ setOverridePercentage(organizer.configuration?.application_fees?.percentage ?? 0);
+ setOverrideFixed(organizer.configuration?.application_fees?.fixed ?? 0);
+ }, [organizer.configuration]);
+
+ const handleAssign = () => {
+ if (!selectedConfigId) return;
+ assignMutation.mutate(selectedConfigId, {
+ onSuccess: () => showSuccess(t`Configuration assigned`),
+ onError: () => showError(t`Failed to assign configuration`),
+ });
+ };
+
+ const handleOverride = () => {
+ const currency = organizer.configuration?.application_fees?.currency ?? "USD";
+ overrideMutation.mutate(
+ {
+ application_fees: {
+ percentage: overridePercentage,
+ fixed: overrideFixed,
+ currency,
+ },
+ },
+ {
+ onSuccess: () => showSuccess(t`Fee override saved`),
+ onError: () => showError(t`Failed to save override`),
+ },
+ );
+ };
+
+ const handleVatSave = () => {
+ vatMutation.mutate(
+ {
+ vat_registered: vatRegistered,
+ vat_number: vatRegistered ? (vatNumber || null) : null,
+ vat_validated: vatValidated,
+ business_name: businessName || null,
+ business_address: businessAddress || null,
+ vat_country_code: vatCountryCode ? vatCountryCode.toUpperCase() : null,
+ },
+ {
+ onSuccess: () => showSuccess(t`VAT settings updated`),
+ onError: () => showError(t`Failed to update VAT settings`),
+ },
+ );
+ };
+
+ const configOptions = configurations.map((c) => ({
+ value: String(c.id),
+ label: c.is_system_default ? `${c.name} (default)` : c.name,
+ }));
+
+ return (
+
+
+
+ {t`Configuration`}
+ {t`VAT`}
+
+
+
+
+
+ {t`Currently assigned`}
+
+
+ {organizer.configuration?.name ?? t`(none)`}
+
+ {organizer.configuration?.is_system_default && (
+ {t`Default`}
+ )}
+
+
+
+
+
+ {t`Assign plan`}
+
+
+
+
+
+ setOverridePercentage(typeof v === "number" ? v : Number(v) || 0)}
+ min={0}
+ max={100}
+ decimalScale={2}
+ />
+ setOverrideFixed(typeof v === "number" ? v : Number(v) || 0)}
+ min={0}
+ decimalScale={2}
+ />
+
+
+ {t`Saving an override creates a dedicated configuration for this organizer if it's currently on the system default.`}
+
+
+ {t`Save fee override`}
+
+
+
+
+
+
+ setVatRegistered(e.currentTarget.checked)}
+ />
+ setVatNumber(e.currentTarget.value)}
+ disabled={!vatRegistered}
+ placeholder="IE1234567A"
+ />
+ setVatValidated(e.currentTarget.checked)}
+ />
+ setBusinessName(e.currentTarget.value)}
+ />
+ setBusinessAddress(e.currentTarget.value)}
+ />
+ setVatCountryCode(e.currentTarget.value.toUpperCase().slice(0, 2))}
+ maxLength={2}
+ placeholder="IE"
+ />
+
+ {t`Save VAT settings`}
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/components/routes/admin/Accounts/AccountDetail/index.tsx b/frontend/src/components/routes/admin/Accounts/AccountDetail/index.tsx
index 7656cd56e6..a18eb8ef1d 100644
--- a/frontend/src/components/routes/admin/Accounts/AccountDetail/index.tsx
+++ b/frontend/src/components/routes/admin/Accounts/AccountDetail/index.tsx
@@ -1,31 +1,27 @@
import {Container, Title, Stack, Card, Text, Group, Button, Badge, Skeleton, Select} from "@mantine/core";
import {t} from "@lingui/macro";
import {useParams, useNavigate} from "react-router";
+import {useState} from "react";
import {useGetAdminAccount} from "../../../../../queries/useGetAdminAccount";
-import {useGetAllConfigurations} from "../../../../../queries/useGetAllConfigurations";
import {useGetMessagingTiers} from "../../../../../queries/useGetMessagingTiers";
-import {useAssignConfiguration} from "../../../../../mutations/useAssignConfiguration";
import {useUpdateAccountMessagingTier} from "../../../../../mutations/useUpdateAccountMessagingTier";
-import {IconArrowLeft, IconCalendar, IconWorld, IconEdit, IconBuildingBank, IconUsers} from "@tabler/icons-react";
-import {useState} from "react";
-import {EditAccountVatSettingsModal} from "../../../../modals/EditAccountVatSettingsModal";
+import {IconArrowLeft, IconCalendar, IconWorld, IconBuildingBank, IconUsers} from "@tabler/icons-react";
import {showSuccess, showError} from "../../../../../utilites/notifications";
-import {getCurrencySymbol} from "../../../../../utilites/currency";
+import {AdminOrganizerSummary} from "../../../../../api/admin.client";
+import {OrganizerAdminModal} from "./OrganizerAdminModal";
import classes from "./AccountDetail.module.scss";
const AccountDetail = () => {
const {accountId} = useParams();
const navigate = useNavigate();
const {data: accountData, isLoading} = useGetAdminAccount(accountId);
- const {data: configurationsData} = useGetAllConfigurations();
const {data: messagingTiersData} = useGetMessagingTiers();
- const assignConfigMutation = useAssignConfiguration(accountId!);
const updateTierMutation = useUpdateAccountMessagingTier(accountId!);
- const [showVatModal, setShowVatModal] = useState(false);
+ const [managedOrganizer, setManagedOrganizer] = useState(null);
const account = accountData?.data;
- const configurations = configurationsData?.data || [];
const messagingTiers = messagingTiersData?.data || [];
+ const organizers = account?.organizers ?? [];
const formatDate = (dateString?: string) => {
if (!dateString) return '-';
@@ -33,18 +29,6 @@ const AccountDetail = () => {
return date.toLocaleDateString();
};
- const handleConfigurationChange = (value: string | null) => {
- if (!value) return;
-
- assignConfigMutation.mutate(
- {configuration_id: parseInt(value, 10)},
- {
- onSuccess: () => showSuccess(t`Configuration assigned successfully`),
- onError: () => showError(t`Failed to assign configuration`),
- }
- );
- };
-
const handleMessagingTierChange = (value: string | null) => {
if (!value) return;
@@ -89,11 +73,6 @@ const AccountDetail = () => {
);
}
- const configOptions = configurations.map((config) => ({
- value: String(config.id),
- label: config.is_system_default ? `${config.name} (${t`Default`})` : config.name,
- }));
-
const tierOptions = messagingTiers.map((tier) => ({
value: String(tier.id),
label: tier.name,
@@ -171,44 +150,6 @@ const AccountDetail = () => {
-
-
- {t`Application Fees Configuration`}
-
-
-
- {account.configuration && (
-
-
- {t`Fixed Fee`}
-
- {getCurrencySymbol(account.configuration.application_fees?.currency || 'USD')}
- {account.configuration.application_fees?.fixed || 0} {account.configuration.application_fees?.currency || 'USD'}
-
-
-
- {t`Percentage Fee`}
-
- {account.configuration.application_fees?.percentage || 0}%
-
-
-
- )}
-
-
- {t`To edit configurations, go to the Configurations section in the admin menu.`}
-
-
-
-
@@ -255,60 +196,56 @@ const AccountDetail = () => {
-
-
-
- {t`VAT Settings`}
- }
- onClick={() => setShowVatModal(true)}
- >
- {t`Edit`}
-
-
-
- {account.vat_setting ? (
-
-
- {t`VAT Registered`}
-
- {account.vat_setting.vat_registered ? t`Yes` : t`No`}
-
-
- {account.vat_setting.vat_registered && (
- <>
-
- {t`VAT Number`}
- {account.vat_setting.vat_number || '-'}
-
-
- {t`Validated`}
-
- {account.vat_setting.vat_validated ? t`Valid` : t`Invalid`}
-
-
- {account.vat_setting.business_name && (
-
- {t`Business Name`}
- {account.vat_setting.business_name}
-
- )}
- {account.vat_setting.vat_country_code && (
-
- {t`VAT Country`}
- {account.vat_setting.vat_country_code}
-
- )}
- >
- )}
-
- ) : (
- {t`No VAT settings configured`}
- )}
-
-
+ {organizers.length > 0 && (
+
+
+ {t`Organizers`}
+
+ {organizers.map((organizer) => {
+ const fees = organizer.configuration?.application_fees;
+ const vat = organizer.vat_setting;
+ return (
+
+
+ {organizer.name}
+
+ {organizer.configuration && (
+
+ {organizer.configuration.name}
+ {fees ? ` · ${fees.percentage}% + ${fees.fixed} ${fees.currency}` : ""}
+
+ )}
+ {vat?.vat_registered ? (
+
+ {t`VAT: ${vat.vat_number ?? "—"}`}
+ {vat.vat_validated ? ` ✓` : ""}
+
+ ) : (
+
+ {t`VAT: not registered`}
+
+ )}
+
+
+ setManagedOrganizer(organizer)}
+ >
+ {t`Manage`}
+
+
+ );
+ })}
+
+
+
+ )}
{account.users && account.users.length > 0 && (
@@ -333,11 +270,10 @@ const AccountDetail = () => {
- {showVatModal && (
- setShowVatModal(false)}
+ {managedOrganizer && (
+ setManagedOrganizer(null)}
/>
)}
>
diff --git a/frontend/src/components/routes/event/Affiliates/index.tsx b/frontend/src/components/routes/event/Affiliates/index.tsx
index e036786a99..2f9ba12ba8 100644
--- a/frontend/src/components/routes/event/Affiliates/index.tsx
+++ b/frontend/src/components/routes/event/Affiliates/index.tsx
@@ -18,6 +18,7 @@ import {affiliateClient} from "../../../../api/affiliate.client.ts";
import {downloadBinary} from "../../../../utilites/download.ts";
import {withLoadingNotification} from "../../../../utilites/withLoadingNotification.tsx";
import {useState} from "react";
+import {SortSelector} from "../../../common/SortSelector";
const Affiliates = () => {
const {eventId} = useParams();
@@ -63,14 +64,26 @@ const Affiliates = () => {
{t`Affiliates`}
- (
-
- )}>
+ (
+
+ )}
+ filterComponent={pagination?.allowed_sorts ? (
+ {
+ setSearchParams({sortBy: key, sortDirection});
+ }}
+ />
+ ) : undefined}
+ >
handleExport(eventId)}
rightSection={}
diff --git a/frontend/src/components/routes/event/CapacityAssignments/index.tsx b/frontend/src/components/routes/event/CapacityAssignments/index.tsx
index 6ec3a8b5f6..aa677fa27a 100644
--- a/frontend/src/components/routes/event/CapacityAssignments/index.tsx
+++ b/frontend/src/components/routes/event/CapacityAssignments/index.tsx
@@ -14,6 +14,7 @@ import {IconPlus} from "@tabler/icons-react";
import {useFilterQueryParamSync} from "../../../../hooks/useFilterQueryParamSync.ts";
import {QueryFilters} from "../../../../types.ts";
import {Pagination} from "../../../common/Pagination";
+import {SortSelector} from "../../../common/SortSelector";
const CapacityAssignments = () => {
const {eventId} = useParams();
@@ -34,14 +35,26 @@ const CapacityAssignments = () => {
{t`Shared Capacity Management`}
- (
-
- )}>
+ (
+
+ )}
+ filterComponent={pagination?.allowed_sorts ? (
+ {
+ setSearchParams({sortBy: key, sortDirection});
+ }}
+ />
+ ) : undefined}
+ >
}
color={'green'}
diff --git a/frontend/src/components/routes/event/CheckInLists/index.tsx b/frontend/src/components/routes/event/CheckInLists/index.tsx
index dc20aa9280..6bc543cfb6 100644
--- a/frontend/src/components/routes/event/CheckInLists/index.tsx
+++ b/frontend/src/components/routes/event/CheckInLists/index.tsx
@@ -12,18 +12,21 @@ import {useFilterQueryParamSync} from "../../../../hooks/useFilterQueryParamSync
import {QueryFilters} from "../../../../types.ts";
import {Pagination} from "../../../common/Pagination";
import {useGetEventCheckInLists} from "../../../../queries/useGetCheckInLists.ts";
-import {CheckInListList} from "../../../common/CheckInListList";
+import {useGetEvent} from "../../../../queries/useGetEvent.ts";
+import {CheckInListTable} from "../../../common/CheckInListTable";
import {CreateCheckInListModal} from "../../../modals/CreateCheckInListModal";
+import {SortSelector} from "../../../common/SortSelector";
const CheckInLists = () => {
const {eventId} = useParams();
+ const {data: event} = useGetEvent(eventId);
const [searchParams, setSearchParams] = useFilterQueryParamSync();
- const {data: checkInListsData} = useGetEventCheckInLists(
+ const checkInListsQuery = useGetEventCheckInLists(
eventId,
searchParams as QueryFilters,
);
- const checkInLists = checkInListsData?.data;
- const pagination = checkInListsData?.meta;
+ const checkInLists = checkInListsQuery?.data?.data;
+ const pagination = checkInListsQuery?.data?.meta;
const [createModalOpen, {open: openCreateModal, close: closeCreateModal}] = useDisclosure(false);
return (
@@ -34,31 +37,47 @@ const CheckInLists = () => {
{t`Check-In Lists`}
- (
-
- )}>
+ (
+
+ )}
+ filterComponent={pagination?.allowed_sorts ? (
+ {
+ setSearchParams({sortBy: key, sortDirection});
+ }}
+ />
+ ) : undefined}
+ resultCount={pagination?.total}
+ resultLabel={t`check-in lists`}
+ >
}
color={'green'}
+ size={'sm'}
onClick={openCreateModal}>{t`Create Check-In List`}
-
+
- {checkInLists && }
{createModalOpen && }
- {(!!checkInLists?.length && (pagination?.total || 0) >= 20) && (
+ {!!checkInLists?.length && (
setSearchParams({pageNumber: value})}
total={Number(pagination?.last_page)}
diff --git a/frontend/src/components/routes/event/EventDashboard/EventDashboard.module.scss b/frontend/src/components/routes/event/EventDashboard/EventDashboard.module.scss
index c62de9c2f4..7e6dbfe920 100644
--- a/frontend/src/components/routes/event/EventDashboard/EventDashboard.module.scss
+++ b/frontend/src/components/routes/event/EventDashboard/EventDashboard.module.scss
@@ -27,6 +27,24 @@
margin-top: 20px;
}
+.sectionLabel {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--mantine-color-dimmed);
+ margin: 4px 0 10px;
+}
+
+.sectionLabelRange {
+ font-weight: 500;
+ letter-spacing: 0;
+ text-transform: none;
+}
+
.setupCard {
margin-bottom: 28px;
margin-top: 24px;
@@ -223,139 +241,3 @@
}
}
-.stripeUpgradeCard {
- margin: 24px 0;
- background: linear-gradient(135deg, var(--mantine-color-primary-0) 0%, var(--mantine-color-secondary-0) 100%);
- border: 1px solid var(--mantine-color-primary-1);
- box-shadow: 0 4px 16px var(--mantine-color-primary-1) !important;
- color: var(--mantine-color-gray-9);
- padding: 28px 32px;
- border-radius: 16px;
- position: relative;
- overflow: hidden;
-
- &::before {
- content: '';
- position: absolute;
- top: 0;
- right: 0;
- width: 200px;
- height: 200px;
- background: radial-gradient(circle, var(--mantine-color-primary-0) 0%, transparent 70%);
- border-radius: 50%;
- transform: translate(50%, -50%);
- }
-}
-
-.stripeUpgradeContent {
- display: flex;
- align-items: flex-start;
- gap: 24px;
- position: relative;
- z-index: 1;
-
- @media (max-width: 768px) {
- flex-direction: column;
- gap: 20px;
- }
-}
-
-.stripeTextContainer {
- flex: 1;
- display: flex;
- flex-direction: column;
- gap: 20px;
-
- @media (max-width: 768px) {
- gap: 24px;
- }
-}
-
-.stripeIcon {
- flex-shrink: 0;
- width: 48px;
- height: 48px;
- background: linear-gradient(135deg, var(--mantine-color-primary-1), var(--mantine-color-secondary-1));
- backdrop-filter: blur(10px);
- border-radius: 12px;
- display: flex;
- align-items: center;
- justify-content: center;
- color: var(--mantine-color-secondary-5);
- border: 1px solid var(--mantine-color-primary-2);
-
- svg {
- width: 28px;
- height: 28px;
- }
-}
-
-.stripeText {
- flex: 1;
-
- h3 {
- margin: 0 0 8px 0;
- font-size: 1.25rem;
- font-weight: 600;
- color: var(--mantine-color-gray-9);
- letter-spacing: -0.02em;
- }
-
- p {
- margin: 0;
- font-size: 0.95rem;
- line-height: 1.6;
- color: var(--mantine-color-gray-7);
- max-width: 600px;
- }
-
- .stripeApology {
- margin-top: 8px !important;
- font-size: 0.875rem !important;
- font-style: italic;
- color: var(--mantine-color-gray-6) !important;
- }
-}
-
-.stripeButton {
- align-self: flex-start;
-
- background: linear-gradient(135deg, var(--mantine-color-secondary-5) 0%, var(--mantine-color-primary-5) 100%) !important;
- color: white !important;
- font-weight: 600;
- border: none !important;
- padding: 12px 24px;
- transition: all 0.2s ease;
- box-shadow: 0 4px 12px var(--mantine-color-secondary-2), 0 2px 4px var(--mantine-color-secondary-1) !important;
- min-width: 180px;
- border-radius: 8px !important;
-
- &:hover:not(:disabled) {
- transform: translateY(-2px);
- box-shadow: 0 6px 20px var(--mantine-color-secondary-3), 0 4px 8px var(--mantine-color-secondary-2) !important;
- background: linear-gradient(135deg, var(--mantine-color-secondary-6) 0%, var(--mantine-color-primary-6) 100%) !important;
- }
-
- &:disabled {
- background: linear-gradient(135deg, var(--mantine-color-primary-2) 0%, var(--mantine-color-primary-1) 100%) !important;
- color: var(--mantine-color-gray-5) !important;
- transform: none;
- box-shadow: 0 2px 8px var(--mantine-color-primary-1) !important;
- opacity: 0.7;
- }
-
- &[data-loading="true"] {
- background: linear-gradient(135deg, var(--mantine-color-primary-4) 0%, var(--mantine-color-primary-3) 100%) !important;
- color: white !important;
- box-shadow: 0 2px 8px var(--mantine-color-primary-2) !important;
- }
-
- @media (max-width: 768px) {
- align-self: stretch;
-
- :global(button) {
- width: 100%;
- min-width: unset;
- }
- }
-}
diff --git a/frontend/src/components/routes/event/EventDashboard/NextOccurrenceHero/NextOccurrenceHero.module.scss b/frontend/src/components/routes/event/EventDashboard/NextOccurrenceHero/NextOccurrenceHero.module.scss
new file mode 100644
index 0000000000..85b0f8f50a
--- /dev/null
+++ b/frontend/src/components/routes/event/EventDashboard/NextOccurrenceHero/NextOccurrenceHero.module.scss
@@ -0,0 +1,183 @@
+.hero {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ padding: 14px 18px;
+ margin-bottom: 20px;
+ border-radius: 12px;
+ background: #fff;
+ border: 1px solid var(--mantine-color-gray-2);
+
+ @media (max-width: 720px) {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 12px;
+ padding: 14px;
+ }
+}
+
+// Label ------------------------------------------------------------------
+
+.label {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ flex-shrink: 0;
+ min-width: 130px;
+}
+
+.labelTop {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--mantine-color-dimmed);
+}
+
+// Each state swaps the accent colour used by the icon, progress bar, and
+// pulse dot. Keeping the mapping here makes future states a one-line change.
+.labelIcon { color: var(--mantine-color-violet-6); }
+.state_future .labelIcon { color: var(--mantine-color-violet-6); }
+.state_imminent .labelIcon { color: var(--mantine-color-orange-6); }
+.state_live .labelIcon { color: var(--mantine-color-green-6); }
+.state_wrapping .labelIcon { color: var(--mantine-color-gray-6); }
+
+.countdown {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--mantine-color-text);
+}
+
+.pulseDot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--mantine-color-green-6);
+ position: relative;
+ flex-shrink: 0;
+
+ &::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: 50%;
+ background: var(--mantine-color-green-6);
+ animation: hero_pulse 1.6s ease-out infinite;
+ }
+}
+
+@keyframes hero_pulse {
+ 0% { transform: scale(1); opacity: 0.9; }
+ 100% { transform: scale(2.4); opacity: 0; }
+}
+
+// Details (date + time) --------------------------------------------------
+
+.details {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.dateLine {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--mantine-color-dimmed);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.timeLine {
+ font-size: 17px;
+ font-weight: 700;
+ color: var(--mantine-color-text);
+ letter-spacing: -0.01em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.occurrenceLabel {
+ font-size: 12px;
+ color: var(--mantine-color-dimmed);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-top: 2px;
+}
+
+// Stats + progress bar ---------------------------------------------------
+
+.stats {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-width: 160px;
+
+ @media (max-width: 720px) {
+ min-width: 0;
+ }
+}
+
+.statLine {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--mantine-color-text);
+}
+
+.statIcon {
+ color: var(--mantine-color-dimmed);
+ flex-shrink: 0;
+}
+
+.statSecondary {
+ color: var(--mantine-color-dimmed);
+ font-weight: 500;
+}
+
+// A slim bar communicates progress at a glance without demanding attention.
+.bar {
+ position: relative;
+ height: 4px;
+ border-radius: 999px;
+ background: var(--mantine-color-gray-2);
+ overflow: hidden;
+}
+
+.barFill {
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ background: var(--mantine-color-violet-6);
+ border-radius: 999px;
+ transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.state_imminent .barFill { background: var(--mantine-color-orange-6); }
+.state_live .barFill { background: var(--mantine-color-green-6); }
+.state_wrapping .barFill { background: var(--mantine-color-gray-6); }
+
+// CTA --------------------------------------------------------------------
+
+.cta {
+ flex-shrink: 0;
+ font-weight: 600;
+
+ @media (max-width: 720px) {
+ width: 100%;
+ }
+}
diff --git a/frontend/src/components/routes/event/EventDashboard/NextOccurrenceHero/index.tsx b/frontend/src/components/routes/event/EventDashboard/NextOccurrenceHero/index.tsx
new file mode 100644
index 0000000000..aa804943dc
--- /dev/null
+++ b/frontend/src/components/routes/event/EventDashboard/NextOccurrenceHero/index.tsx
@@ -0,0 +1,247 @@
+import {useEffect, useMemo, useState} from "react";
+import {useNavigate} from "react-router";
+import {t} from "@lingui/macro";
+import {Button, Skeleton} from "@mantine/core";
+import {
+ IconArrowRight,
+ IconBroadcast,
+ IconCalendarEvent,
+ IconUsers,
+} from "@tabler/icons-react";
+import dayjs from "dayjs";
+import utc from "dayjs/plugin/utc";
+import timezone from "dayjs/plugin/timezone";
+import {Event, EventOccurrence, EventOccurrenceStatus, IdParam} from "../../../../../types.ts";
+import {useGetEventOccurrence} from "../../../../../queries/useGetEventOccurrence.ts";
+import {useGetEventCheckInLists} from "../../../../../queries/useGetCheckInLists.ts";
+import {formatDateWithLocale} from "../../../../../utilites/dates.ts";
+import {launchCheckInForOccurrence} from "../../OccurrencesTab/checkInLaunch.tsx";
+import {CreateCheckInListModal} from "../../../../modals/CreateCheckInListModal";
+import classes from "./NextOccurrenceHero.module.scss";
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+
+interface NextOccurrenceHeroProps {
+ event: Event;
+ eventId: IdParam;
+}
+
+type UrgencyState = 'future' | 'imminent' | 'live' | 'wrapping';
+
+interface Countdown {
+ state: UrgencyState;
+ primary: string;
+ secondary?: string;
+ /** How often to re-render — tighter windows tick faster. */
+ tickMs: number;
+}
+
+const computeCountdown = (occurrence: EventOccurrence, tz: string, now: dayjs.Dayjs): Countdown => {
+ const start = dayjs.utc(occurrence.start_date).tz(tz);
+ const end = occurrence.end_date ? dayjs.utc(occurrence.end_date).tz(tz) : null;
+
+ // Live — between start and end (or start and start+8h if no end).
+ const effectiveEnd = end ?? start.add(8, 'hour');
+ if (now.isAfter(start) && now.isBefore(effectiveEnd)) {
+ return {
+ state: 'live',
+ primary: t`Happening now`,
+ secondary: end ? t`Ends ${end.from(now)}` : undefined,
+ tickMs: 60_000,
+ };
+ }
+
+ // Just wrapped — within an hour of ending, so "you just wrapped!"
+ if (end && now.isAfter(end) && now.diff(end, 'hour') < 1) {
+ return {
+ state: 'wrapping',
+ primary: t`That's a wrap`,
+ secondary: t`Ended ${end.from(now)}`,
+ tickMs: 60_000,
+ };
+ }
+
+ const diffMs = start.diff(now);
+ const diffMinutes = start.diff(now, 'minute');
+ const diffHours = start.diff(now, 'hour');
+ const diffDays = start.diff(now, 'day');
+
+ // Imminent: under an hour → live ticking
+ if (diffMs < 60 * 60 * 1000) {
+ if (diffMinutes < 1) {
+ const seconds = Math.max(0, Math.floor(diffMs / 1000));
+ return {
+ state: 'imminent',
+ primary: t`Starts in ${seconds}s`,
+ tickMs: 1_000,
+ };
+ }
+ return {
+ state: 'imminent',
+ primary: t`Starts in ${diffMinutes} min`,
+ tickMs: 1_000,
+ };
+ }
+
+ // Today or tomorrow
+ if (diffHours < 24) {
+ const h = Math.floor(diffMs / (1000 * 60 * 60));
+ const m = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
+ return {
+ state: 'imminent',
+ primary: t`Starts in ${h}h ${m}m`,
+ tickMs: 60_000,
+ };
+ }
+
+ // > 24h: days countdown
+ return {
+ state: 'future',
+ primary: diffDays === 1 ? t`Starts tomorrow` : t`Starts in ${diffDays} days`,
+ tickMs: 60_000,
+ };
+};
+
+const pickNextOccurrence = (occurrences: EventOccurrence[] | undefined): EventOccurrence | null => {
+ if (!occurrences || occurrences.length === 0) return null;
+ const now = dayjs();
+
+ // Prefer an in-progress occurrence so mid-event views surface the live one.
+ const live = occurrences
+ .filter(o => o.status === EventOccurrenceStatus.ACTIVE)
+ .find(o => {
+ const s = dayjs.utc(o.start_date);
+ const e = o.end_date ? dayjs.utc(o.end_date) : s.add(8, 'hour');
+ return now.isAfter(s) && now.isBefore(e);
+ });
+ if (live) return live;
+
+ const upcoming = occurrences
+ .filter(o => o.status === EventOccurrenceStatus.ACTIVE && !o.is_past)
+ .sort((a, b) => a.start_date.localeCompare(b.start_date));
+ return upcoming[0] ?? null;
+};
+
+export const NextOccurrenceHero = ({event, eventId}: NextOccurrenceHeroProps) => {
+ const navigate = useNavigate();
+ const tz = event.timezone;
+
+ const nextOccurrence = useMemo(() => pickNextOccurrence(event.occurrences), [event.occurrences]);
+
+ // Fetch fresh stats so the capacity bar reflects reality on every view.
+ const occurrenceQuery = useGetEventOccurrence(eventId, nextOccurrence?.id);
+ const occurrence = occurrenceQuery.data ?? nextOccurrence;
+
+ const checkInListsQuery = useGetEventCheckInLists(eventId);
+ const checkInLists = checkInListsQuery?.data?.data;
+ const [createCheckInForOccurrenceId, setCreateCheckInForOccurrenceId] = useState();
+
+ const [now, setNow] = useState(() => dayjs());
+ const countdown = useMemo(
+ () => occurrence ? computeCountdown(occurrence, tz, now) : null,
+ [occurrence, tz, now],
+ );
+
+ // Tick rate matches urgency — 1s under a minute, 60s otherwise.
+ useEffect(() => {
+ if (!countdown) return;
+ const id = setInterval(() => setNow(dayjs()), countdown.tickMs);
+ return () => clearInterval(id);
+ }, [countdown?.tickMs]);
+
+ if (occurrenceQuery.isLoading && nextOccurrence) {
+ return ;
+ }
+
+ if (!occurrence || !countdown) {
+ return null;
+ }
+
+ const stats = occurrence.statistics;
+ const registered = stats?.attendees_registered ?? 0;
+ const capacity = occurrence.capacity ?? 0;
+ const percent = capacity > 0 ? Math.min(100, Math.round((registered / capacity) * 100)) : 0;
+
+ // dayName already includes the date ("Thursday, April 23"); fall back to
+ // shortDate for locales where it returns empty.
+ const dateLine = formatDateWithLocale(occurrence.start_date, 'dayName', tz)
+ || formatDateWithLocale(occurrence.start_date, 'shortDate', tz);
+ const timeLine = formatDateWithLocale(occurrence.start_date, 'timeOnly', tz)
+ + (occurrence.end_date ? ' – ' + formatDateWithLocale(occurrence.end_date, 'timeOnly', tz) : '');
+
+ const labelText = countdown.state === 'live'
+ ? t`Happening now`
+ : countdown.state === 'wrapping'
+ ? t`Just wrapped`
+ : t`Next occurrence`;
+
+ return (
+
+
+
+ {countdown.state === 'live'
+ ?
+ : }
+ {labelText}
+
+
+ {countdown.state === 'live' && }
+ {countdown.primary}
+
+
+
+
+
{dateLine}
+
{timeLine}
+ {occurrence.label &&
{occurrence.label}
}
+
+
+
+
+
+ {capacity > 0 ? (
+ <>
+ {registered} / {capacity}
+ · {percent}%
+ >
+ ) : (
+ {registered} {registered === 1 ? t`attendee` : t`attendees`}
+ )}
+
+ {capacity > 0 && (
+
+ )}
+
+
+
}
+ onClick={() => {
+ if (countdown.state === 'live') {
+ launchCheckInForOccurrence({
+ occurrenceId: Number(occurrence.id),
+ checkInLists,
+ onCreateForOccurrence: setCreateCheckInForOccurrenceId,
+ });
+ return;
+ }
+ navigate(`/manage/event/${eventId}/occurrences/${occurrence.id}`);
+ }}
+ size="sm"
+ classNames={{root: classes.cta}}
+ >
+ {countdown.state === 'live' ? t`Open check-in` : t`Open occurrence`}
+
+
+ {createCheckInForOccurrenceId && (
+
setCreateCheckInForOccurrenceId(undefined)}
+ initialOccurrenceId={createCheckInForOccurrenceId}
+ />
+ )}
+
+ );
+};
diff --git a/frontend/src/components/routes/event/EventDashboard/UpcomingOccurrences/UpcomingOccurrences.module.scss b/frontend/src/components/routes/event/EventDashboard/UpcomingOccurrences/UpcomingOccurrences.module.scss
new file mode 100644
index 0000000000..427f012c1e
--- /dev/null
+++ b/frontend/src/components/routes/event/EventDashboard/UpcomingOccurrences/UpcomingOccurrences.module.scss
@@ -0,0 +1,169 @@
+.upcomingCard {
+ margin-top: 20px;
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 16px;
+
+ h2 {
+ margin: 0;
+ font-size: 1.1rem;
+ font-weight: 600;
+ color: var(--mantine-color-text);
+ }
+}
+
+.occurrenceList {
+ display: flex;
+ flex-direction: column;
+}
+
+.occurrenceRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 8px;
+ margin: 0 -8px;
+ border-bottom: 1px solid var(--mantine-color-gray-1);
+ gap: 16px;
+ cursor: pointer;
+ border-radius: 6px;
+ transition: background-color 0.15s ease;
+
+ &:hover {
+ background-color: var(--mantine-color-gray-0);
+ }
+
+ &:last-child {
+ border-bottom: none;
+ padding-bottom: 10px;
+ }
+
+ &:first-child {
+ padding-top: 10px;
+ }
+}
+
+.occurrenceInfo {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
+ flex: 1;
+}
+
+.dateBlock {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-width: 44px;
+ padding: 4px 8px;
+ background: var(--mantine-color-gray-0);
+ border-radius: 6px;
+ flex-shrink: 0;
+}
+
+.dateMonth {
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: var(--mantine-color-primary-6);
+ line-height: 1.2;
+}
+
+.dateDay {
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--mantine-color-text);
+ line-height: 1.2;
+}
+
+.details {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+
+.timeLabel {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--mantine-color-text);
+ white-space: nowrap;
+}
+
+.suffix {
+ font-size: 12px;
+ color: var(--mantine-color-dimmed);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.occurrenceRight {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-shrink: 0;
+}
+
+.capacityBadge {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 12px;
+ color: var(--mantine-color-dimmed);
+ white-space: nowrap;
+
+ .sold {
+ font-weight: 600;
+ color: var(--mantine-color-text);
+ }
+}
+
+.statusDot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+
+ &[data-status="ACTIVE"] {
+ background: var(--mantine-color-green-5);
+ }
+
+ &[data-status="CANCELLED"] {
+ background: var(--mantine-color-red-5);
+ }
+
+ &[data-status="SOLD_OUT"] {
+ background: var(--mantine-color-orange-5);
+ }
+}
+
+.quickLinks {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+}
+
+.emptyState {
+ text-align: center;
+ padding: 20px 0 4px;
+}
+
+@media (max-width: 768px) {
+ .occurrenceRow {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+ }
+
+ .occurrenceRight {
+ width: 100%;
+ justify-content: space-between;
+ }
+}
diff --git a/frontend/src/components/routes/event/EventDashboard/UpcomingOccurrences/index.tsx b/frontend/src/components/routes/event/EventDashboard/UpcomingOccurrences/index.tsx
new file mode 100644
index 0000000000..aa3bfd19ee
--- /dev/null
+++ b/frontend/src/components/routes/event/EventDashboard/UpcomingOccurrences/index.tsx
@@ -0,0 +1,66 @@
+import {useState} from "react";
+import {t} from "@lingui/macro";
+import {Anchor, Skeleton, Text} from "@mantine/core";
+import {useNavigate} from "react-router";
+import dayjs from "dayjs";
+import {Card} from "../../../../common/Card";
+import {useGetEventOccurrences} from "../../../../../queries/useGetEventOccurrences.ts";
+import {Event, IdParam} from "../../../../../types.ts";
+import {CalendarView} from "../../OccurrencesTab/CalendarView";
+import classes from "./UpcomingOccurrences.module.scss";
+
+interface UpcomingOccurrencesProps {
+ eventId: IdParam;
+ event: Event;
+}
+
+export const UpcomingOccurrences = ({eventId, event}: UpcomingOccurrencesProps) => {
+ const navigate = useNavigate();
+ const [currentMonth, setCurrentMonth] = useState(() => dayjs().startOf('month'));
+ const {data: occurrencesData, isLoading} = useGetEventOccurrences(eventId, {
+ pageNumber: 1,
+ perPage: 500,
+ sortBy: 'start_date',
+ sortDirection: 'asc',
+ });
+
+ const occurrences = occurrencesData?.data || [];
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
{t`Schedule`}
+
navigate(`/manage/event/${eventId}/occurrences`)}
+ >
+ {t`Manage Schedule`}
+
+
+
+ {occurrences.length === 0 ? (
+
+
+ {t`No dates scheduled.`}
+
+
+ ) : (
+ navigate(`/manage/event/${eventId}/occurrences/${id}`)}
+ />
+ )}
+
+ );
+};
diff --git a/frontend/src/components/routes/event/EventDashboard/index.tsx b/frontend/src/components/routes/event/EventDashboard/index.tsx
index aaa2e4d057..e1edea387f 100644
--- a/frontend/src/components/routes/event/EventDashboard/index.tsx
+++ b/frontend/src/components/routes/event/EventDashboard/index.tsx
@@ -13,16 +13,17 @@ import {formatCurrency} from "../../../../utilites/currency.ts";
import {formatDateWithLocale} from "../../../../utilites/dates.ts";
import {Button, SegmentedControl, Skeleton, Tooltip} from "@mantine/core";
import {useMediaQuery} from "@mantine/hooks";
-import {IconAlertCircle, IconX} from "@tabler/icons-react";
+import {IconX} from "@tabler/icons-react";
import {useGetAccount} from "../../../../queries/useGetAccount.ts";
import {useUpdateEventStatus} from "../../../../mutations/useUpdateEventStatus.ts";
import {confirmationDialog} from "../../../../utilites/confirmationDialog.tsx";
import {showError, showSuccess} from "../../../../utilites/notifications.tsx";
import {useEffect, useRef, useState} from 'react';
-import {EventLifecycleStatus, EventStatus, StripePlatform} from "../../../../types.ts";
-import {isHiEvents} from "../../../../utilites/helpers.ts";
-import {StripeConnectButton} from "../../../common/StripeConnectButton";
+import {EventLifecycleStatus, EventStatus, EventType} from "../../../../types.ts";
+import {UpcomingOccurrences} from "./UpcomingOccurrences";
+import {NextOccurrenceHero} from "./NextOccurrenceHero";
import {trackEvent, AnalyticsEvents} from "../../../../utilites/analytics.ts";
+import {useGetOrganizer} from "../../../../queries/useGetOrganizer.ts";
export const DashBoardSkeleton = () => {
return (
@@ -47,7 +48,10 @@ export const EventDashboard = () => {
const [dateRange, setDateRange] = useState(null);
const effectiveDateRange = dateRange ?? defaultDateRangeRef.current ?? 'last_30_days';
- const eventStatsQuery = useGetEventStats(eventId, effectiveDateRange, !!defaultDateRangeRef.current);
+ const eventStatsQuery = useGetEventStats(eventId, {
+ dateRange: effectiveDateRange,
+ enabled: !!defaultDateRangeRef.current,
+ });
const {data: eventStats} = eventStatsQuery;
const isMobile = useMediaQuery('(max-width: 768px)');
const {data: account, isFetched: accountIsFetched} = useGetAccount();
@@ -56,9 +60,9 @@ export const EventDashboard = () => {
const [isChecklistVisible, setIsChecklistVisible] = useState(true);
const [isMounted, setIsMounted] = useState(false);
- const showStripeUpgradeNotice = account?.stripe_platform === StripePlatform.Canada.valueOf()
- && account?.stripe_connect_setup_complete
- && isHiEvents();
+ const organizerId = event?.organizer_id;
+ const {data: organizer} = useGetOrganizer(organizerId);
+ const isStripeConnected = !!organizer?.stripe_connect_setup_complete;
useEffect(() => {
setIsMounted(true);
@@ -104,7 +108,7 @@ export const EventDashboard = () => {
: '';
const shouldShowChecklist = (isChecklistVisible && event && accountIsFetched && account?.is_saas_mode_enabled) && (
- !account?.stripe_connect_setup_complete ||
+ !isStripeConnected ||
event?.status !== 'LIVE'
);
@@ -126,33 +130,25 @@ export const EventDashboard = () => {
{!event && }
- {showStripeUpgradeNotice && (
-
-
-
-
-
-
-
-
{t`Important: Stripe reconnection required`}
-
{t`We've relocated our headquarters to Ireland. As a result, we need you to reconnect your Stripe account. This quick process takes just a few minutes. Your sales and existing data remain completely unaffected.`}
-
{t`Sorry for the inconvenience.`}
-
-
-
-
-
- )}
-
{event && (<>
+ {event?.type === EventType.RECURRING && (
+
+ )}
+
+ {/* Scope label — makes it unambiguous that the stat boxes
+ aggregate across the whole event for the current date range,
+ not the specific next session shown in the hero above. */}
+
+ {t`Event totals`}
+ {dateRangeLabel && · {dateRangeLabel} }
+
+
+ {event?.type === EventType.RECURRING && (
+
+ )}
+
{shouldShowChecklist && (
{
- {account?.stripe_connect_setup_complete && (
+ {isStripeConnected && (
{
{t`Connect payment processing`}
{t`Link your Stripe account to receive funds from ticket sales.`}
- {!account?.stripe_connect_setup_complete && (
+ {!isStripeConnected && organizerId && (
{
- window.location.href = '/account/payment';
+ window.location.href = `/manage/organizer/${organizerId}/settings#payouts`;
}}
variant="light"
size="sm"
radius="md"
fullWidth
>
- {account?.stripe_account_id ? t`Complete Stripe Setup` : t`Connect to Stripe`}
+ {organizer?.stripe_account_id ? t`Complete Stripe Setup` : t`Connect to Stripe`}
)}
diff --git a/frontend/src/components/routes/event/GettingStarted/index.tsx b/frontend/src/components/routes/event/GettingStarted/index.tsx
index bfa019753c..07a59a8d83 100644
--- a/frontend/src/components/routes/event/GettingStarted/index.tsx
+++ b/frontend/src/components/routes/event/GettingStarted/index.tsx
@@ -16,6 +16,9 @@ import {useEffect, useState} from 'react';
import ConfettiAnimation from "./ConfettiAnimaiton";
import {Browser, useBrowser} from "../../../../hooks/useGetBrowser.ts";
import {trackEvent, AnalyticsEvents} from "../../../../utilites/analytics.ts";
+import {EventType} from "../../../../types.ts";
+import {useGetEventOccurrences} from "../../../../queries/useGetEventOccurrences.ts";
+import {useGetOrganizer} from "../../../../queries/useGetOrganizer.ts";
const GettingStarted = () => {
const {eventId} = useParams();
@@ -50,7 +53,13 @@ const GettingStarted = () => {
const hasImages = eventImages && eventImages.length > 0;
const accountQuery = useGetAccount();
const account = accountQuery.data;
+ const organizerId = event?.organizer_id;
+ const {data: organizer} = useGetOrganizer(organizerId);
+ const isStripeConnected = !!organizer?.stripe_connect_setup_complete;
const statusToggleMutation = useUpdateEventStatus();
+ const isRecurring = event?.type === EventType.RECURRING;
+ const occurrencesQuery = useGetEventOccurrences(eventId, {pageNumber: 1, perPage: 1});
+ const hasOccurrences = isRecurring && (occurrencesQuery?.data?.data?.length ?? 0) > 0;
const handleStatusToggle = () => {
const newStatus = event?.status === 'LIVE' ? 'DRAFT' : 'LIVE';
@@ -99,14 +108,18 @@ const GettingStarted = () => {
{
+ const steps = [
+ hasProducts,
+ event?.description,
+ ...(account?.is_saas_mode_enabled ? [isStripeConnected] : []),
+ hasImages,
+ event?.status === 'LIVE',
+ account?.is_account_email_confirmed,
+ ...(isRecurring ? [hasOccurrences] : []),
+ ];
+ return steps.filter(Boolean).length / steps.length * 100;
+ })()}
size="md"
radius="xl"
className={classes.progressBar}
@@ -133,6 +146,22 @@ const GettingStarted = () => {
+ {isRecurring && (
+
+ {hasOccurrences && }
+
+ {t`📅 Set up your schedule`}
+
+
+ {t`Generate or add dates and times for your recurring event.`}
+
+
+ {hasOccurrences ? t`Manage schedule` : t`Set up schedule`}
+
+
+ )}
+
{event?.description && }
@@ -146,20 +175,22 @@ const GettingStarted = () => {
-
- {account?.stripe_connect_setup_complete && }
-
- {t`💳 Connect with Stripe`}
-
-
- {t`Connect your Stripe account to start receiving payments.`}
-
- {!account?.stripe_connect_setup_complete && (
-
- {t`Connect with Stripe`}
- )
- }
-
+ {account?.is_saas_mode_enabled && (
+
+ {isStripeConnected && }
+
+ {t`💳 Connect with Stripe`}
+
+
+ {t`Connect your Stripe account to start receiving payments.`}
+
+ {!isStripeConnected && organizerId && (
+
+ {t`Connect with Stripe`}
+
+ )}
+
+ )}
{hasImages && }
diff --git a/frontend/src/components/routes/event/OccurrenceDetail/OccurrenceDetail.module.scss b/frontend/src/components/routes/event/OccurrenceDetail/OccurrenceDetail.module.scss
new file mode 100644
index 0000000000..7d9b7b402e
--- /dev/null
+++ b/frontend/src/components/routes/event/OccurrenceDetail/OccurrenceDetail.module.scss
@@ -0,0 +1,67 @@
+.header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 4px;
+}
+
+.chartCard {
+ margin-top: 20px;
+
+ .chartCardTitle {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+
+ h2 {
+ margin: 0;
+ }
+ }
+
+ .dateRange {
+ color: var(--mantine-color-gray-5);
+ }
+}
+
+.statusBadge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 10px;
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ white-space: nowrap;
+ flex-shrink: 0;
+
+ &[data-status="ACTIVE"] {
+ background: var(--mantine-color-green-1);
+ color: var(--mantine-color-green-9);
+ }
+
+ &[data-status="CANCELLED"] {
+ background: var(--mantine-color-red-1);
+ color: var(--mantine-color-red-9);
+ }
+
+ &[data-status="SOLD_OUT"] {
+ background: var(--mantine-color-orange-1);
+ color: var(--mantine-color-orange-9);
+ }
+}
+
+@media (max-width: 768px) {
+ .header {
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .chartCard .chartCardTitle {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ }
+
+}
diff --git a/frontend/src/components/routes/event/OccurrenceDetail/index.tsx b/frontend/src/components/routes/event/OccurrenceDetail/index.tsx
new file mode 100644
index 0000000000..0c7193d832
--- /dev/null
+++ b/frontend/src/components/routes/event/OccurrenceDetail/index.tsx
@@ -0,0 +1,273 @@
+import {useNavigate, useParams} from "react-router";
+import {t} from "@lingui/macro";
+import {Checkbox, Skeleton, Text} from "@mantine/core";
+import {useCallback, useMemo, useRef, useState} from "react";
+import {AreaChart} from "@mantine/charts";
+import {useDisclosure} from "@mantine/hooks";
+import {modals} from "@mantine/modals";
+import {PageBody} from "../../../common/PageBody";
+import {PageTitle} from "../../../common/PageTitle";
+import {StatBoxes} from "../../../common/StatBoxes";
+import {Card} from "../../../common/Card";
+import {OccurrenceAttendeesAndOrders} from "../../../common/OccurrenceAttendeesAndOrders";
+import {OccurrenceEditModal} from "../OccurrencesTab/OccurrenceEditModal";
+import {SendMessageModal} from "../../../modals/SendMessageModal";
+import {ShareModal} from "../../../modals/ShareModal";
+import {CreateCheckInListModal} from "../../../modals/CreateCheckInListModal";
+import {OccurrenceActionBar, OccurrenceMenuActions, statusLabel} from "../OccurrencesTab/OccurrenceMenu";
+import {launchCheckInForOccurrence} from "../OccurrencesTab/checkInLaunch";
+import {useGetEventOccurrence} from "../../../../queries/useGetEventOccurrence.ts";
+import {useGetEvent} from "../../../../queries/useGetEvent.ts";
+import {useGetEventStats} from "../../../../queries/useGetEventStats.ts";
+import {useGetEventCheckInLists} from "../../../../queries/useGetCheckInLists.ts";
+import {useCancelOccurrence} from "../../../../mutations/useCancelOccurrence.ts";
+import {useDeleteEventOccurrence} from "../../../../mutations/useDeleteEventOccurrence.ts";
+import {useReactivateOccurrence} from "../../../../mutations/useReactivateOccurrence.ts";
+import {formatDateWithLocale} from "../../../../utilites/dates.ts";
+import {formatCurrency} from "../../../../utilites/currency.ts";
+import {EventOccurrence, MessageType} from "../../../../types.ts";
+import {showError, showSuccess} from "../../../../utilites/notifications.tsx";
+import {confirmationDialog} from "../../../../utilites/confirmationDialog.tsx";
+import {eventHomepageUrl} from "../../../../utilites/urlHelper.ts";
+import classes from "./OccurrenceDetail.module.scss";
+
+const OccurrenceDetail = () => {
+ const {eventId, occurrenceId} = useParams();
+ const navigate = useNavigate();
+ const {data: event} = useGetEvent(eventId);
+ const {data: occurrence, isLoading: occurrenceLoading} = useGetEventOccurrence(eventId, occurrenceId);
+ const {data: eventStats} = useGetEventStats(eventId, {occurrenceId});
+
+ const [editModalOpen, {open: openEditModal, close: closeEditModal}] = useDisclosure(false);
+ const [showMessageModal, setShowMessageModal] = useState(false);
+ const [showShareOccurrence, setShowShareOccurrence] = useState();
+ const [createCheckInForOccurrenceId, setCreateCheckInForOccurrenceId] = useState();
+
+ const checkInListsQuery = useGetEventCheckInLists(eventId);
+ const checkInLists = checkInListsQuery?.data?.data;
+
+ const cancelMutation = useCancelOccurrence();
+ const deleteMutation = useDeleteEventOccurrence();
+ const reactivateMutation = useReactivateOccurrence();
+ const refundRef = useRef(false);
+
+ const handleCheckIn = useCallback((occId: number) => {
+ launchCheckInForOccurrence({
+ occurrenceId: occId,
+ checkInLists,
+ onCreateForOccurrence: setCreateCheckInForOccurrenceId,
+ });
+ }, [checkInLists]);
+
+ const handleCancel = useCallback((occId: number) => {
+ const orderCount = occurrence?.statistics?.orders_created ?? 0;
+ refundRef.current = false;
+
+ modals.openConfirmModal({
+ title: t`Cancel Date`,
+ children: (
+ <>
+
+ {t`Are you sure you want to cancel this date? Affected attendees will be notified by email.`}
+
+ {orderCount > 0 && (
+
+ {t`This date has ${orderCount} order(s) that will be affected.`}
+
+ )}
+ { refundRef.current = e.currentTarget.checked; }}
+ />
+ >
+ ),
+ labels: {confirm: t`Cancel Date`, cancel: t`Go Back`},
+ confirmProps: {color: 'red'},
+ onConfirm: () => {
+ cancelMutation.mutate({eventId, occurrenceId: occId, refundOrders: refundRef.current}, {
+ onSuccess: () => showSuccess(t`Date cancelled`),
+ onError: (error: any) => showError(error?.response?.data?.message || t`Failed to cancel date`),
+ });
+ },
+ });
+ }, [occurrence, eventId]);
+
+ const handleDelete = useCallback((occId: number) => {
+ confirmationDialog(t`Are you sure you want to delete this date? This action cannot be undone.`, () => {
+ deleteMutation.mutate({eventId, occurrenceId: occId}, {
+ onSuccess: () => {
+ showSuccess(t`Date deleted`);
+ navigate(`/manage/event/${eventId}/occurrences`);
+ },
+ onError: (error: any) => showError(error?.response?.data?.message || t`Failed to delete date`),
+ });
+ });
+ }, [eventId, navigate]);
+
+ const handleReactivate = useCallback((occ: EventOccurrence) => {
+ confirmationDialog(t`Reactivate this date? It will be reopened for future sales.`, () => {
+ reactivateMutation.mutate({
+ eventId,
+ occurrenceId: occ.id,
+ }, {
+ onSuccess: () => showSuccess(t`Date reactivated`),
+ onError: (error: any) => showError(error?.response?.data?.message || t`Failed to reactivate date`),
+ });
+ });
+ }, [eventId]);
+
+ const menuActions: OccurrenceMenuActions = useMemo(() => ({
+ eventId: eventId!,
+ onEdit: () => openEditModal(),
+ onCancel: handleCancel,
+ onDelete: handleDelete,
+ onNavigate: navigate,
+ onMessage: () => setShowMessageModal(true),
+ onCheckIn: handleCheckIn,
+ onReactivate: handleReactivate,
+ onShare: (occ: EventOccurrence) => setShowShareOccurrence(occ),
+ }), [eventId, handleCheckIn, handleCancel, handleDelete, handleReactivate, navigate, openEditModal]);
+
+ if (occurrenceLoading || !event) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ const startDate = occurrence
+ ? formatDateWithLocale(occurrence.start_date, 'fullDateTime', event.timezone)
+ : '';
+ const dateRange = eventStats
+ ? `${formatDateWithLocale(eventStats.start_date, 'chartDate', event.timezone)} - ${formatDateWithLocale(eventStats.end_date, 'chartDate', event.timezone)}`
+ : '';
+
+ return (
+
+
+
+ {startDate}
+ {occurrence?.label && ` — ${occurrence.label}`}
+
+ {occurrence?.status && (
+
+ {statusLabel(occurrence.status)}
+
+ )}
+
+
+ {occurrence && (
+
+
+
+ )}
+
+
+
+
+
+
+
+ {eventStats && (
+ <>
+
+
+
{t`Product Sales`}
+
{dateRange}
+
+ ({
+ date: formatDateWithLocale(stat.date, 'chartDate', event.timezone),
+ orders_created: stat.orders_created,
+ products_sold: stat.products_sold,
+ attendees_registered: stat.attendees_registered,
+ })) || []}
+ dataKey="date"
+ withLegend
+ legendProps={{verticalAlign: 'bottom', height: 50}}
+ series={[
+ {name: 'orders_created', color: 'blue.6', label: t`Completed Orders`},
+ {name: 'products_sold', color: 'blue.2', label: t`Products Sold`},
+ {name: 'attendees_registered', color: 'blue.4', label: t`Attendees Registered`},
+ ]}
+ curveType="bump"
+ tickLine="none"
+ areaChartProps={{syncId: 'occurrences'}}
+ />
+
+
+
+
+
{t`Revenue`}
+
{dateRange}
+
+ ({
+ date: formatDateWithLocale(stat.date, 'chartDate', event.timezone),
+ total_fees: stat.total_fees,
+ total_sales_gross: stat.total_sales_gross,
+ total_tax: stat.total_tax,
+ total_refunded: stat.total_refunded,
+ })) || []}
+ dataKey="date"
+ valueFormatter={(value) => formatCurrency(value, event.currency)}
+ withLegend
+ legendProps={{verticalAlign: 'bottom', height: 50}}
+ series={[
+ {name: 'total_fees', label: t`Total Fees`, color: 'primary.3'},
+ {name: 'total_sales_gross', label: t`Gross Sales`, color: 'grape.5'},
+ {name: 'total_tax', label: t`Total Tax`, color: 'grape.7'},
+ {name: 'total_refunded', label: t`Total Refunded`, color: 'red.6'},
+ ]}
+ curveType="natural"
+ tickLine="none"
+ areaChartProps={{syncId: 'occurrences'}}
+ />
+
+ >
+ )}
+
+ {editModalOpen && (
+
+ )}
+
+ {showMessageModal && (
+ setShowMessageModal(false)}
+ messageType={MessageType.AllAttendees}
+ eventOccurrenceId={occurrenceId}
+ />
+ )}
+
+ {createCheckInForOccurrenceId && (
+ setCreateCheckInForOccurrenceId(undefined)}
+ initialOccurrenceId={createCheckInForOccurrenceId}
+ />
+ )}
+
+ {showShareOccurrence && (
+ setShowShareOccurrence(undefined)}
+ url={`${eventHomepageUrl(event)}?occurrence_id=${showShareOccurrence.id}`}
+ title={event.title}
+ shareText={`${event.title} — ${formatDateWithLocale(showShareOccurrence.start_date, 'shortDateTime', event.timezone)}`}
+ />
+ )}
+
+ );
+};
+
+export default OccurrenceDetail;
diff --git a/frontend/src/components/routes/event/OccurrencesTab/CalendarView/CalendarView.module.scss b/frontend/src/components/routes/event/OccurrencesTab/CalendarView/CalendarView.module.scss
new file mode 100644
index 0000000000..f138fad0f9
--- /dev/null
+++ b/frontend/src/components/routes/event/OccurrencesTab/CalendarView/CalendarView.module.scss
@@ -0,0 +1,253 @@
+.calendarContainer {
+ border: 1px solid var(--mantine-color-gray-2);
+ border-radius: 8px;
+ background: var(--mantine-color-white);
+ overflow: hidden;
+}
+
+.monthNav {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--mantine-color-gray-2);
+
+ .monthLabel {
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--mantine-color-text);
+ }
+}
+
+.calendarGrid {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+}
+
+.dayHeader {
+ padding: 8px 4px;
+ text-align: center;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--mantine-color-dimmed);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ border-bottom: 1px solid var(--mantine-color-gray-2);
+}
+
+.dayCell {
+ min-height: 80px;
+ padding: 6px;
+ border-bottom: 1px solid var(--mantine-color-gray-1);
+ border-right: 1px solid var(--mantine-color-gray-1);
+ cursor: pointer;
+ transition: background-color 0.15s ease;
+ position: relative;
+
+ &:nth-child(7n) {
+ border-right: none;
+ }
+
+ &:hover {
+ background: var(--mantine-color-gray-0);
+ }
+
+ &.otherMonth {
+ background: var(--mantine-color-gray-0);
+ opacity: 0.5;
+ }
+
+ &.selected {
+ background: var(--mantine-color-primary-0);
+ outline: 2px solid var(--mantine-color-primary-3);
+ outline-offset: -2px;
+ }
+
+ &.today .dayNumber {
+ background: var(--mantine-color-primary-6);
+ color: white;
+ border-radius: 50%;
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+}
+
+.dayNumber {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--mantine-color-text);
+ margin-bottom: 4px;
+}
+
+.dayDots {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.occurrenceDot {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 1px 4px;
+ border-radius: 3px;
+ font-size: 10px;
+ font-weight: 500;
+ line-height: 1.4;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &[data-status="ACTIVE"] {
+ background: color-mix(
+ in srgb,
+ var(--mantine-color-green-3) calc(var(--fill-pct, 0) * 1%),
+ var(--mantine-color-green-1)
+ );
+ color: var(--mantine-color-green-8);
+ }
+
+ &[data-status="CANCELLED"] {
+ background: var(--mantine-color-red-1);
+ color: var(--mantine-color-red-8);
+ }
+
+ &[data-status="SOLD_OUT"] {
+ background: var(--mantine-color-orange-1);
+ color: var(--mantine-color-orange-8);
+ }
+}
+
+.moreCount {
+ font-size: 10px;
+ color: var(--mantine-color-dimmed);
+ font-weight: 500;
+ padding: 1px 4px;
+}
+
+// Popover
+.popoverContent {
+ max-height: 360px;
+ overflow-y: auto;
+}
+
+.popoverHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--mantine-color-gray-2);
+}
+
+.popoverTitle {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--mantine-color-text);
+}
+
+.popoverEmpty {
+ padding: 20px 14px;
+ text-align: center;
+}
+
+.popoverList {
+ display: flex;
+ flex-direction: column;
+}
+
+.popoverRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 14px;
+ gap: 8px;
+ transition: background 0.1s ease;
+
+ &:not(:last-child) {
+ border-bottom: 1px solid var(--mantine-color-gray-1);
+ }
+
+ &:hover {
+ background: var(--mantine-color-gray-0);
+ }
+}
+
+.popoverRowInfo {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ min-width: 0;
+ flex: 1;
+}
+
+.popoverStatusDot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ margin-top: 5px;
+
+ &[data-status="ACTIVE"] {
+ background: var(--mantine-color-green-5);
+ }
+
+ &[data-status="CANCELLED"] {
+ background: var(--mantine-color-red-5);
+ }
+
+ &[data-status="SOLD_OUT"] {
+ background: var(--mantine-color-orange-5);
+ }
+}
+
+.popoverRowDetails {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ min-width: 0;
+}
+
+.popoverTime {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--mantine-color-text);
+ line-height: 1.3;
+}
+
+.popoverLabel {
+ font-size: 12px;
+ color: var(--mantine-color-dimmed);
+ line-height: 1.3;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.popoverCapacity {
+ font-size: 12px;
+ color: var(--mantine-color-dimmed);
+ line-height: 1.3;
+}
+
+@media (max-width: 768px) {
+ .dayCell {
+ min-height: 60px;
+ padding: 4px;
+ }
+
+ .dayNumber {
+ font-size: 12px;
+ }
+
+ .occurrenceDot {
+ font-size: 0;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ padding: 0;
+ min-width: 8px;
+ }
+}
diff --git a/frontend/src/components/routes/event/OccurrencesTab/CalendarView/index.tsx b/frontend/src/components/routes/event/OccurrencesTab/CalendarView/index.tsx
new file mode 100644
index 0000000000..c6c2c1f1da
--- /dev/null
+++ b/frontend/src/components/routes/event/OccurrencesTab/CalendarView/index.tsx
@@ -0,0 +1,284 @@
+import React, {useMemo, useRef, useState} from "react";
+import {t} from "@lingui/macro";
+import {ActionIcon, Button, Menu, Popover, Text} from "@mantine/core";
+import {
+ IconChevronLeft,
+ IconChevronRight,
+ IconDotsVertical,
+ IconPlus,
+ IconX,
+} from "@tabler/icons-react";
+import dayjs from "dayjs";
+import utc from "dayjs/plugin/utc";
+import timezone from "dayjs/plugin/timezone";
+import {EventOccurrence} from "../../../../../types.ts";
+import {formatDateWithLocale} from "../../../../../utilites/dates.ts";
+import {OccurrenceMenuItems, OccurrenceMenuActions} from "../OccurrenceMenu";
+import classes from "./CalendarView.module.scss";
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+
+interface CalendarViewProps {
+ occurrences: EventOccurrence[];
+ eventTimezone: string;
+ currentMonth: dayjs.Dayjs;
+ onMonthChange: (month: dayjs.Dayjs) => void;
+ menuActions?: OccurrenceMenuActions;
+ onOccurrenceClick?: (occurrenceId: number) => void;
+ onCreate?: (defaultDate: string) => void;
+}
+
+const MAX_DOTS = 3;
+
+export const CalendarView = ({
+ occurrences,
+ eventTimezone,
+ currentMonth,
+ onMonthChange,
+ menuActions,
+ onOccurrenceClick,
+ onCreate,
+}: CalendarViewProps) => {
+ const [selectedDate, setSelectedDate] = useState(null);
+ const cellRefs = useRef>({});
+
+ const occurrencesByDate = useMemo(() => {
+ const map: Record = {};
+ occurrences.forEach(occ => {
+ const dateKey = dayjs.utc(occ.start_date).tz(eventTimezone).format('YYYY-MM-DD');
+ if (!map[dateKey]) map[dateKey] = [];
+ map[dateKey].push(occ);
+ });
+ return map;
+ }, [occurrences, eventTimezone]);
+
+ const calendarDays = useMemo(() => {
+ const startOfMonth = currentMonth.startOf('month');
+ const startDay = startOfMonth.day();
+ const offset = startDay === 0 ? 6 : startDay - 1;
+ const gridStart = startOfMonth.subtract(offset, 'day');
+
+ const days: { date: dayjs.Dayjs; isCurrentMonth: boolean }[] = [];
+ for (let i = 0; i < 42; i++) {
+ const date = gridStart.add(i, 'day');
+ days.push({
+ date,
+ isCurrentMonth: date.month() === currentMonth.month(),
+ });
+ }
+ return days;
+ }, [currentMonth]);
+
+ const todayStr = dayjs().format('YYYY-MM-DD');
+
+ const dayNames = useMemo(() => {
+ const start = dayjs().startOf('week').add(1, 'day');
+ return Array.from({length: 7}, (_, i) =>
+ start.add(i, 'day').format('ddd')
+ );
+ }, []);
+
+ const selectedOccurrences = selectedDate ? (occurrencesByDate[selectedDate] || []) : [];
+
+ const handleCellClick = (dateStr: string) => {
+ setSelectedDate(prev => prev === dateStr ? null : dateStr);
+ };
+
+ const closeAndDo = (fn: () => void) => {
+ setSelectedDate(null);
+ fn();
+ };
+
+ const popoverMenuActions: OccurrenceMenuActions | undefined = useMemo(() => {
+ if (!menuActions) return undefined;
+ return {
+ ...menuActions,
+ onEdit: (id: number) => closeAndDo(() => menuActions.onEdit(id)),
+ onCancel: (id: number) => closeAndDo(() => menuActions.onCancel(id)),
+ onDelete: (id: number) => closeAndDo(() => menuActions.onDelete(id)),
+ onNavigate: (path: string) => closeAndDo(() => menuActions.onNavigate(path)),
+ onDuplicate: menuActions.onDuplicate
+ ? (occ: EventOccurrence) => closeAndDo(() => menuActions.onDuplicate!(occ))
+ : undefined,
+ onMessage: menuActions.onMessage
+ ? (id: number) => closeAndDo(() => menuActions.onMessage!(id))
+ : undefined,
+ onCheckIn: menuActions.onCheckIn
+ ? (id: number) => closeAndDo(() => menuActions.onCheckIn!(id))
+ : undefined,
+ onReactivate: menuActions.onReactivate
+ ? (occ: EventOccurrence) => closeAndDo(() => menuActions.onReactivate!(occ))
+ : undefined,
+ onShare: menuActions.onShare
+ ? (occ: EventOccurrence) => closeAndDo(() => menuActions.onShare!(occ))
+ : undefined,
+ };
+ }, [menuActions]);
+
+ const renderPopoverContent = () => {
+ if (!selectedDate) return null;
+
+ return (
+
+
+
+ {dayjs(selectedDate).format('dddd, MMMM D')}
+
+
setSelectedDate(null)}>
+
+
+
+ {selectedOccurrences.length === 0 ? (
+
+ {t`No occurrences on this date`}
+ {onCreate && (
+ }
+ onClick={() => closeAndDo(() => onCreate(selectedDate))}
+ >
+ {t`Add a date`}
+
+ )}
+
+ ) : (
+
+ {selectedOccurrences.map(occ => {
+ const startTime = formatDateWithLocale(occ.start_date, 'timeOnly', eventTimezone);
+ const endTime = occ.end_date
+ ? formatDateWithLocale(occ.end_date, 'timeOnly', eventTimezone)
+ : null;
+
+ return (
+
+
closeAndDo(() => onOccurrenceClick?.(occ.id as number))}
+ style={onOccurrenceClick ? {cursor: 'pointer'} : undefined}
+ >
+
+
+
+ {startTime}{endTime && <> – {endTime}>}
+
+ {occ.label && (
+ {occ.label}
+ )}
+
+ {occ.capacity != null ? (
+ <>{occ.used_capacity ?? 0} / {occ.capacity}>
+ ) : t`Unlimited`}
+
+
+
+ {popoverMenuActions && (
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+ };
+
+ return (
+
+
+
onMonthChange(currentMonth.subtract(1, 'month'))}>
+
+
+
+ {currentMonth.format('MMMM YYYY')}
+
+
onMonthChange(currentMonth.add(1, 'month'))}>
+
+
+
+
+
+ {dayNames.map(name => (
+
{name}
+ ))}
+
+ {calendarDays.map(({date, isCurrentMonth}) => {
+ const dateStr = date.format('YYYY-MM-DD');
+ const dayOccs = occurrencesByDate[dateStr] || [];
+ const isSelected = selectedDate === dateStr;
+ const isToday = dateStr === todayStr;
+
+ const cellClasses = [
+ classes.dayCell,
+ !isCurrentMonth && classes.otherMonth,
+ isSelected && classes.selected,
+ isToday && classes.today,
+ ].filter(Boolean).join(' ');
+
+ return (
+
0 || dayOccs.length === 0)}
+ onClose={() => setSelectedDate(null)}
+ position="bottom"
+ withArrow
+ shadow="lg"
+ width={320}
+ trapFocus={false}
+ clickOutsideEvents={['mousedown']}
+ >
+
+ { cellRefs.current[dateStr] = el; }}
+ className={cellClasses}
+ onClick={() => handleCellClick(dateStr)}
+ >
+
{date.date()}
+ {dayOccs.length > 0 && (
+
+ {dayOccs.slice(0, MAX_DOTS).map(occ => {
+ const fillPct = occ.capacity
+ ? Math.min(100, Math.round(((occ.used_capacity ?? 0) / occ.capacity) * 100))
+ : 0;
+ return (
+
+ {formatDateWithLocale(occ.start_date, 'timeOnly', eventTimezone)}
+ {occ.label && ` · ${occ.label}`}
+
+ );
+ })}
+ {dayOccs.length > MAX_DOTS && (
+
+ +{dayOccs.length - MAX_DOTS} {t`more`}
+
+ )}
+
+ )}
+
+
+
+ {renderPopoverContent()}
+
+
+ );
+ })}
+
+
+ );
+};
diff --git a/frontend/src/components/routes/event/OccurrencesTab/GroupedOccurrenceTable/GroupedOccurrenceTable.module.scss b/frontend/src/components/routes/event/OccurrencesTab/GroupedOccurrenceTable/GroupedOccurrenceTable.module.scss
new file mode 100644
index 0000000000..06cb31cad2
--- /dev/null
+++ b/frontend/src/components/routes/event/OccurrencesTab/GroupedOccurrenceTable/GroupedOccurrenceTable.module.scss
@@ -0,0 +1,85 @@
+.tableWrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.card {
+ padding: 0 !important;
+ overflow: hidden;
+
+ :global(.mantine-ScrollArea-viewport) {
+ padding-bottom: 0;
+ }
+
+ :global(.mantine-ScrollArea-scrollbar) {
+ z-index: 20 !important;
+ }
+}
+
+.table {
+ th {
+ white-space: nowrap;
+ }
+
+ tbody tr td {
+ padding: 16px 20px !important;
+ white-space: nowrap;
+ }
+
+ position: relative;
+}
+
+.tableHead {
+ white-space: nowrap;
+
+ th {
+ background-color: var(--mantine-color-gray-1);
+ color: var(--hi-secondary-text) !important;
+ padding: 16px 20px !important;
+ }
+}
+
+.stickyRight {
+ position: sticky;
+ right: 0;
+ z-index: 2;
+ background: var(--mantine-color-body);
+}
+
+.tableHead .stickyRight {
+ background-color: var(--mantine-color-gray-1);
+}
+
+.dateHeaderRow td {
+ background: var(--mantine-color-gray-0) !important;
+ padding: 10px 20px !important;
+ border-bottom: 1px solid var(--mantine-color-gray-2);
+}
+
+.dateHeaderContent {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.dateHeaderLabel {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--mantine-color-text);
+}
+
+.dateHeaderBadge {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--mantine-color-dimmed);
+}
+
+.dateHeaderCheckbox {
+ margin-right: 2px;
+}
+
+.rowCheckbox {
+ display: flex;
+ align-items: center;
+}
diff --git a/frontend/src/components/routes/event/OccurrencesTab/GroupedOccurrenceTable/index.tsx b/frontend/src/components/routes/event/OccurrencesTab/GroupedOccurrenceTable/index.tsx
new file mode 100644
index 0000000000..0fc2de0405
--- /dev/null
+++ b/frontend/src/components/routes/event/OccurrencesTab/GroupedOccurrenceTable/index.tsx
@@ -0,0 +1,200 @@
+import {ReactNode, CSSProperties, useMemo, useCallback} from "react";
+import {Table as MantineTable, Checkbox} from "@mantine/core";
+import {t} from "@lingui/macro";
+import dayjs from "dayjs";
+import utc from "dayjs/plugin/utc";
+import timezone from "dayjs/plugin/timezone";
+import {EventOccurrence} from "../../../../../types.ts";
+import {Card} from "../../../../common/Card";
+import classes from "./GroupedOccurrenceTable.module.scss";
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+
+export interface GroupedTableColumn {
+ id: string;
+ header: string;
+ render: (occ: EventOccurrence) => ReactNode;
+ headerStyle?: CSSProperties;
+ sticky?: 'right';
+}
+
+interface DateGroup {
+ dateKey: string;
+ label: string;
+ occurrences: EventOccurrence[];
+}
+
+interface GroupedOccurrenceTableProps {
+ occurrences: EventOccurrence[];
+ columns: GroupedTableColumn[];
+ eventTimezone: string;
+ selectedIds: Set
;
+ onSelectionChange: (ids: Set) => void;
+ rowStyle?: (occ: EventOccurrence) => CSSProperties | undefined;
+}
+
+const formatDateHeader = (dateKey: string): string => {
+ const date = dayjs(dateKey);
+ const today = dayjs().startOf('day');
+ const tomorrow = today.add(1, 'day');
+
+ if (date.isSame(today, 'day')) {
+ return t`Today` + ' — ' + date.format('dddd, MMMM D, YYYY');
+ }
+ if (date.isSame(tomorrow, 'day')) {
+ return t`Tomorrow` + ' — ' + date.format('dddd, MMMM D, YYYY');
+ }
+ return date.format('dddd, MMMM D, YYYY');
+};
+
+export const GroupedOccurrenceTable = ({
+ occurrences,
+ columns,
+ eventTimezone,
+ selectedIds,
+ onSelectionChange,
+ rowStyle,
+}: GroupedOccurrenceTableProps) => {
+ const groups: DateGroup[] = useMemo(() => {
+ const map = new Map();
+ occurrences.forEach(occ => {
+ const dateKey = dayjs.utc(occ.start_date).tz(eventTimezone).format('YYYY-MM-DD');
+ if (!map.has(dateKey)) map.set(dateKey, []);
+ map.get(dateKey)!.push(occ);
+ });
+ return Array.from(map.entries()).map(([dateKey, occs]) => ({
+ dateKey,
+ label: formatDateHeader(dateKey),
+ occurrences: occs,
+ }));
+ }, [occurrences, eventTimezone]);
+
+ const allIds = useMemo(
+ () => occurrences.map(o => o.id as number),
+ [occurrences]
+ );
+
+ const allSelected = allIds.length > 0 && allIds.every(id => selectedIds.has(id));
+ const someSelected = allIds.some(id => selectedIds.has(id)) && !allSelected;
+
+ const toggleAll = useCallback(() => {
+ if (allSelected) {
+ onSelectionChange(new Set());
+ } else {
+ onSelectionChange(new Set(allIds));
+ }
+ }, [allSelected, allIds, onSelectionChange]);
+
+ const toggleGroup = useCallback((groupOccs: EventOccurrence[]) => {
+ const groupIds = groupOccs.map(o => o.id as number);
+ const allGroupSelected = groupIds.every(id => selectedIds.has(id));
+ const next = new Set(selectedIds);
+ if (allGroupSelected) {
+ groupIds.forEach(id => next.delete(id));
+ } else {
+ groupIds.forEach(id => next.add(id));
+ }
+ onSelectionChange(next);
+ }, [selectedIds, onSelectionChange]);
+
+ const toggleRow = useCallback((id: number) => {
+ const next = new Set(selectedIds);
+ if (next.has(id)) {
+ next.delete(id);
+ } else {
+ next.add(id);
+ }
+ onSelectionChange(next);
+ }, [selectedIds, onSelectionChange]);
+
+ const totalColumns = columns.length + 1; // +1 for checkbox
+
+ return (
+
+
+
+
+
+
+
+
+
+ {columns.map(col => (
+
+ {col.header}
+
+ ))}
+
+
+
+ {groups.map(group => {
+ const groupIds = group.occurrences.map(o => o.id as number);
+ const allGroupSelected = groupIds.every(id => selectedIds.has(id));
+ const someGroupSelected = groupIds.some(id => selectedIds.has(id)) && !allGroupSelected;
+
+ return [
+
+
+
+ toggleGroup(group.occurrences)}
+ aria-label={t`Select all on ${group.label}`}
+ />
+ {group.label}
+ {group.occurrences.length > 1 && (
+
+ {group.occurrences.length}
+
+ )}
+
+
+ ,
+ ...group.occurrences.map(occ => (
+
+
+
+ toggleRow(occ.id as number)}
+ aria-label={t`Select date`}
+ />
+
+
+ {columns.map(col => (
+
+ {col.render(occ)}
+
+ ))}
+
+ )),
+ ];
+ })}
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/components/routes/event/OccurrencesTab/OccurrenceBulkEditModal/OccurrenceBulkEditModal.module.scss b/frontend/src/components/routes/event/OccurrencesTab/OccurrenceBulkEditModal/OccurrenceBulkEditModal.module.scss
new file mode 100644
index 0000000000..8106958375
--- /dev/null
+++ b/frontend/src/components/routes/event/OccurrencesTab/OccurrenceBulkEditModal/OccurrenceBulkEditModal.module.scss
@@ -0,0 +1,66 @@
+.actionPicker {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.actionOption {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 14px 16px;
+ border: 1px solid var(--mantine-color-gray-2);
+ border-radius: var(--mantine-radius-md);
+ background: white;
+ cursor: pointer;
+ text-align: left;
+ transition: all 0.12s ease;
+ width: 100%;
+
+ &:hover {
+ border-color: var(--mantine-primary-color-3);
+ background: var(--mantine-primary-color-0);
+ }
+}
+
+.actionOptionIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ border-radius: var(--mantine-radius-sm);
+ background: var(--mantine-primary-color-0);
+ color: var(--mantine-primary-color-filled);
+ flex-shrink: 0;
+}
+
+.actionOptionLabel {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--mantine-color-text);
+}
+
+.actionOptionDesc {
+ font-size: 12px;
+ color: var(--mantine-color-dimmed);
+ margin-top: 1px;
+}
+
+.backLink {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 13px;
+ color: var(--mantine-color-dimmed);
+ background: none;
+ border: none;
+ padding: 0;
+ margin-bottom: 16px;
+ cursor: pointer;
+ transition: color 0.12s ease;
+
+ &:hover {
+ color: var(--mantine-color-text);
+ }
+}
diff --git a/frontend/src/components/routes/event/OccurrencesTab/OccurrenceBulkEditModal/index.tsx b/frontend/src/components/routes/event/OccurrencesTab/OccurrenceBulkEditModal/index.tsx
new file mode 100644
index 0000000000..8d23d71d30
--- /dev/null
+++ b/frontend/src/components/routes/event/OccurrencesTab/OccurrenceBulkEditModal/index.tsx
@@ -0,0 +1,433 @@
+import {t} from "@lingui/macro";
+import {Alert, Button, Checkbox, NumberInput, SegmentedControl, Stack, Text, TextInput} from "@mantine/core";
+import {useForm} from "@mantine/form";
+import {modals} from "@mantine/modals";
+import {useParams} from "react-router";
+import {useMemo, useState} from "react";
+import {IconClock, IconInfoCircle, IconRuler, IconTag, IconUsers} from "@tabler/icons-react";
+import {Modal} from "../../../../common/Modal";
+import {InputGroup} from "../../../../common/InputGroup";
+import {BulkUpdateOccurrencesRequest, EventOccurrence, EventOccurrenceStatus, GenericModalProps, MessageType} from "../../../../../types.ts";
+import {useBulkUpdateOccurrences} from "../../../../../mutations/useBulkUpdateOccurrences.ts";
+import {useGetEvent} from "../../../../../queries/useGetEvent.ts";
+import {showSuccess, showError} from "../../../../../utilites/notifications.tsx";
+import {useFormErrorResponseHandler} from "../../../../../hooks/useFormErrorResponseHandler.tsx";
+import {SendMessageModal} from "../../../../modals/SendMessageModal";
+import {buildBulkRescheduleTemplate} from "../rescheduleMessageTemplate";
+import classes from './OccurrenceBulkEditModal.module.scss';
+
+type BulkAction = 'shift_times' | 'change_duration' | 'update_capacity' | 'update_label';
+
+interface OccurrenceBulkEditModalProps extends GenericModalProps {
+ occurrences?: EventOccurrence[];
+}
+
+export const OccurrenceBulkEditModal = ({onClose, occurrences}: OccurrenceBulkEditModalProps) => {
+ const ACTIONS: { value: BulkAction; label: string; icon: typeof IconClock; description: string }[] = [
+ {value: 'shift_times', label: t`Shift times`, icon: IconClock, description: t`Move all dates earlier or later`},
+ {value: 'change_duration', label: t`Change duration`, icon: IconRuler, description: t`Set how long each date lasts`},
+ {value: 'update_capacity', label: t`Update capacity`, icon: IconUsers, description: t`Change the attendee limit`},
+ {value: 'update_label', label: t`Update label`, icon: IconTag, description: t`Set or clear the date label`},
+ ];
+ const {eventId} = useParams();
+ const bulkUpdateMutation = useBulkUpdateOccurrences();
+ const errorHandler = useFormErrorResponseHandler();
+ const {data: event} = useGetEvent(eventId);
+
+ const [pendingNotification, setPendingNotification] = useState<{
+ occurrenceIds: number[];
+ actionDescription: string;
+ } | null>(null);
+
+ const form = useForm({
+ initialValues: {
+ bulk_action: null as BulkAction | null,
+ shift_direction: 'later' as 'later' | 'earlier',
+ shift_hours: 0,
+ shift_minutes: 0,
+ duration_hours: 1,
+ duration_minutes: 0,
+ capacity: undefined as number | undefined,
+ clear_capacity: false,
+ label: '',
+ clear_label: false,
+ future_only: true,
+ skip_overridden: true,
+ // 'loaded' → only the dates currently visible on this page/calendar window
+ // 'matching' → every date in the event matching the filter toggles below
+ // The list view paginates at 50/page and the calendar view loads only its
+ // visible window, so without this distinction "Bulk Edit" silently affected
+ // far fewer dates than the modal copy implied.
+ scope: 'loaded' as 'loaded' | 'matching',
+ },
+ });
+
+ const selectedAction = form.values.bulk_action;
+ const isAllMatching = form.values.scope === 'matching';
+
+ const affectedOccurrences = (occurrences ?? []).filter(occ => {
+ if (occ.status === EventOccurrenceStatus.CANCELLED) return false;
+ if (form.values.future_only && occ.is_past) return false;
+ if (form.values.skip_overridden && occ.is_overridden) return false;
+ return true;
+ });
+ const affectedOccurrenceIds = affectedOccurrences
+ .map(occ => Number(occ.id))
+ .filter((id): id is number => Number.isFinite(id));
+ const loadedAffectedCount = occurrences ? affectedOccurrences.length : undefined;
+ const loadedAffectedAttendees = affectedOccurrences
+ .reduce((sum, occ) => sum + (occ.statistics?.attendees_registered ?? 0), 0);
+
+ const buildRequest = (): BulkUpdateOccurrencesRequest | null => {
+ if (!isAllMatching && affectedOccurrenceIds.length === 0) {
+ showError(t`No dates match the current filters.`);
+ return null;
+ }
+
+ // 'matching' scope expands the update to every occurrence in the
+ // event that satisfies future_only / skip_overridden — the server
+ // resolves the set, so we omit occurrence_ids and flip apply_to_all.
+ // 'loaded' scope keeps the historical safe behaviour: only the dates
+ // currently visible on the page/calendar window are updated.
+ const base: BulkUpdateOccurrencesRequest = isAllMatching
+ ? {
+ action: 'update',
+ future_only: form.values.future_only,
+ skip_overridden: form.values.skip_overridden,
+ apply_to_all: true,
+ }
+ : {
+ action: 'update',
+ future_only: form.values.future_only,
+ skip_overridden: form.values.skip_overridden,
+ occurrence_ids: affectedOccurrenceIds,
+ };
+
+ switch (selectedAction) {
+ case 'shift_times': {
+ const totalMinutes = (form.values.shift_hours * 60) + form.values.shift_minutes;
+ if (totalMinutes === 0) {
+ showError(t`Enter a time to shift by.`);
+ return null;
+ }
+ const shift = form.values.shift_direction === 'earlier' ? -totalMinutes : totalMinutes;
+ return {...base, start_time_shift: shift, end_time_shift: shift};
+ }
+ case 'change_duration': {
+ const totalMinutes = (form.values.duration_hours * 60) + form.values.duration_minutes;
+ if (totalMinutes === 0) {
+ showError(t`Duration must be at least 1 minute.`);
+ return null;
+ }
+ return {...base, duration_minutes: totalMinutes};
+ }
+ case 'update_capacity': {
+ if (form.values.clear_capacity) {
+ return {...base, clear_capacity: true};
+ }
+ if (form.values.capacity === undefined || form.values.capacity === null) {
+ showError(t`Enter a capacity value or choose unlimited.`);
+ return null;
+ }
+ return {...base, capacity: form.values.capacity};
+ }
+ case 'update_label': {
+ if (form.values.clear_label) {
+ return {...base, clear_label: true};
+ }
+ if (form.values.label.trim() === '') {
+ showError(t`Enter a label or choose to remove it.`);
+ return null;
+ }
+ return {...base, label: form.values.label.trim()};
+ }
+ default:
+ return null;
+ }
+ };
+
+ const submit = (data: BulkUpdateOccurrencesRequest, notifyAfterSave: boolean) => {
+ bulkUpdateMutation.mutate({eventId, data}, {
+ onSuccess: (response) => {
+ const count = response.updated_count;
+ const actionLabels: Record = {
+ 'shift_times': t`Shifted times for ${count} date(s)`,
+ 'change_duration': t`Changed duration for ${count} date(s)`,
+ 'update_capacity': t`Updated capacity for ${count} date(s)`,
+ 'update_label': t`Updated label for ${count} date(s)`,
+ };
+ showSuccess(actionLabels[selectedAction!] || t`Updated ${count} date(s)`);
+
+ // Chain the notification step using server-returned ids so an
+ // 'all matching' update can still target the exact attendees
+ // the backend touched (the local list only contains the
+ // current page / calendar window).
+ const updatedIds = response.updated_ids ?? [];
+ if (notifyAfterSave && updatedIds.length > 0 && selectedAction) {
+ setPendingNotification({
+ occurrenceIds: updatedIds,
+ actionDescription: describeAction(selectedAction),
+ });
+ return;
+ }
+ onClose();
+ },
+ onError: (error: any) => {
+ if (error?.response?.status === 422) {
+ errorHandler(form, error);
+ } else {
+ showError(error?.response?.data?.message || t`Bulk update failed.`);
+ }
+ },
+ });
+ };
+
+ const handleSubmit = () => {
+ const data = buildRequest();
+ if (!data) return;
+
+ // Shift-times and change-duration move start/end timestamps; attendees
+ // aren't auto-notified, so we make the organizer acknowledge the impact.
+ // For 'all matching' scope we can't know the attendee total upfront
+ // (the count would only cover the loaded page), so we always show the
+ // confirm and let the organizer decide whether to chain the message.
+ const changesDateOrTime = selectedAction === 'shift_times' || selectedAction === 'change_duration';
+ const shouldConfirm = changesDateOrTime && (isAllMatching || loadedAffectedAttendees > 0);
+ if (shouldConfirm) {
+ const notifyRef = {current: true};
+ modals.openConfirmModal({
+ title: t`You're changing session times`,
+ children: (
+ <>
+
+ {isAllMatching
+ ? t`This applies to every matching date in the event, including dates not currently visible. Attendees registered on any of those dates will be reachable via the message composer once the update finishes.`
+ : (loadedAffectedAttendees === 1
+ ? t`1 attendee is registered across the affected sessions.`
+ : t`${loadedAffectedAttendees} attendees are registered across the affected sessions.`)}
+
+ { notifyRef.current = e.currentTarget.checked; }}
+ />
+ >
+ ),
+ labels: {confirm: t`Save`, cancel: t`Cancel`},
+ onConfirm: () => submit(data, notifyRef.current),
+ });
+ return;
+ }
+
+ submit(data, false);
+ };
+
+ const notificationTemplate = useMemo(() => {
+ if (!pendingNotification || !event) return null;
+ return buildBulkRescheduleTemplate(
+ event,
+ pendingNotification.occurrenceIds.length,
+ pendingNotification.actionDescription,
+ );
+ }, [pendingNotification, event]);
+
+ const scopeParts = [];
+ scopeParts.push(form.values.future_only ? t`future` : t`all`);
+ if (form.values.skip_overridden) {
+ scopeParts.push(t`non-edited`);
+ }
+
+ const describeAction = (action: BulkAction): string => {
+ switch (action) {
+ case 'shift_times': return t`a shift in start/end times`;
+ case 'change_duration': return t`a change in duration`;
+ case 'update_capacity': return t`capacity updates`;
+ case 'update_label': return t`label updates`;
+ }
+ };
+
+ return (
+ <>
+
+ {!selectedAction ? (
+
+ {ACTIONS.map(({value, label, icon: Icon, description}) => (
+
form.setFieldValue('bulk_action', value)}
+ >
+
+
+
+
+
{label}
+
{description}
+
+
+ ))}
+
+ ) : (
+ { e.preventDefault(); handleSubmit(); }}>
+ form.setFieldValue('bulk_action', null)}
+ >
+ ← {t`Choose a different action`}
+
+
+ {t`Apply to`}
+
+
+
+
+
+
+
+ } color="blue" variant="light" mb="md">
+ {isAllMatching
+ ? t`Applies to every ${scopeParts.join(', ')}, non-cancelled date in this event — including dates not currently loaded.`
+ : t`Applies to ${scopeParts.join(', ')}, non-cancelled dates currently loaded on this page.`}
+ {!isAllMatching && loadedAffectedCount !== undefined && (
+
+ {t`This will affect ${loadedAffectedCount} date(s).`}
+
+ )}
+
+
+ {selectedAction === 'shift_times' && (
+ <>
+
+
+
+
+
+ >
+ )}
+
+ {selectedAction === 'change_duration' && (
+ <>
+
+ {t`Set the end time of each date to be this long after its start time.`}
+
+
+
+
+
+ >
+ )}
+
+ {selectedAction === 'update_capacity' && (
+ <>
+ {!form.values.clear_capacity && (
+
+ )}
+
+ >
+ )}
+
+ {selectedAction === 'update_label' && (
+ <>
+ {!form.values.clear_label && (
+ }
+ mb="xs"
+ />
+ )}
+
+ >
+ )}
+
+
+ {t`Apply Changes`}
+
+
+ )}
+
+ {pendingNotification && notificationTemplate && (
+ {
+ setPendingNotification(null);
+ onClose();
+ }}
+ messageType={MessageType.AllAttendees}
+ eventOccurrenceIds={pendingNotification.occurrenceIds}
+ initialSubject={notificationTemplate.subject}
+ initialMessage={notificationTemplate.message}
+ />
+ )}
+ >
+ );
+};
diff --git a/frontend/src/components/routes/event/OccurrencesTab/OccurrenceEditModal/OccurrenceEditModal.module.scss b/frontend/src/components/routes/event/OccurrencesTab/OccurrenceEditModal/OccurrenceEditModal.module.scss
new file mode 100644
index 0000000000..7299919bd2
--- /dev/null
+++ b/frontend/src/components/routes/event/OccurrencesTab/OccurrenceEditModal/OccurrenceEditModal.module.scss
@@ -0,0 +1,182 @@
+.section {
+ border: 1px solid var(--mantine-color-gray-2);
+ border-radius: var(--mantine-radius-md);
+ padding: 16px;
+ margin-bottom: 16px;
+ transition: border-color 0.15s ease;
+
+ &:hover {
+ border-color: var(--mantine-color-gray-3);
+ }
+}
+
+.sectionHeader {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 12px;
+}
+
+.sectionIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: var(--mantine-radius-sm);
+ background: var(--mantine-primary-color-0);
+ color: var(--mantine-primary-color-filled);
+ flex-shrink: 0;
+}
+
+.sectionTitle {
+ font-weight: 600;
+ font-size: var(--mantine-font-size-sm);
+ color: var(--mantine-color-text);
+}
+
+.cancelledBanner {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
+ margin-bottom: 16px;
+ border-radius: var(--mantine-radius-md);
+ background: var(--mantine-color-red-0);
+ border: 1px solid var(--mantine-color-red-2);
+}
+
+.cancelledIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: var(--mantine-color-red-1);
+ color: var(--mantine-color-red-7);
+ flex-shrink: 0;
+}
+
+.cancelledText {
+ font-size: var(--mantine-font-size-sm);
+ color: var(--mantine-color-red-9);
+ line-height: 1.4;
+}
+
+.pastDateWarning {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ margin-top: 10px;
+ padding: 10px 12px;
+ border-radius: var(--mantine-radius-sm);
+ background: var(--mantine-color-orange-0);
+ border: 1px solid var(--mantine-color-orange-2);
+ font-size: var(--mantine-font-size-xs);
+ color: var(--mantine-color-orange-9);
+ line-height: 1.4;
+
+ svg {
+ flex-shrink: 0;
+ margin-top: 1px;
+ color: var(--mantine-color-orange-6);
+ }
+}
+
+.dangerZone {
+ border: 1px solid var(--mantine-color-red-2);
+ border-radius: var(--mantine-radius-md);
+ padding: 16px;
+ margin-top: 20px;
+}
+
+.dangerHeader {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 12px;
+}
+
+.dangerIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: var(--mantine-radius-sm);
+ background: var(--mantine-color-red-0);
+ color: var(--mantine-color-red-6);
+ flex-shrink: 0;
+}
+
+.dangerTitle {
+ font-weight: 600;
+ font-size: var(--mantine-font-size-sm);
+ color: var(--mantine-color-red-7);
+}
+
+.dangerActions {
+ display: flex;
+ gap: 8px;
+}
+
+.dangerAction {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
+ border-radius: var(--mantine-radius-md);
+ border: 1px solid var(--mantine-color-gray-2);
+ cursor: pointer;
+ transition: all 0.15s ease;
+ background: white;
+
+ &:hover {
+ border-color: var(--mantine-color-red-3);
+ background: var(--mantine-color-red-0);
+ }
+
+ &:disabled, &[data-loading="true"] {
+ opacity: 0.6;
+ pointer-events: none;
+ }
+}
+
+.dangerActionIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: var(--mantine-radius-sm);
+ background: var(--mantine-color-red-0);
+ color: var(--mantine-color-red-6);
+ flex-shrink: 0;
+}
+
+.dangerActionLabel {
+ font-size: var(--mantine-font-size-sm);
+ font-weight: 500;
+ color: var(--mantine-color-text);
+}
+
+.dangerActionDesc {
+ font-size: var(--mantine-font-size-xs);
+ color: var(--mantine-color-dimmed);
+}
+
+@media (max-width: 480px) {
+ .section {
+ padding: 12px;
+ }
+
+ .dangerZone {
+ padding: 12px;
+ }
+
+ .dangerActions {
+ flex-direction: column;
+ }
+}
diff --git a/frontend/src/components/routes/event/OccurrencesTab/OccurrenceEditModal/index.tsx b/frontend/src/components/routes/event/OccurrencesTab/OccurrenceEditModal/index.tsx
new file mode 100644
index 0000000000..521a62d275
--- /dev/null
+++ b/frontend/src/components/routes/event/OccurrencesTab/OccurrenceEditModal/index.tsx
@@ -0,0 +1,432 @@
+import {t} from "@lingui/macro";
+import {Button, Checkbox, NumberInput, Tabs, Text, TextInput, UnstyledButton} from "@mantine/core";
+import {useForm} from "@mantine/form";
+import {modals} from "@mantine/modals";
+import {useParams} from "react-router";
+import {useEffect, useMemo, useRef, useState} from "react";
+import dayjs from "dayjs";
+import {
+ IconAlertTriangle,
+ IconCalendar,
+ IconEdit,
+ IconInfoCircle,
+ IconShoppingCart,
+ IconTag,
+ IconTrash,
+ IconUsers,
+ IconX,
+} from "@tabler/icons-react";
+import {Modal} from "../../../../common/Modal";
+import {InputGroup} from "../../../../common/InputGroup";
+import {
+ EventOccurrence,
+ EventOccurrenceStatus,
+ GenericModalProps,
+ IdParam,
+ UpsertEventOccurrenceRequest,
+} from "../../../../../types.ts";
+import {useGetEventOccurrence} from "../../../../../queries/useGetEventOccurrence.ts";
+import {useCreateEventOccurrence} from "../../../../../mutations/useCreateEventOccurrence.ts";
+import {useUpdateEventOccurrence} from "../../../../../mutations/useUpdateEventOccurrence.ts";
+import {useCancelOccurrence} from "../../../../../mutations/useCancelOccurrence.ts";
+import {useDeleteEventOccurrence} from "../../../../../mutations/useDeleteEventOccurrence.ts";
+import {useGetEvent} from "../../../../../queries/useGetEvent.ts";
+import {utcToTz} from "../../../../../utilites/dates.ts";
+import {showSuccess, showError} from "../../../../../utilites/notifications.tsx";
+import {useFormErrorResponseHandler} from "../../../../../hooks/useFormErrorResponseHandler.tsx";
+import {OccurrenceProductSettings} from "../PriceOverrideForm";
+import {SendMessageModal} from "../../../../modals/SendMessageModal";
+import {MessageType} from "../../../../../types.ts";
+import {buildSingleRescheduleTemplate} from "../rescheduleMessageTemplate";
+import classes from './OccurrenceEditModal.module.scss';
+
+interface OccurrenceEditModalProps extends GenericModalProps {
+ occurrenceId?: IdParam;
+ duplicateFrom?: EventOccurrence;
+ defaultDate?: string;
+}
+
+export const OccurrenceEditModal = ({onClose, occurrenceId, duplicateFrom, defaultDate}: OccurrenceEditModalProps) => {
+ const {eventId} = useParams();
+ const isEditing = !!occurrenceId;
+ const errorHandler = useFormErrorResponseHandler();
+ const {data: event} = useGetEvent(eventId);
+ const {data: occurrence} = useGetEventOccurrence(eventId, occurrenceId);
+
+ const createMutation = useCreateEventOccurrence();
+ const updateMutation = useUpdateEventOccurrence();
+ const cancelMutation = useCancelOccurrence();
+ const deleteMutation = useDeleteEventOccurrence();
+
+ const form = useForm({
+ initialValues: {
+ start_date: '',
+ end_date: '',
+ capacity: null,
+ label: '',
+ },
+ validate: {
+ start_date: (value) => !value ? t`Start date is required` : null,
+ end_date: (value, values) => {
+ if (value && values.start_date && value < values.start_date) {
+ return t`End date must be after start date`;
+ }
+ return null;
+ },
+ capacity: (value) => {
+ if (value !== null && value !== undefined && value < 0) {
+ return t`Capacity must be 0 or greater`;
+ }
+ return null;
+ },
+ },
+ });
+
+ useEffect(() => {
+ if (occurrence && event) {
+ form.setValues({
+ start_date: utcToTz(occurrence.start_date, event.timezone) || '',
+ end_date: utcToTz(occurrence.end_date, event.timezone) || '',
+ capacity: occurrence.capacity ?? null,
+ label: occurrence.label || '',
+ });
+ }
+ }, [occurrence, event]);
+
+ useEffect(() => {
+ if (duplicateFrom && event && !isEditing) {
+ form.setValues({
+ start_date: utcToTz(duplicateFrom.start_date, event.timezone) || '',
+ end_date: utcToTz(duplicateFrom.end_date, event.timezone) || '',
+ capacity: duplicateFrom.capacity ?? null,
+ label: duplicateFrom.label || '',
+ });
+ }
+ }, [duplicateFrom, event]);
+
+ useEffect(() => {
+ if (defaultDate && !isEditing && !duplicateFrom) {
+ form.setFieldValue('start_date', defaultDate + 'T09:00');
+ }
+ }, [defaultDate]);
+
+ // If the user opts in to notifying attendees, we stash what's needed to
+ // pre-fill the SendMessageModal and open it once the save succeeds.
+ const [pendingNotification, setPendingNotification] = useState<{
+ occurrence: EventOccurrence;
+ newStartDate: string;
+ newEndDate: string | null | undefined;
+ } | null>(null);
+
+ const submit = (values: UpsertEventOccurrenceRequest, notifyAfterSave: boolean) => {
+ const onSuccess = () => {
+ showSuccess(isEditing
+ ? t`Date updated successfully`
+ : t`Date created successfully`
+ );
+ if (notifyAfterSave && isEditing && occurrence) {
+ // The edit modal hides (opened={!pendingNotification}) and
+ // SendMessageModal takes over — one visible modal at a time.
+ setPendingNotification({
+ occurrence,
+ newStartDate: values.start_date,
+ newEndDate: values.end_date,
+ });
+ return;
+ }
+ onClose();
+ };
+ const onError = (error: any) => errorHandler(form, error);
+
+ if (isEditing) {
+ updateMutation.mutate({eventId, occurrenceId, data: values}, {onSuccess, onError});
+ } else {
+ createMutation.mutate({eventId, data: values}, {onSuccess, onError});
+ }
+ };
+
+ const handleSubmit = (values: UpsertEventOccurrenceRequest) => {
+ // Only warn when editing an existing occurrence whose date/time actually
+ // changed AND someone has already registered.
+ const attendeeCount = occurrence?.statistics?.attendees_registered ?? 0;
+ const originalStart = occurrence && event ? utcToTz(occurrence.start_date, event.timezone) : '';
+ const originalEnd = occurrence && event ? utcToTz(occurrence.end_date, event.timezone) : '';
+ const dateChanged = isEditing
+ && (values.start_date !== originalStart || (values.end_date || '') !== (originalEnd || ''));
+
+ if (dateChanged && attendeeCount > 0) {
+ // Wrap in a ref so the onConfirm closure can read the current checkbox
+ // value — modals.openConfirmModal doesn't have a built-in way to track
+ // inner form state.
+ const notifyRef = {current: true};
+ modals.openConfirmModal({
+ title: t`You've changed the session time`,
+ children: (
+ <>
+
+ {attendeeCount === 1
+ ? t`1 attendee is registered for this session.`
+ : t`${attendeeCount} attendees are registered for this session.`}
+
+ { notifyRef.current = e.currentTarget.checked; }}
+ />
+ >
+ ),
+ labels: {confirm: t`Save`, cancel: t`Cancel`},
+ onConfirm: () => submit(values, notifyRef.current),
+ });
+ return;
+ }
+
+ submit(values, false);
+ };
+
+ const refundRef = useRef(false);
+
+ const handleCancel = () => {
+ refundRef.current = false;
+ const orderCount = occurrence?.statistics?.orders_created ?? 0;
+
+ modals.openConfirmModal({
+ title: t`Cancel Date`,
+ children: (
+ <>
+
+ {t`Are you sure you want to cancel this date? Affected attendees will be notified by email.`}
+
+ {orderCount > 0 && (
+
+ {t`This date has ${orderCount} order(s) that will be affected.`}
+
+ )}
+ { refundRef.current = e.currentTarget.checked; }}
+ />
+ >
+ ),
+ labels: {confirm: t`Cancel Date`, cancel: t`Go Back`},
+ confirmProps: {color: 'red'},
+ onConfirm: () => {
+ cancelMutation.mutate({eventId, occurrenceId, refundOrders: refundRef.current}, {
+ onSuccess: () => {
+ showSuccess(t`Date cancelled successfully`);
+ onClose();
+ },
+ onError: (error: any) => {
+ showError(error?.response?.data?.message || t`Failed to cancel date`);
+ },
+ });
+ },
+ });
+ };
+
+ const handleDelete = () => {
+ modals.openConfirmModal({
+ title: t`Delete Date`,
+ children: (
+
+ {t`Are you sure you want to permanently delete this date? This cannot be undone.`}
+
+ ),
+ labels: {confirm: t`Delete Permanently`, cancel: t`Go Back`},
+ confirmProps: {color: 'red'},
+ onConfirm: () => {
+ deleteMutation.mutate({eventId, occurrenceId}, {
+ onSuccess: () => {
+ showSuccess(t`Date deleted successfully`);
+ onClose();
+ },
+ onError: (error: any) => {
+ showError(error?.response?.data?.message || t`Failed to delete date. It may have existing orders.`);
+ },
+ });
+ },
+ });
+ };
+
+ const isCancelled = occurrence?.status === EventOccurrenceStatus.CANCELLED;
+ const isPending = createMutation.isPending || updateMutation.isPending;
+ const isStartDateInPast = useMemo(() => {
+ if (!form.values.start_date) return false;
+ return dayjs(form.values.start_date).isBefore(dayjs());
+ }, [form.values.start_date]);
+
+ const notificationTemplate = useMemo(() => {
+ if (!pendingNotification || !event) return null;
+ return buildSingleRescheduleTemplate(
+ event,
+ pendingNotification.occurrence,
+ pendingNotification.newStartDate,
+ pendingNotification.newEndDate,
+ );
+ }, [pendingNotification, event]);
+
+ return (
+ <>
+
+
+
+ }>
+ {t`Details`}
+
+ {isEditing && (
+ }>
+ {t`Products`}
+
+ )}
+
+
+
+ {isCancelled && (
+
+
+
+
+
+ {t`This date has been cancelled. You can still delete it to remove it permanently.`}
+
+
+ )}
+
+
+
+
+
+
+
+
+ }
+ />
+
+ {isStartDateInPast && !isEditing && (
+
+
+ {t`This date is in the past. It will be created but won't be visible to attendees under upcoming dates.`}
+
+ )}
+
+
+
+
+
+ {isEditing ? t`Save Changes` : t`Create Date`}
+
+
+
+
+ {isEditing && (
+
+
+
+ {!isCancelled && (
+
+
+
+
+
+
{t`Cancel Date`}
+
{t`Notify attendees and stop sales`}
+
+
+ )}
+
+
+
+
+
+
{t`Delete Date`}
+
{t`Permanently remove this date`}
+
+
+
+
+ )}
+
+
+ {isEditing && (
+
+ {isCancelled ? (
+
+
+
+
+
+ {t`Product settings cannot be edited for cancelled dates.`}
+
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ {pendingNotification && notificationTemplate && (
+ {
+ setPendingNotification(null);
+ onClose();
+ }}
+ messageType={MessageType.AllAttendees}
+ eventOccurrenceId={pendingNotification.occurrence.id}
+ initialSubject={notificationTemplate.subject}
+ initialMessage={notificationTemplate.message}
+ />
+ )}
+ >
+ );
+};
diff --git a/frontend/src/components/routes/event/OccurrencesTab/OccurrenceMenu.tsx b/frontend/src/components/routes/event/OccurrencesTab/OccurrenceMenu.tsx
new file mode 100644
index 0000000000..1c208f991b
--- /dev/null
+++ b/frontend/src/components/routes/event/OccurrencesTab/OccurrenceMenu.tsx
@@ -0,0 +1,233 @@
+import {ReactNode} from "react";
+import {t} from "@lingui/macro";
+import {ActionIcon, Menu, Tooltip} from "@mantine/core";
+import {
+ IconAlertTriangle,
+ IconChartBar,
+ IconCheck,
+ IconClipboardList,
+ IconCopy,
+ IconDotsVertical,
+ IconPencil,
+ IconPlayerPlay,
+ IconReceipt,
+ IconSend,
+ IconShare,
+ IconTrash,
+ IconUsers,
+ IconX,
+} from "@tabler/icons-react";
+import {EventOccurrence, EventOccurrenceStatus, IdParam} from "../../../../types.ts";
+
+export interface OccurrenceMenuActions {
+ eventId: IdParam;
+ onEdit: (occurrenceId: number) => void;
+ onCancel: (occurrenceId: number) => void;
+ onDelete: (occurrenceId: number) => void;
+ onNavigate: (path: string) => void;
+ onDuplicate?: (occ: EventOccurrence) => void;
+ onMessage?: (occurrenceId: number) => void;
+ onCheckIn?: (occurrenceId: number) => void;
+ onReactivate?: (occ: EventOccurrence) => void;
+ onShare?: (occ: EventOccurrence) => void;
+}
+
+type ActionGroup = 'primary' | 'secondary' | 'danger';
+
+interface OccurrenceAction {
+ key: string;
+ icon: ReactNode;
+ label: string;
+ onClick: () => void;
+ group: ActionGroup;
+ color?: string;
+}
+
+const buildActions = (occ: EventOccurrence, actions: OccurrenceMenuActions): OccurrenceAction[] => {
+ const isActive = occ.status === EventOccurrenceStatus.ACTIVE;
+ const isCancelled = occ.status === EventOccurrenceStatus.CANCELLED;
+ const id = occ.id as number;
+
+ const items: (OccurrenceAction | false)[] = [
+ {key: 'edit', icon: , label: t`Edit`, onClick: () => actions.onEdit(id), group: 'primary'},
+ !!actions.onDuplicate && {key: 'duplicate', icon: , label: t`Duplicate`, onClick: () => actions.onDuplicate!(occ), group: 'secondary'},
+ {key: 'dashboard', icon: , label: t`Dashboard`, onClick: () => actions.onNavigate(`/manage/event/${actions.eventId}/occurrences/${occ.id}`), group: 'primary'},
+ {key: 'attendees', icon: , label: t`Attendees`, onClick: () => actions.onNavigate(`/manage/event/${actions.eventId}/attendees?filterFields[event_occurrence_id][eq]=${occ.id}`), group: 'secondary'},
+ {key: 'orders', icon: , label: t`Orders`, onClick: () => actions.onNavigate(`/manage/event/${actions.eventId}/orders?filterFields[event_occurrence_id][eq]=${occ.id}`), group: 'secondary'},
+ !!actions.onMessage && {key: 'message', icon: , label: t`Message`, onClick: () => actions.onMessage!(id), group: 'primary'},
+ !!actions.onCheckIn && !isCancelled && {key: 'checkin', icon: , label: t`Check-In`, onClick: () => actions.onCheckIn!(id), group: 'primary'},
+ !!actions.onShare && !isCancelled && {key: 'share', icon: , label: t`Share`, onClick: () => actions.onShare!(occ), group: 'primary'},
+ isCancelled && !!actions.onReactivate && {key: 'reactivate', icon: , label: t`Reopen for new sales`, onClick: () => actions.onReactivate!(occ), group: 'primary', color: 'green'},
+ isActive && {key: 'cancel', icon: , label: t`Cancel`, onClick: () => actions.onCancel(id), group: 'danger', color: 'red'},
+ {key: 'delete', icon: , label: t`Delete`, onClick: () => actions.onDelete(id), group: 'danger', color: 'red'},
+ ];
+
+ return items.filter(Boolean) as OccurrenceAction[];
+};
+
+// Vertical dropdown menu (used in table rows and calendar)
+
+interface OccurrenceMenuItemsProps {
+ occurrence: EventOccurrence;
+ actions: OccurrenceMenuActions;
+}
+
+export const OccurrenceMenuItems = ({occurrence, actions}: OccurrenceMenuItemsProps) => {
+ const allActions = buildActions(occurrence, actions);
+ const primary = allActions.filter(a => a.group === 'primary');
+ const secondary = allActions.filter(a => a.group === 'secondary');
+ const danger = allActions.filter(a => a.group === 'danger');
+
+ return (
+ <>
+ {t`Manage`}
+ {[...primary, ...secondary].map(action => (
+
+ {action.label}
+
+ ))}
+ {danger.length > 0 && (
+ <>
+
+ {t`Danger zone`}
+ {danger.map(action => (
+
+ {action.label}
+
+ ))}
+ >
+ )}
+ >
+ );
+};
+
+// Horizontal action bar (used in slideout / manage modal)
+
+interface OccurrenceActionBarProps {
+ occurrence: EventOccurrence;
+ actions: OccurrenceMenuActions;
+}
+
+const actionButtonStyle = {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 4,
+ padding: '6px 10px',
+ borderRadius: 'var(--mantine-radius-sm)',
+ fontSize: 'var(--mantine-font-size-xs)',
+ fontWeight: 500,
+ cursor: 'pointer',
+ border: 'none',
+ background: 'var(--mantine-color-gray-1)',
+ color: 'var(--mantine-color-text)',
+ transition: 'background 0.1s',
+ whiteSpace: 'nowrap' as const,
+};
+
+export const OccurrenceActionBar = ({occurrence, actions}: OccurrenceActionBarProps) => {
+ const allActions = buildActions(occurrence, actions);
+ const primary = allActions.filter(a => a.group === 'primary');
+ const overflow = allActions.filter(a => a.group === 'secondary' || a.group === 'danger');
+ const overflowSecondary = overflow.filter(a => a.group === 'secondary');
+ const overflowDanger = overflow.filter(a => a.group === 'danger');
+
+ const getStyle = (action: OccurrenceAction) => {
+ if (action.color === 'green') {
+ return {style: {...actionButtonStyle, background: 'var(--mantine-color-green-0)', color: 'var(--mantine-color-green-7)'}, hoverBg: 'var(--mantine-color-green-1)', restBg: 'var(--mantine-color-green-0)'};
+ }
+ return {style: actionButtonStyle, hoverBg: 'var(--mantine-color-gray-2)', restBg: 'var(--mantine-color-gray-1)'};
+ };
+
+ return (
+
+ {primary.map(action => {
+ const {style, hoverBg, restBg} = getStyle(action);
+ return (
+
(e.currentTarget.style.background = hoverBg)}
+ onMouseLeave={e => (e.currentTarget.style.background = restBg)}
+ >
+ {action.icon} {action.label}
+
+ );
+ })}
+
+ {overflow.length > 0 && (
+
+
+
+
+
+
+
+
+
+ {overflowSecondary.map(action => (
+
+ {action.label}
+
+ ))}
+ {overflowDanger.length > 0 && overflowSecondary.length > 0 && }
+ {overflowDanger.map(action => (
+
+ {action.label}
+
+ ))}
+
+
+ )}
+
+ );
+};
+
+// Shared utilities
+
+export const statusLabel = (status?: EventOccurrenceStatus) => {
+ switch (status) {
+ case EventOccurrenceStatus.ACTIVE:
+ return t`Active`;
+ case EventOccurrenceStatus.CANCELLED:
+ return t`Cancelled`;
+ case EventOccurrenceStatus.SOLD_OUT:
+ return t`Sold Out`;
+ default:
+ return status || '';
+ }
+};
+
+export const StatusIcon = ({status}: {status?: EventOccurrenceStatus}) => {
+ switch (status) {
+ case EventOccurrenceStatus.ACTIVE:
+ return ;
+ case EventOccurrenceStatus.CANCELLED:
+ return ;
+ case EventOccurrenceStatus.SOLD_OUT:
+ return ;
+ default:
+ return null;
+ }
+};
diff --git a/frontend/src/components/routes/event/OccurrencesTab/OccurrencesTab.module.scss b/frontend/src/components/routes/event/OccurrencesTab/OccurrencesTab.module.scss
new file mode 100644
index 0000000000..dad294188a
--- /dev/null
+++ b/frontend/src/components/routes/event/OccurrencesTab/OccurrencesTab.module.scss
@@ -0,0 +1,235 @@
+@use "../../../../styles/mixins";
+
+// Unified toolbar
+.toolbar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 16px;
+ background: white;
+ border: 1px solid var(--mantine-color-gray-2);
+ border-radius: var(--mantine-radius-md);
+ margin-bottom: 16px;
+ flex-wrap: wrap;
+}
+
+.toolbarDivider {
+ width: 1px;
+ height: 24px;
+ background: var(--mantine-color-gray-2);
+ flex-shrink: 0;
+}
+
+.toolbarSpacer {
+ flex: 1;
+}
+
+// Selection group (inline in toolbar)
+.selectionGroup {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.selectionCount {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--mantine-primary-color-filled);
+ padding-right: 4px;
+}
+
+.selectionAction {
+ padding: 4px 10px;
+ font-size: 12px;
+ font-weight: 500;
+ border-radius: var(--mantine-radius-sm);
+ cursor: pointer;
+ border: 1px solid var(--mantine-color-gray-3);
+ background: white;
+ color: var(--mantine-color-text);
+ transition: all 0.12s ease;
+
+ &:hover {
+ background: var(--mantine-color-gray-0);
+ border-color: var(--mantine-color-gray-4);
+ }
+
+ &[data-danger] {
+ color: var(--mantine-color-red-7);
+ border-color: var(--mantine-color-red-2);
+
+ &:hover {
+ background: var(--mantine-color-red-0);
+ border-color: var(--mantine-color-red-3);
+ }
+ }
+}
+
+// Date & Time column
+.dateTimeLink {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ text-decoration: none;
+ color: inherit;
+ cursor: pointer;
+
+ &:hover .dateTimePrimary {
+ color: var(--mantine-color-primary-6);
+ }
+}
+
+.dateTimePrimary {
+ display: flex;
+ align-items: center;
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--mantine-color-text);
+ line-height: 1.3;
+ transition: color 0.15s ease;
+}
+
+.dateTimeMeta {
+ font-size: 12px;
+ color: var(--mantine-color-dimmed);
+ line-height: 1.3;
+}
+
+// Status badge
+.statusBadge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 6px 12px;
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ white-space: nowrap;
+ transition: filter 0.15s ease, box-shadow 0.15s ease;
+
+ &[data-status="ACTIVE"] {
+ background: var(--mantine-color-green-1);
+ color: var(--mantine-color-green-9);
+ }
+
+ &[data-status="CANCELLED"] {
+ background: var(--mantine-color-red-1);
+ color: var(--mantine-color-red-9);
+ }
+
+ &[data-status="SOLD_OUT"] {
+ background: var(--mantine-color-orange-1);
+ color: var(--mantine-color-orange-9);
+ }
+
+ &[data-clickable] {
+ cursor: pointer;
+
+ &:hover {
+ filter: brightness(0.95);
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
+ }
+ }
+}
+
+.editedIcon {
+ color: var(--mantine-color-dimmed);
+ margin-left: 8px;
+ vertical-align: middle;
+ flex-shrink: 0;
+}
+
+// Tickets sold column
+.ticketsSold {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 80px;
+}
+
+.ticketsSoldNumbers {
+ font-size: 13px;
+ line-height: 1;
+}
+
+.ticketsSoldCount {
+ font-weight: 600;
+ color: var(--mantine-color-text);
+}
+
+.ticketsSoldTotal {
+ color: var(--mantine-color-dimmed);
+}
+
+.ticketsProgress {
+ max-width: 80px;
+}
+
+.capacityUnlimited {
+ font-size: 13px;
+ color: var(--mantine-color-dimmed);
+}
+
+// Activity column
+.activity {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.activityText {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--mantine-color-text);
+}
+
+.activityRefunded {
+ font-size: 11px;
+ color: var(--mantine-color-red-6);
+}
+
+// Actions
+.action {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+}
+
+// Empty state
+.emptyState {
+ padding: 60px 24px;
+ text-align: center;
+ border-style: dashed;
+}
+
+// Content area - ensures consistent layout across loading/list/calendar views
+.contentArea {
+ min-height: 720px;
+}
+
+// Count text
+.countText {
+ font-size: 13px;
+ color: var(--mantine-color-dimmed);
+ padding: 12px 4px 0;
+}
+
+@media (max-width: 768px) {
+ .toolbar {
+ gap: 6px;
+ padding: 8px;
+ }
+
+ .toolbarDivider {
+ display: none;
+ }
+
+ .toolbarSpacer {
+ display: none;
+ }
+
+ .selectionGroup {
+ width: 100%;
+ flex-wrap: wrap;
+ }
+}
diff --git a/frontend/src/components/routes/event/OccurrencesTab/PriceOverrideForm/PriceOverrideForm.module.scss b/frontend/src/components/routes/event/OccurrencesTab/PriceOverrideForm/PriceOverrideForm.module.scss
new file mode 100644
index 0000000000..83a3bd00f0
--- /dev/null
+++ b/frontend/src/components/routes/event/OccurrencesTab/PriceOverrideForm/PriceOverrideForm.module.scss
@@ -0,0 +1,119 @@
+.infoText {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: var(--mantine-font-size-xs);
+ color: var(--mantine-color-dimmed);
+ margin-bottom: 4px;
+
+ svg {
+ flex-shrink: 0;
+ color: var(--mantine-color-blue-4);
+ }
+}
+
+.productCard {
+ border: 1px solid var(--mantine-color-gray-2);
+ border-radius: var(--mantine-radius-md);
+ overflow: hidden;
+ transition: border-color 0.15s ease, opacity 0.15s ease;
+
+ &:hover {
+ border-color: var(--mantine-color-gray-3);
+ }
+
+ &.disabled {
+ opacity: 0.5;
+ }
+}
+
+.productHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px 16px;
+ min-height: 48px;
+ background: var(--mantine-color-gray-0);
+ border-bottom: 1px solid var(--mantine-color-gray-2);
+
+ // Mantine's Switch ships with margin-bottom: 20px on its root for spacing
+ // when used inside form stacks. Inside a flex row with align-items: center,
+ // that margin is included in the centered box — so the visible track sits
+ // 10px above the optical center. Zero it out here.
+ :global(.mantine-Switch-root) {
+ margin: 0;
+ }
+}
+
+.productName {
+ font-weight: 600;
+ font-size: var(--mantine-font-size-sm);
+ // Explicit line-height so the text's optical center matches the Switch's
+ // track center — without this, inherited line-height (1.5+) makes the text
+ // sit slightly above the toggle even with align-items: center on the flex.
+ line-height: 1;
+ color: var(--mantine-color-text);
+}
+
+// Price tiers used to render as a ; vertical-align: middle on td uses
+// table baseline rules, which mis-aligned the NumberInput and ActionIcon
+// against the text spans next to them. Grid + align-items: center sidesteps
+// the issue entirely — every cell on a row sits on the same vertical axis.
+.priceTable {
+ display: flex;
+ flex-direction: column;
+}
+
+.priceHeaderRow,
+.priceRow {
+ display: grid;
+ grid-template-columns: 1fr 1fr minmax(0, 130px) 32px;
+ align-items: center;
+ gap: 12px;
+ padding: 8px 16px;
+ border-bottom: 1px solid var(--mantine-color-gray-1);
+}
+
+.priceRow {
+ padding-top: 6px;
+ padding-bottom: 6px;
+ font-size: var(--mantine-font-size-sm);
+
+ &:last-child {
+ border-bottom: none;
+ }
+}
+
+.priceHeaderRow {
+ font-size: var(--mantine-font-size-xs);
+ font-weight: 500;
+ color: var(--mantine-color-dimmed);
+}
+
+.priceLabel {
+ font-weight: 500;
+ color: var(--mantine-color-text);
+}
+
+.basePrice {
+ color: var(--mantine-color-dimmed);
+}
+
+.overrideInput {
+ width: 100%;
+ // Same Mantine quirk as the Switch — InputWrapper has a 20px bottom margin
+ // by default, which would shift the input 10px above center inside the
+ // grid row.
+ margin: 0;
+}
+
+.priceRowAction {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.saveButton {
+ margin-top: 4px;
+}
diff --git a/frontend/src/components/routes/event/OccurrencesTab/PriceOverrideForm/index.tsx b/frontend/src/components/routes/event/OccurrencesTab/PriceOverrideForm/index.tsx
new file mode 100644
index 0000000000..089b585b7d
--- /dev/null
+++ b/frontend/src/components/routes/event/OccurrencesTab/PriceOverrideForm/index.tsx
@@ -0,0 +1,254 @@
+import {t} from "@lingui/macro";
+import {
+ ActionIcon,
+ Button,
+ NumberInput,
+ Switch,
+ Text,
+ Tooltip,
+} from "@mantine/core";
+import {useParams} from "react-router";
+import {useCallback, useEffect, useState} from "react";
+import {IconInfoCircle, IconX} from "@tabler/icons-react";
+import {IdParam, ProductPriceOccurrenceOverride} from "../../../../../types.ts";
+import {useGetEvent} from "../../../../../queries/useGetEvent.ts";
+import {useGetPriceOverrides} from "../../../../../queries/useGetPriceOverrides.ts";
+import {useGetProductVisibility} from "../../../../../queries/useGetProductVisibility.ts";
+import {useUpsertPriceOverride} from "../../../../../mutations/useUpsertPriceOverride.ts";
+import {useDeletePriceOverride} from "../../../../../mutations/useDeletePriceOverride.ts";
+import {useUpdateProductVisibility} from "../../../../../mutations/useUpdateProductVisibility.ts";
+import {showSuccess, showError} from "../../../../../utilites/notifications.tsx";
+import {getProductsFromEvent} from "../../../../../utilites/helpers.ts";
+import classes from "./PriceOverrideForm.module.scss";
+
+interface OccurrenceProductSettingsProps {
+ occurrenceId?: IdParam;
+}
+
+export const OccurrenceProductSettings = ({occurrenceId}: OccurrenceProductSettingsProps) => {
+ const {eventId} = useParams();
+ const {data: event} = useGetEvent(eventId);
+ const {data: overrides} = useGetPriceOverrides(eventId, occurrenceId);
+ const {data: visibilityData} = useGetProductVisibility(eventId, occurrenceId);
+ const upsertMutation = useUpsertPriceOverride();
+ const deleteMutation = useDeletePriceOverride();
+ const visibilityMutation = useUpdateProductVisibility();
+ const [pendingOverrides, setPendingOverrides] = useState>({});
+ const [enabledProductIds, setEnabledProductIds] = useState>(new Set());
+ const [visibilityInitialized, setVisibilityInitialized] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
+
+ const products = getProductsFromEvent(event);
+
+ useEffect(() => {
+ if (!products || visibilityInitialized) return;
+
+ if (visibilityData && visibilityData.length > 0) {
+ setEnabledProductIds(new Set(visibilityData.map(v => Number(v.product_id))));
+ } else if (visibilityData) {
+ setEnabledProductIds(new Set(products.map(p => p.id!)));
+ }
+ if (visibilityData !== undefined) {
+ setVisibilityInitialized(true);
+ }
+ }, [visibilityData, products, visibilityInitialized]);
+
+ const getExistingOverride = useCallback((priceId: number): ProductPriceOccurrenceOverride | undefined => {
+ return overrides?.find(o => o.product_price_id === priceId);
+ }, [overrides]);
+
+ const handleToggleProduct = (productId: number, enabled: boolean) => {
+ setEnabledProductIds(prev => {
+ const next = new Set(prev);
+ if (enabled) {
+ next.add(productId);
+ } else {
+ next.delete(productId);
+ }
+ return next;
+ });
+ };
+
+ const handlePriceChange = (priceId: number, value: number | string) => {
+ setPendingOverrides(prev => ({
+ ...prev,
+ [priceId]: value === '' ? undefined : Number(value),
+ }));
+ };
+
+ const handleResetOverride = (priceId: number) => {
+ const existing = getExistingOverride(priceId);
+ if (!existing?.id) return;
+
+ deleteMutation.mutate({
+ eventId,
+ occurrenceId,
+ overrideId: existing.id,
+ }, {
+ onSuccess: () => {
+ showSuccess(t`Override removed`);
+ setPendingOverrides(prev => {
+ const next = {...prev};
+ delete next[priceId];
+ return next;
+ });
+ },
+ onError: (error: any) => {
+ showError(error?.response?.data?.message || t`Failed to remove override`);
+ },
+ });
+ };
+
+ const handleSave = async () => {
+ if (!products) return;
+ setIsSaving(true);
+
+ try {
+ await visibilityMutation.mutateAsync({
+ eventId,
+ occurrenceId,
+ productIds: Array.from(enabledProductIds),
+ });
+
+ const disabledProductIds = new Set(
+ products.filter(p => !enabledProductIds.has(p.id!)).map(p => p.id!)
+ );
+ const overridesToDelete = (overrides || []).filter(o => {
+ const product = products.find(p => p.prices?.some(pr => pr.id === Number(o.product_price_id)));
+ return product && disabledProductIds.has(product.id!);
+ });
+
+ for (const override of overridesToDelete) {
+ if (override.id) {
+ await deleteMutation.mutateAsync({eventId, occurrenceId, overrideId: override.id});
+ }
+ }
+
+ const priceEntries = Object.entries(pendingOverrides).filter(([, val]) => val !== undefined);
+
+ for (const [priceId, price] of priceEntries) {
+ const priceProduct = products.find(p => p.prices?.some(pr => pr.id === Number(priceId)));
+ if (priceProduct && !enabledProductIds.has(priceProduct.id!)) continue;
+
+ try {
+ await upsertMutation.mutateAsync({
+ eventId,
+ occurrenceId,
+ data: {product_price_id: Number(priceId), price: price!},
+ });
+ } catch (error: any) {
+ showError(error?.response?.data?.message || t`Failed to save price override`);
+ }
+ }
+
+ showSuccess(t`Product settings saved successfully`);
+ setPendingOverrides({});
+ } catch (error: any) {
+ showError(error?.response?.data?.message || t`Failed to save product settings`);
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ if (!products || products.length === 0) {
+ return {t`No products configured for this event.`} ;
+ }
+
+ if (!visibilityInitialized) {
+ return null;
+ }
+
+ return (
+
+
+
+ {t`Configure which products are available for this occurrence and optionally adjust pricing.`}
+
+
+ {products.map(product => {
+ const isEnabled = enabledProductIds.has(product.id!);
+ const hasPrices = isEnabled && product.prices && product.prices.length > 0;
+
+ return (
+
+
+ {product.title}
+ handleToggleProduct(product.id!, e.currentTarget.checked)}
+ size="sm"
+ />
+
+
+ {hasPrices && (
+
+
+ {t`Price Tier`}
+ {t`Base Price`}
+ {t`Override`}
+
+
+ {product.prices!.map(price => {
+ const existing = getExistingOverride(price.id!);
+ const pendingValue = pendingOverrides[price.id!];
+ const displayPrice = pendingValue !== undefined
+ ? pendingValue
+ : existing?.price;
+
+ return (
+
+
+ {price.label || t`Default`}
+
+
+ {price.price?.toFixed(2)}
+
+
handlePriceChange(price.id!, val)}
+ min={0}
+ decimalScale={2}
+ fixedDecimalScale
+ className={classes.overrideInput}
+ />
+
+ {existing && (
+
+ handleResetOverride(price.id!)}
+ >
+
+
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+ );
+ })}
+
+
+ {t`Save Changes`}
+
+
+ );
+};
diff --git a/frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleModal/RecurrenceScheduleModal.module.scss b/frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleModal/RecurrenceScheduleModal.module.scss
new file mode 100644
index 0000000000..7775bfeb12
--- /dev/null
+++ b/frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleModal/RecurrenceScheduleModal.module.scss
@@ -0,0 +1,274 @@
+// Section cards
+.section {
+ border: 1px solid var(--mantine-color-gray-2);
+ border-radius: var(--mantine-radius-md);
+ padding: 16px;
+ margin-bottom: 16px;
+ transition: border-color 0.15s ease;
+
+ &:hover {
+ border-color: var(--mantine-color-gray-3);
+ }
+}
+
+.sectionHeader {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 12px;
+}
+
+.sectionIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: var(--mantine-radius-sm);
+ background: var(--mantine-primary-color-0);
+ color: var(--mantine-primary-color-filled);
+ flex-shrink: 0;
+}
+
+.sectionTitle {
+ font-weight: 600;
+ font-size: var(--mantine-font-size-sm);
+ color: var(--mantine-color-text);
+}
+
+.sectionTip {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: var(--mantine-font-size-xs);
+ color: var(--mantine-color-dimmed);
+ margin-top: 10px;
+ line-height: 1.5;
+}
+
+.tipIcon {
+ color: var(--mantine-color-yellow-6);
+ flex-shrink: 0;
+}
+
+.introCallout {
+ margin-bottom: 16px;
+}
+
+// Time slots
+.timeSlot {
+ &:not(:last-child) {
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--mantine-color-gray-1);
+ }
+}
+
+.timeSlotTimes {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.timeSlotTimes :global(.mantine-TextInput-root) {
+ flex: 1;
+ min-width: 0;
+}
+
+.timeSeparator {
+ font-size: var(--mantine-font-size-sm);
+ color: var(--mantine-color-dimmed);
+ flex-shrink: 0;
+ padding-top: 4px;
+}
+
+.timeSeparatorLabeled {
+ padding-top: 26px;
+}
+
+.removeButton {
+ flex-shrink: 0;
+}
+
+.removeButtonLabeled {
+ margin-top: 22px;
+}
+
+// Range type toggle
+.rangeToggle {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 12px;
+
+ @media (max-width: 480px) {
+ flex-direction: column;
+ }
+}
+
+.rangeOption {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 14px;
+ border-radius: var(--mantine-radius-md);
+ border: 2px solid var(--mantine-color-gray-2);
+ cursor: pointer;
+ transition: all 0.15s ease;
+ user-select: none;
+ background: white;
+
+ &:hover {
+ border-color: var(--mantine-color-gray-4);
+ }
+
+ &[data-active="true"] {
+ border-color: var(--mantine-primary-color-filled);
+ background: var(--mantine-primary-color-0);
+
+ .rangeOptionIcon {
+ background: var(--mantine-primary-color-1);
+ color: var(--mantine-primary-color-filled);
+ }
+ }
+}
+
+.rangeOptionIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border-radius: var(--mantine-radius-sm);
+ background: var(--mantine-color-gray-1);
+ color: var(--mantine-color-gray-6);
+ flex-shrink: 0;
+ transition: all 0.15s ease;
+}
+
+.rangeOptionLabel {
+ font-size: var(--mantine-font-size-sm);
+ font-weight: 500;
+ color: var(--mantine-color-text);
+}
+
+.rangeOptionDesc {
+ font-size: var(--mantine-font-size-xs);
+ color: var(--mantine-color-dimmed);
+}
+
+// Preview
+.previewCard {
+ border-radius: var(--mantine-radius-md);
+ background: var(--mantine-primary-color-0);
+ border: 1px solid var(--mantine-primary-color-2);
+ padding: 14px;
+ margin-top: 16px;
+}
+
+.previewHeader {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 10px;
+}
+
+.previewIcon {
+ color: var(--mantine-primary-color-filled);
+}
+
+.previewLabel {
+ font-size: var(--mantine-font-size-sm);
+ font-weight: 600;
+ color: var(--mantine-primary-color-filled);
+}
+
+.previewDates {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.previewChip {
+ font-size: var(--mantine-font-size-xs);
+ color: var(--mantine-primary-color-filled);
+ background: rgba(255, 255, 255, 0.65);
+ border-radius: var(--mantine-radius-sm);
+ padding: 3px 10px;
+ white-space: nowrap;
+}
+
+.previewMore {
+ font-size: var(--mantine-font-size-xs);
+ color: var(--mantine-color-dimmed);
+ font-style: italic;
+ padding: 3px 0;
+}
+
+// Warning state for preview card
+.previewCardWarning {
+ border-radius: var(--mantine-radius-md);
+ background: var(--mantine-color-orange-0);
+ border: 1px solid var(--mantine-color-orange-3);
+ padding: 14px;
+ margin-top: 16px;
+}
+
+.previewIconWarning {
+ color: var(--mantine-color-orange-7);
+}
+
+.previewLabelWarning {
+ font-size: var(--mantine-font-size-sm);
+ font-weight: 600;
+ color: var(--mantine-color-orange-8);
+}
+
+.previewChipWarning {
+ font-size: var(--mantine-font-size-xs);
+ color: var(--mantine-color-orange-8);
+ background: rgba(255, 255, 255, 0.65);
+ border-radius: var(--mantine-radius-sm);
+ padding: 3px 10px;
+ white-space: nowrap;
+}
+
+.previewWarning {
+ font-size: var(--mantine-font-size-xs);
+ color: var(--mantine-color-orange-7);
+ margin-bottom: 6px;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+// Mobile
+@media (max-width: 480px) {
+ .timeSlotTimes {
+ flex-wrap: wrap;
+ }
+
+ .timeSlotTimes :global(.mantine-TextInput-root) {
+ flex: 1 1 40%;
+ min-width: 100px;
+ }
+
+ .timeSeparator {
+ padding-top: 0;
+ }
+
+ .timeSeparatorLabeled {
+ padding-top: 0;
+ }
+
+ .removeButtonLabeled {
+ margin-top: 0;
+ }
+
+ .welcomeBanner {
+ padding: 16px 12px;
+ }
+
+ .section {
+ padding: 12px;
+ }
+}
diff --git a/frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleModal/index.tsx b/frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleModal/index.tsx
new file mode 100644
index 0000000000..e67e6e8c17
--- /dev/null
+++ b/frontend/src/components/routes/event/OccurrencesTab/RecurrenceScheduleModal/index.tsx
@@ -0,0 +1,888 @@
+import {t, plural} from "@lingui/macro";
+import {
+ ActionIcon,
+ Button,
+ Checkbox,
+ Chip,
+ Group,
+ NumberInput,
+ Radio,
+ Select,
+ Stack,
+ Text,
+ TextInput,
+} from "@mantine/core";
+import {useForm} from "@mantine/form";
+import {useParams} from "react-router";
+import {
+ IconAlertTriangle,
+ IconBulb,
+ IconCalendarEvent,
+ IconCalendarStats,
+ IconClock,
+ IconHash,
+ IconPlus,
+ IconRepeat,
+ IconSparkles,
+ IconTag,
+ IconUsers,
+ IconX,
+} from "@tabler/icons-react";
+import {Modal} from "../../../../common/Modal";
+import {InputGroup} from "../../../../common/InputGroup";
+import {Callout} from "../../../../common/Callout";
+
+import {GenericModalProps, RecurrenceRule, RecurrenceTimeSlot} from "../../../../../types.ts";
+import {useGenerateOccurrences} from "../../../../../mutations/useGenerateOccurrences.ts";
+import {useGetEvent} from "../../../../../queries/useGetEvent.ts";
+import {showSuccess, showError} from "../../../../../utilites/notifications.tsx";
+import {useFormErrorResponseHandler} from "../../../../../hooks/useFormErrorResponseHandler.tsx";
+import {useEffect, useMemo} from "react";
+import classes from './RecurrenceScheduleModal.module.scss';
+
+const MAX_PREVIEW = 1200;
+
+const DAYS_OF_WEEK = [
+ {value: 'monday', label: t`Mon`},
+ {value: 'tuesday', label: t`Tue`},
+ {value: 'wednesday', label: t`Wed`},
+ {value: 'thursday', label: t`Thu`},
+ {value: 'friday', label: t`Fri`},
+ {value: 'saturday', label: t`Sat`},
+ {value: 'sunday', label: t`Sun`},
+];
+
+const FREQUENCIES = [
+ {value: 'daily', label: t`Daily`},
+ {value: 'weekly', label: t`Weekly`},
+ {value: 'monthly', label: t`Monthly`},
+ {value: 'yearly', label: t`Yearly`},
+];
+
+const WEEK_POSITIONS = [
+ {value: '1', label: t`First`},
+ {value: '2', label: t`Second`},
+ {value: '3', label: t`Third`},
+ {value: '4', label: t`Fourth`},
+ {value: '-1', label: t`Last`},
+];
+
+const MONTHS = [
+ {value: '1', label: t`January`},
+ {value: '2', label: t`February`},
+ {value: '3', label: t`March`},
+ {value: '4', label: t`April`},
+ {value: '5', label: t`May`},
+ {value: '6', label: t`June`},
+ {value: '7', label: t`July`},
+ {value: '8', label: t`August`},
+ {value: '9', label: t`September`},
+ {value: '10', label: t`October`},
+ {value: '11', label: t`November`},
+ {value: '12', label: t`December`},
+];
+
+const DAY_NUMBER_MAP: Record = {
+ 'sunday': 0, 'monday': 1, 'tuesday': 2, 'wednesday': 3,
+ 'thursday': 4, 'friday': 5, 'saturday': 6,
+};
+
+const frequencyUnitLabel = (frequency: string, interval: number): string => {
+ if (interval === 1) {
+ switch (frequency) {
+ case 'daily': return t`day`;
+ case 'weekly': return t`week`;
+ case 'monthly': return t`month`;
+ case 'yearly': return t`year`;
+ default: return '';
+ }
+ }
+ switch (frequency) {
+ case 'daily': return t`days`;
+ case 'weekly': return t`weeks`;
+ case 'monthly': return t`months`;
+ case 'yearly': return t`years`;
+ default: return '';
+ }
+};
+
+const getNthWeekdayOfMonth = (year: number, month: number, dayOfWeek: number, position: number): Date | null => {
+ if (position === -1) {
+ const lastDay = new Date(year, month + 1, 0);
+ for (let d = lastDay.getDate(); d >= 1; d--) {
+ const candidate = new Date(year, month, d);
+ if (candidate.getDay() === dayOfWeek) return candidate;
+ }
+ return null;
+ }
+ let count = 0;
+ for (let d = 1; d <= 31; d++) {
+ const candidate = new Date(year, month, d);
+ if (candidate.getMonth() !== month) break;
+ if (candidate.getDay() === dayOfWeek) {
+ count++;
+ if (count === position) return candidate;
+ }
+ }
+ return null;
+};
+
+const parseLocalDate = (value: string): Date | null => {
+ if (!value) return null;
+ const [y, m, d] = value.split('-').map(Number);
+ if (!y || !m || !d) return null;
+ return new Date(y, m - 1, d);
+};
+
+const computePreviewDates = (values: RecurrenceFormValues): Date[] => {
+ const dates: Date[] = [];
+ // Anchor preview generation on the schedule start date (defaults to today,
+ // but organizers can shift it for events being scheduled in advance).
+ // Without this anchor the preview always starts from "today" even though
+ // the backend generates from values.range_start.
+ const today = parseLocalDate(values.range_start) ?? new Date();
+ today.setHours(0, 0, 0, 0);
+
+ const endDate = values.range_type === 'until' && values.range_until
+ ? new Date(values.range_until + 'T23:59:59')
+ : null;
+ const maxCount = values.range_type === 'count'
+ ? Math.min(values.range_count || 1, MAX_PREVIEW)
+ : MAX_PREVIEW;
+
+ if (values.range_type === 'until' && !endDate) return dates;
+
+ const addCandidate = (date: Date): boolean => {
+ if (endDate && date > endDate) return false;
+ if (dates.length >= maxCount) return false;
+ dates.push(new Date(date));
+ return true;
+ };
+
+ switch (values.frequency) {
+ case 'daily': {
+ const current = new Date(today);
+ let safety = 0;
+ while (dates.length < maxCount && safety < MAX_PREVIEW + 100) {
+ if (!addCandidate(current)) break;
+ current.setDate(current.getDate() + (values.interval || 1));
+ safety++;
+ }
+ break;
+ }
+ case 'weekly': {
+ const selectedDays = values.days_of_week
+ .map(d => DAY_NUMBER_MAP[d])
+ .filter(d => d !== undefined)
+ .sort((a, b) => a - b);
+ if (selectedDays.length === 0) break;
+
+ const weekStart = new Date(today);
+ const todayDay = weekStart.getDay();
+ const diff = todayDay === 0 ? -6 : 1 - todayDay;
+ weekStart.setDate(weekStart.getDate() + diff);
+
+ let safety = 0;
+ outer:
+ while (dates.length < maxCount && safety < MAX_PREVIEW + 100) {
+ for (const dayNum of selectedDays) {
+ const candidate = new Date(weekStart);
+ const offset = dayNum === 0 ? 6 : dayNum - 1;
+ candidate.setDate(weekStart.getDate() + offset);
+ if (candidate >= today) {
+ if (!addCandidate(candidate)) break outer;
+ }
+ }
+ weekStart.setDate(weekStart.getDate() + 7 * (values.interval || 1));
+ safety++;
+ }
+ break;
+ }
+ case 'monthly': {
+ if (values.monthly_pattern === 'by_day_of_month') {
+ const days = values.days_of_month
+ .map(d => parseInt(d))
+ .filter(n => !isNaN(n))
+ .sort((a, b) => a - b);
+ if (days.length === 0) break;
+
+ const currentMonth = new Date(today.getFullYear(), today.getMonth(), 1);
+ let safety = 0;
+ outer2:
+ while (dates.length < maxCount && safety < MAX_PREVIEW + 100) {
+ for (const day of days) {
+ const candidate = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day);
+ if (candidate.getMonth() !== currentMonth.getMonth()) continue;
+ if (candidate >= today) {
+ if (!addCandidate(candidate)) break outer2;
+ }
+ }
+ currentMonth.setMonth(currentMonth.getMonth() + (values.interval || 1));
+ safety++;
+ }
+ } else {
+ const targetDay = DAY_NUMBER_MAP[values.day_of_week] ?? 1;
+ const position = parseInt(values.week_position) || 1;
+
+ const currentMonth = new Date(today.getFullYear(), today.getMonth(), 1);
+ let safety = 0;
+ while (dates.length < maxCount && safety < MAX_PREVIEW + 100) {
+ const candidate = getNthWeekdayOfMonth(
+ currentMonth.getFullYear(), currentMonth.getMonth(), targetDay, position
+ );
+ if (candidate && candidate >= today) {
+ if (!addCandidate(candidate)) break;
+ }
+ currentMonth.setMonth(currentMonth.getMonth() + (values.interval || 1));
+ safety++;
+ }
+ }
+ break;
+ }
+ case 'yearly': {
+ const month = parseInt(values.yearly_month) - 1;
+ const day = values.yearly_day;
+ let year = today.getFullYear();
+ let safety = 0;
+ while (dates.length < maxCount && safety < MAX_PREVIEW + 100) {
+ const candidate = new Date(year, month, day);
+ if (candidate.getMonth() === month && candidate >= today) {
+ if (!addCandidate(candidate)) break;
+ }
+ if (endDate && candidate > endDate) break;
+ year += (values.interval || 1);
+ safety++;
+ }
+ break;
+ }
+ }
+
+ return dates;
+};
+
+const computeEndTime = (startTime: string, durationMinutes: number): string => {
+ if (!startTime || !durationMinutes) return '';
+ const [h, m] = startTime.split(':').map(Number);
+ if (isNaN(h) || isNaN(m)) return '';
+ const totalMinutes = h * 60 + m + durationMinutes;
+ const endH = Math.floor(totalMinutes / 60) % 24;
+ const endM = totalMinutes % 60;
+ return `${String(endH).padStart(2, '0')}:${String(endM).padStart(2, '0')}`;
+};
+
+const computeDurationFromTimes = (startTime: string, endTime: string): number | null => {
+ if (!startTime || !endTime) return null;
+ const [sh, sm] = startTime.split(':').map(Number);
+ const [eh, em] = endTime.split(':').map(Number);
+ if (isNaN(sh) || isNaN(sm) || isNaN(eh) || isNaN(em)) return null;
+ let diff = (eh * 60 + em) - (sh * 60 + sm);
+ if (diff <= 0) diff += 24 * 60;
+ return diff;
+};
+
+const formatPreviewDate = (date: Date): string => {
+ return date.toLocaleDateString(undefined, {month: 'short', day: 'numeric', year: 'numeric'});
+};
+
+interface TimeSlotFormValue {
+ time: string;
+ end_time: string;
+ label: string;
+}
+
+interface RecurrenceFormValues {
+ frequency: string;
+ interval: number;
+ days_of_week: string[];
+ time_slots: TimeSlotFormValue[];
+ range_start: string;
+ range_type: string;
+ range_until: string;
+ range_count: number;
+ default_capacity: number | undefined;
+ monthly_pattern: string;
+ days_of_month: string[];
+ day_of_week: string;
+ week_position: string;
+ yearly_month: string;
+ yearly_day: number;
+}
+
+const formatLocalDate = (date: Date): string => {
+ const y = date.getFullYear();
+ const m = String(date.getMonth() + 1).padStart(2, '0');
+ const d = String(date.getDate()).padStart(2, '0');
+ return `${y}-${m}-${d}`;
+};
+
+const todayLocalDate = (): string => formatLocalDate(new Date());
+
+export const RecurrenceScheduleModal = ({onClose}: GenericModalProps) => {
+ const {eventId} = useParams();
+ const {data: event} = useGetEvent(eventId);
+ const generateMutation = useGenerateOccurrences();
+ const errorHandler = useFormErrorResponseHandler();
+
+ const hasExistingRule = !!event?.recurrence_rule;
+
+ const parseTimeSlotsFromRule = (rule: RecurrenceRule): TimeSlotFormValue[] => {
+ const times = rule.times_of_day;
+ const fallbackDuration = rule.duration_minutes || 120;
+
+ if (!times?.length) {
+ return [{time: '09:00', end_time: computeEndTime('09:00', fallbackDuration), label: ''}];
+ }
+
+ return times.map((entry) => {
+ if (typeof entry === 'string') {
+ return {
+ time: entry,
+ end_time: computeEndTime(entry, fallbackDuration),
+ label: '',
+ };
+ }
+ const duration = entry.duration_minutes || fallbackDuration;
+ return {
+ time: entry.time,
+ end_time: computeEndTime(entry.time, duration),
+ label: entry.label || '',
+ };
+ });
+ };
+
+ const form = useForm({
+ initialValues: {
+ frequency: 'weekly',
+ interval: 1,
+ days_of_week: [],
+ time_slots: [{time: '09:00', end_time: '11:00', label: ''}],
+ range_start: todayLocalDate(),
+ range_type: 'until',
+ range_until: '',
+ range_count: 10,
+ default_capacity: undefined,
+ monthly_pattern: 'by_day_of_month',
+ days_of_month: ['1'],
+ day_of_week: 'monday',
+ week_position: '1',
+ yearly_month: String(new Date().getMonth() + 1),
+ yearly_day: 1,
+ },
+ validate: {
+ days_of_week: (value, values) => values.frequency === 'weekly' && value.length === 0
+ ? t`Pick at least one day of the week`
+ : null,
+ range_until: (value, values) => values.range_type === 'until' && !value
+ ? t`Pick an end date`
+ : null,
+ time_slots: (value) => value.every(s => !s.time.trim())
+ ? t`Add at least one time`
+ : null,
+ days_of_month: (value, values) => values.frequency === 'monthly'
+ && values.monthly_pattern === 'by_day_of_month'
+ && value.length === 0
+ ? t`Pick at least one day of the month`
+ : null,
+ },
+ });
+
+ useEffect(() => {
+ if (event?.recurrence_rule) {
+ const rule = event.recurrence_rule;
+ // Prefer the rule's own start, then the earliest existing occurrence
+ // start (for events that already have generated dates), then today.
+ const earliestOccurrence = event.occurrences?.length
+ ? event.occurrences
+ .map(o => o.start_date)
+ .filter((d): d is string => !!d)
+ .sort()[0]
+ : undefined;
+ const startFromRule = rule.range?.start;
+ const fallbackStart = earliestOccurrence
+ ? earliestOccurrence.slice(0, 10)
+ : todayLocalDate();
+
+ form.setValues({
+ frequency: rule.frequency || 'weekly',
+ interval: rule.interval || 1,
+ days_of_week: rule.days_of_week || [],
+ time_slots: parseTimeSlotsFromRule(rule),
+ range_start: startFromRule ? startFromRule.slice(0, 10) : fallbackStart,
+ range_type: rule.range?.type || 'until',
+ range_until: rule.range?.until || '',
+ range_count: rule.range?.count || 10,
+ default_capacity: rule.default_capacity ?? undefined,
+ monthly_pattern: rule.monthly_pattern || 'by_day_of_month',
+ days_of_month: rule.days_of_month?.map(String) || ['1'],
+ day_of_week: rule.day_of_week || 'monday',
+ week_position: String(rule.week_position || 1),
+ yearly_month: String(rule.month || new Date().getMonth() + 1),
+ yearly_day: rule.days_of_month?.[0] || 1,
+ });
+ }
+ }, [event]);
+
+ const handleAddTime = () => {
+ const lastSlot = form.values.time_slots[form.values.time_slots.length - 1];
+ const defaultStart = lastSlot?.end_time || '09:00';
+ const defaultEnd = computeEndTime(defaultStart, 120);
+ form.setFieldValue('time_slots', [
+ ...form.values.time_slots,
+ {time: defaultStart, end_time: defaultEnd, label: ''},
+ ]);
+ };
+
+ const handleRemoveTime = (index: number) => {
+ const updated = form.values.time_slots.filter((_, i) => i !== index);
+ form.setFieldValue('time_slots', updated.length > 0 ? updated : [{time: '', end_time: '', label: ''}]);
+ };
+
+ const handleSlotChange = (index: number, field: keyof TimeSlotFormValue, value: string) => {
+ const updated = [...form.values.time_slots];
+ updated[index] = {...updated[index], [field]: value};
+ form.setFieldValue('time_slots', updated);
+ };
+
+ const previewDates = useMemo(
+ () => computePreviewDates(form.values),
+ [
+ form.values.frequency, form.values.interval, form.values.days_of_week,
+ form.values.range_start,
+ form.values.range_type, form.values.range_until, form.values.range_count,
+ form.values.monthly_pattern, form.values.days_of_month,
+ form.values.day_of_week, form.values.week_position,
+ form.values.yearly_month, form.values.yearly_day,
+ ]
+ );
+
+ const validTimes = form.values.time_slots.filter(s => s.time.trim() !== '');
+ const totalOccurrences = previewDates.length * Math.max(validTimes.length, 1);
+ const exceedsLimit = totalOccurrences > MAX_PREVIEW;
+
+ const handleSubmit = (values: RecurrenceFormValues) => {
+ const filteredSlots = values.time_slots.filter(s => s.time.trim() !== '');
+
+ const timesOfDay: RecurrenceTimeSlot[] = filteredSlots.length > 0
+ ? filteredSlots.map(s => {
+ const duration = computeDurationFromTimes(s.time, s.end_time);
+ return {
+ time: s.time,
+ ...(s.label ? {label: s.label} : {}),
+ ...(duration ? {duration_minutes: duration} : {}),
+ };
+ })
+ : [{time: '09:00'}];
+
+ const range: RecurrenceRule['range'] = values.range_type === 'until'
+ ? {type: 'until', until: values.range_until}
+ : {type: 'count', count: values.range_count};
+
+ // Send range.start so the backend doesn't fall back to "now()" — for
+ // events scheduled in advance this is what anchors the generated dates
+ // to the organizer's intended start instead of the moment of submit.
+ if (values.range_start) {
+ range.start = values.range_start;
+ }
+
+ // Hidden recurrence metadata — written by the backend whenever a
+ // generated date is cancelled (excluded_occurrences) or manually
+ // added (additional_dates), and never editable through this form.
+ // Carry them forward verbatim so a routine schedule edit doesn't
+ // silently resurrect cancelled dates or drop manually-added ones.
+ // Legacy excluded_dates is also preserved to keep older rules intact
+ // until the backend migrates them to excluded_occurrences.
+ const existingRule = event?.recurrence_rule;
+ const preservedMetadata: Partial = {};
+ if (existingRule?.excluded_occurrences && existingRule.excluded_occurrences.length > 0) {
+ preservedMetadata.excluded_occurrences = existingRule.excluded_occurrences;
+ }
+ if (existingRule?.excluded_dates && existingRule.excluded_dates.length > 0) {
+ preservedMetadata.excluded_dates = existingRule.excluded_dates;
+ }
+ if (existingRule?.additional_dates && existingRule.additional_dates.length > 0) {
+ preservedMetadata.additional_dates = existingRule.additional_dates;
+ }
+
+ const rule: RecurrenceRule = {
+ frequency: values.frequency as RecurrenceRule['frequency'],
+ interval: values.interval,
+ times_of_day: timesOfDay,
+ range,
+ // `??` not `||` — `0` means "closed/no inventory" (legitimate for
+ // placeholder dates, comp-only sessions). Coercing 0 to null would
+ // silently flip a closed date to unlimited capacity.
+ default_capacity: values.default_capacity ?? null,
+ ...preservedMetadata,
+ };
+
+ if (values.frequency === 'weekly') {
+ rule.days_of_week = values.days_of_week;
+ }
+
+ if (values.frequency === 'monthly') {
+ rule.monthly_pattern = values.monthly_pattern as RecurrenceRule['monthly_pattern'];
+ if (values.monthly_pattern === 'by_day_of_month') {
+ rule.days_of_month = values.days_of_month.map(d => parseInt(d)).filter(n => !isNaN(n));
+ } else {
+ rule.day_of_week = values.day_of_week;
+ rule.week_position = parseInt(values.week_position);
+ }
+ }
+
+ if (values.frequency === 'yearly') {
+ rule.month = parseInt(values.yearly_month);
+ rule.days_of_month = [values.yearly_day];
+ }
+
+ generateMutation.mutate({eventId, data: {recurrence_rule: rule}}, {
+ onSuccess: () => {
+ showSuccess(t`Schedule created successfully`);
+ onClose();
+ },
+ onError: (error: any) => {
+ const errors = error?.response?.data?.errors;
+ if (error?.response?.status === 422 && errors) {
+ const firstError = Object.values(errors).flat()[0] as string | undefined;
+ showError(firstError || t`Please check the provided information is correct`);
+ errorHandler(form, error);
+ } else {
+ showError(error?.response?.data?.message || t`Failed to create schedule`);
+ }
+ },
+ });
+ };
+
+ const daysOfMonthOptions = Array.from({length: 31}, (_, i) => String(i + 1));
+
+ const previewSummary = useMemo(() => {
+ if (previewDates.length === 0) return null;
+
+ const maxShow = 8;
+ const shown = previewDates.slice(0, maxShow).map(formatPreviewDate);
+ const remaining = previewDates.length - maxShow;
+
+ if (validTimes.length > 1) {
+ return {
+ label: t`${totalOccurrences} sessions across ${previewDates.length} dates (${plural(validTimes.length, {one: "# session", other: "# sessions"})} per day)`,
+ dates: shown,
+ remaining: remaining > 0 ? remaining : 0,
+ };
+ }
+
+ return {
+ label: t`${totalOccurrences} dates`,
+ dates: shown,
+ remaining: remaining > 0 ? remaining : 0,
+ };
+ }, [previewDates, validTimes.length, totalOccurrences]);
+
+ return (
+
+ {hasExistingRule ? (
+
+ {t`Any dates you've manually customized will be kept.`}
+
+ ) : (
+ }
+ className={classes.introCallout}
+ >
+ {t`Tell us how often your event repeats and we'll create all the dates for you.`}
+
+ )}
+
+
+
+
+
+
+
+
+ {frequencyUnitLabel(form.values.frequency, form.values.interval)}
+
+ }
+ rightSectionWidth={60}
+ {...form.getInputProps('interval')}
+ />
+
+
+ {form.values.frequency === 'weekly' && (
+
+
+ {DAYS_OF_WEEK.map(day => (
+
+ ))}
+
+
+ )}
+
+ {form.values.frequency === 'monthly' && (
+
+
+
+
+
+
+
+
+ {form.values.monthly_pattern === 'by_day_of_month' && (
+
+ {t`Days of Month`}
+
+
+ {daysOfMonthOptions.map(day => (
+
+ {day}
+
+ ))}
+
+
+
+ )}
+
+ {form.values.monthly_pattern === 'by_day_of_week' && (
+
+
+
+
+ )}
+
+ )}
+
+ {form.values.frequency === 'yearly' && (
+
+
+
+
+ )}
+
+
+
+
+
+
+ {form.values.time_slots.map((slot, index) => (
+
+
+
handleSlotChange(index, 'time', e.currentTarget.value)}
+ placeholder="09:00"
+ />
+ {t`to`}
+ handleSlotChange(index, 'end_time', e.currentTarget.value)}
+ placeholder="11:00"
+ />
+ handleSlotChange(index, 'label', e.currentTarget.value)}
+ placeholder={t`e.g. Morning Session`}
+ leftSection={}
+ />
+ {form.values.time_slots.length > 1 && (
+ handleRemoveTime(index)}
+ size="lg"
+ >
+
+
+ )}
+
+
+ ))}
+
+
}
+ onClick={handleAddTime}
+ mt="xs"
+ >
+ {t`Add another time`}
+
+
+
+ {t`Add multiple times if you run several sessions per day.`}
+
+
+
+
+
+
+
{t`How long does the schedule run?`}
+
+
+
+
+
+
form.setFieldValue('range_type', 'until')}
+ >
+
+
+
+
+
{t`End on a date`}
+
{t`Run until a specific date`}
+
+
+
form.setFieldValue('range_type', 'count')}
+ >
+
+
+
+
+
{t`Set number of dates`}
+
{t`Create a fixed number`}
+
+
+
+
+ {form.values.range_type === 'until' && (
+
+ )}
+
+ {form.values.range_type === 'count' && (
+
+ )}
+
+
+
+
+
+
+
+
+ {t`You can override this for individual dates later.`}
+
+
+
+ {previewSummary && previewSummary.dates.length > 0 && (
+
+
+ {exceedsLimit
+ ?
+ :
+ }
+
+ {previewSummary.label}
+
+
+ {exceedsLimit && (
+
+ {t`The maximum is ${MAX_PREVIEW} sessions. Please reduce the date range, frequency, or number of sessions per day.`}
+
+ )}
+
+ {previewSummary.dates.map((date, i) => (
+ {date}
+ ))}
+ {previewSummary.remaining > 0 && (
+
+ {t`and ${previewSummary.remaining} more...`}
+
+ )}
+
+
+ )}
+
+
+ {hasExistingRule ? t`Save Schedule` : t`Create Schedule`}
+
+
+
+ );
+};
diff --git a/frontend/src/components/routes/event/OccurrencesTab/cancelOccurrenceDialog.tsx b/frontend/src/components/routes/event/OccurrencesTab/cancelOccurrenceDialog.tsx
new file mode 100644
index 0000000000..c246ab5e7d
--- /dev/null
+++ b/frontend/src/components/routes/event/OccurrencesTab/cancelOccurrenceDialog.tsx
@@ -0,0 +1,62 @@
+import {Checkbox, Text} from "@mantine/core";
+import {modals} from "@mantine/modals";
+import {t} from "@lingui/macro";
+import {IdParam} from "../../../../types.ts";
+import {showError, showSuccess} from "../../../../utilites/notifications.tsx";
+
+type CancelOccurrenceMutation = {
+ mutate: (
+ variables: { eventId: IdParam; occurrenceId: IdParam; refundOrders: boolean },
+ options?: { onSuccess?: () => void; onError?: (error: any) => void },
+ ) => void;
+};
+
+type OpenCancelOccurrenceDialogArgs = {
+ eventId: IdParam;
+ occurrenceId: IdParam;
+ orderCount?: number;
+ mutation: CancelOccurrenceMutation;
+ onSuccess?: () => void;
+};
+
+export const openCancelOccurrenceDialog = ({
+ eventId,
+ occurrenceId,
+ orderCount = 0,
+ mutation,
+ onSuccess,
+}: OpenCancelOccurrenceDialogArgs) => {
+ let refund = false;
+
+ modals.openConfirmModal({
+ title: t`Cancel Date`,
+ children: (
+ <>
+
+ {t`Are you sure you want to cancel this date? Affected attendees will be notified by email.`}
+
+ {orderCount > 0 && (
+
+ {t`This date has ${orderCount} order(s) that will be affected.`}
+
+ )}
+ { refund = e.currentTarget.checked; }}
+ />
+ >
+ ),
+ labels: {confirm: t`Cancel Date`, cancel: t`Go Back`},
+ confirmProps: {color: 'red'},
+ onConfirm: () => {
+ mutation.mutate({eventId, occurrenceId, refundOrders: refund}, {
+ onSuccess: () => {
+ showSuccess(t`Date cancelled`);
+ onSuccess?.();
+ },
+ onError: (error: any) => showError(error?.response?.data?.message || t`Failed to cancel date`),
+ });
+ },
+ });
+};
diff --git a/frontend/src/components/routes/event/OccurrencesTab/checkInLaunch.tsx b/frontend/src/components/routes/event/OccurrencesTab/checkInLaunch.tsx
new file mode 100644
index 0000000000..ded5fb3469
--- /dev/null
+++ b/frontend/src/components/routes/event/OccurrencesTab/checkInLaunch.tsx
@@ -0,0 +1,62 @@
+import {modals} from "@mantine/modals";
+import {Text} from "@mantine/core";
+import {t} from "@lingui/macro";
+import {CheckInList} from "../../../../types.ts";
+
+type LaunchArgs = {
+ occurrenceId: number;
+ checkInLists: CheckInList[] | undefined;
+ onCreateForOccurrence: (occurrenceId: number) => void;
+};
+
+const openInNewTab = (shortId: string | number) => {
+ if (typeof window === 'undefined') return;
+ window.open(`/check-in/${shortId}`, '_blank');
+};
+
+/**
+ * Launching check-in from a specific occurrence has three branches:
+ *
+ * 1. There's a list scoped to the occurrence — open it.
+ * 2. There's only a global (no-occurrence) list — prompt: "use the all-occurrences
+ * list?" or "create a date-specific list?". The previous behaviour silently
+ * fell through to the global list, which lets staff scan attendees from
+ * other dates without realising it.
+ * 3. No relevant list exists — open the create flow scoped to this occurrence.
+ */
+export const launchCheckInForOccurrence = ({
+ occurrenceId,
+ checkInLists,
+ onCreateForOccurrence,
+}: LaunchArgs): void => {
+ const scoped = checkInLists?.find(list => list.event_occurrence_id === occurrenceId);
+ if (scoped) {
+ openInNewTab(scoped.short_id);
+ return;
+ }
+
+ const global = checkInLists?.find(list => !list.event_occurrence_id);
+ if (global) {
+ modals.openConfirmModal({
+ title: t`No date-specific check-in list`,
+ children: (
+ <>
+
+ {t`There's no check-in list scoped to this date. The "${global.name}" list checks in attendees across every date — staff scanning a ticket for a different date will still succeed.`}
+
+
+ {t`Use the all-dates list, or create a list for this date?`}
+
+ >
+ ),
+ labels: {confirm: t`Use all-dates list`, cancel: t`Create for this date`},
+ // The "cancel" button is the safer of the two — that's where Mantine
+ // anchors keyboard focus by default, and it leads to creation.
+ onCancel: () => onCreateForOccurrence(occurrenceId),
+ onConfirm: () => openInNewTab(global.short_id),
+ });
+ return;
+ }
+
+ onCreateForOccurrence(occurrenceId);
+};
diff --git a/frontend/src/components/routes/event/OccurrencesTab/index.tsx b/frontend/src/components/routes/event/OccurrencesTab/index.tsx
new file mode 100644
index 0000000000..68e2bd7b49
--- /dev/null
+++ b/frontend/src/components/routes/event/OccurrencesTab/index.tsx
@@ -0,0 +1,705 @@
+import {useLocation, useNavigate, useParams} from "react-router";
+import {t} from "@lingui/macro";
+import {Anchor, Button, Checkbox, Group, Menu, Paper, Progress, SegmentedControl, Skeleton, Stack, Text, Tooltip} from "@mantine/core";
+import {
+ IconCalendar,
+ IconCalendarEvent,
+ IconCalendarPlus,
+ IconDotsVertical,
+ IconList,
+ IconPencil,
+ IconPlus,
+} from "@tabler/icons-react";
+import {useDisclosure} from "@mantine/hooks";
+import {useCallback, useMemo, useRef, useState} from "react";
+import dayjs from "dayjs";
+import utc from "dayjs/plugin/utc";
+import timezone from "dayjs/plugin/timezone";
+import {modals} from "@mantine/modals";
+import {PageBody} from "../../../common/PageBody";
+import {PageTitle} from "../../../common/PageTitle";
+import {Pagination} from "../../../common/Pagination";
+import {TableSkeleton} from "../../../common/TableSkeleton";
+import {useGetEventOccurrences} from "../../../../queries/useGetEventOccurrences.ts";
+import {useFilterQueryParamSync} from "../../../../hooks/useFilterQueryParamSync.ts";
+import {EventOccurrence, EventOccurrenceStatus, MessageType, QueryFilterFields, QueryFilterOperator, QueryFilters} from "../../../../types.ts";
+import {useGetEvent} from "../../../../queries/useGetEvent.ts";
+import {formatDateWithLocale} from "../../../../utilites/dates.ts";
+import {formatCurrency} from "../../../../utilites/currency.ts";
+import {OccurrenceEditModal} from "./OccurrenceEditModal";
+import {OccurrenceBulkEditModal} from "./OccurrenceBulkEditModal";
+import {RecurrenceScheduleModal} from "./RecurrenceScheduleModal";
+import {CalendarView} from "./CalendarView";
+import {useCancelOccurrence} from "../../../../mutations/useCancelOccurrence.ts";
+import {useDeleteEventOccurrence} from "../../../../mutations/useDeleteEventOccurrence.ts";
+import {useBulkUpdateOccurrences} from "../../../../mutations/useBulkUpdateOccurrences.ts";
+import {useReactivateOccurrence} from "../../../../mutations/useReactivateOccurrence.ts";
+import {confirmationDialog} from "../../../../utilites/confirmationDialog.tsx";
+import {showError, showSuccess} from "../../../../utilites/notifications.tsx";
+import {GroupedOccurrenceTable, GroupedTableColumn} from "./GroupedOccurrenceTable";
+import {OccurrenceMenuItems, OccurrenceMenuActions, statusLabel, StatusIcon} from "./OccurrenceMenu";
+import {openCancelOccurrenceDialog} from "./cancelOccurrenceDialog";
+import {launchCheckInForOccurrence} from "./checkInLaunch";
+import {ManageOccurrenceModal} from "../../../modals/ManageOccurrenceModal";
+import {SendMessageModal} from "../../../modals/SendMessageModal";
+import {ShareModal} from "../../../modals/ShareModal";
+import {CreateCheckInListModal} from "../../../modals/CreateCheckInListModal";
+import {useGetEventCheckInLists} from "../../../../queries/useGetCheckInLists.ts";
+import {eventHomepageUrl} from "../../../../utilites/urlHelper.ts";
+import classes from './OccurrencesTab.module.scss';
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+
+const OccurrencesTab = () => {
+ const {eventId} = useParams();
+ const navigate = useNavigate();
+ const location = useLocation();
+ const [searchParams, setSearchParams] = useFilterQueryParamSync();
+ const viewMode: 'list' | 'calendar' = location.pathname.endsWith('/calendar') ? 'calendar' : 'list';
+
+ const handleViewModeChange = (val: string) => {
+ const mode = val as 'list' | 'calendar';
+ const target = mode === 'calendar'
+ ? `/manage/event/${eventId}/occurrences/calendar`
+ : `/manage/event/${eventId}/occurrences`;
+ navigate(target);
+ };
+ const [selectedIds, setSelectedIds] = useState>(new Set());
+ const [calendarMonth, setCalendarMonth] = useState(() => dayjs().startOf('month'));
+
+ const timePeriod = useMemo(() => {
+ const tp = searchParams.filterFields?.time_period;
+ if (!tp) return 'upcoming';
+ const val = Array.isArray(tp) ? tp[0]?.value : tp.value;
+ return (val as string) || 'upcoming';
+ }, [searchParams.filterFields?.time_period]);
+
+ const {data: event} = useGetEvent(eventId);
+
+ const calendarRange = useMemo(() => {
+ if (!event?.timezone) return null;
+ const tz = event.timezone;
+ const monthStart = dayjs.tz(calendarMonth.format('YYYY-MM-01'), tz).startOf('month');
+ const startDay = monthStart.day();
+ const offset = startDay === 0 ? 6 : startDay - 1;
+ const gridStart = monthStart.subtract(offset, 'day').startOf('day');
+ const gridEnd = gridStart.add(42, 'day').endOf('day');
+ return {
+ start: gridStart.utc().format('YYYY-MM-DD HH:mm:ss'),
+ end: gridEnd.utc().format('YYYY-MM-DD HH:mm:ss'),
+ };
+ }, [calendarMonth, event?.timezone]);
+
+ const queryParams: QueryFilters = useMemo(() => {
+ if (viewMode === 'calendar') {
+ if (!calendarRange) return {pageNumber: 1, perPage: 500, filterFields: {}};
+ return {
+ pageNumber: 1,
+ perPage: 500,
+ filterFields: {
+ start_date: [
+ {operator: QueryFilterOperator.GreaterThanOrEquals, value: calendarRange.start},
+ {operator: QueryFilterOperator.LessThanOrEquals, value: calendarRange.end},
+ ],
+ },
+ };
+ }
+
+ const filterFields: QueryFilterFields = {...searchParams.filterFields};
+ if (timePeriod === 'all') {
+ delete filterFields.time_period;
+ } else if (!filterFields.time_period) {
+ filterFields.time_period = {operator: QueryFilterOperator.Equals, value: 'upcoming'};
+ }
+ return {...searchParams, perPage: searchParams.perPage || 50, filterFields};
+ }, [searchParams, timePeriod, viewMode, calendarRange]);
+ const queryEnabled = viewMode !== 'calendar' || calendarRange !== null;
+ const occurrencesQuery = useGetEventOccurrences(eventId, queryParams, queryEnabled);
+ const occurrences = occurrencesQuery?.data?.data;
+ const pagination = occurrencesQuery?.data?.meta;
+
+ const [editModalOpen, {open: openEditModal, close: closeEditModal}] = useDisclosure(false);
+ const [bulkEditOpen, {open: openBulkEdit, close: closeBulkEdit}] = useDisclosure(false);
+ const [generateOpen, {open: openGenerate, close: closeGenerate}] = useDisclosure(false);
+ const [selectedOccurrenceId, setSelectedOccurrenceId] = useState();
+ const [slideoutOccurrenceId, setSlideoutOccurrenceId] = useState();
+ const [duplicateFrom, setDuplicateFrom] = useState();
+ const [defaultDate, setDefaultDate] = useState();
+ const [messageOccurrenceId, setMessageOccurrenceId] = useState();
+ const [shareOccurrence, setShareOccurrence] = useState();
+ const [createCheckInForOccurrenceId, setCreateCheckInForOccurrenceId] = useState();
+
+ const checkInListsQuery = useGetEventCheckInLists(eventId);
+ const checkInLists = checkInListsQuery?.data?.data;
+
+ const cancelMutation = useCancelOccurrence();
+ const deleteMutation = useDeleteEventOccurrence();
+ const bulkUpdateMutation = useBulkUpdateOccurrences();
+ const reactivateMutation = useReactivateOccurrence();
+ const refundRef = useRef(false);
+
+ const handleEditClick = (occurrenceId: number) => {
+ setSelectedOccurrenceId(occurrenceId);
+ setDuplicateFrom(undefined);
+ openEditModal();
+ };
+
+ const handleEditClose = () => {
+ setSelectedOccurrenceId(undefined);
+ setDuplicateFrom(undefined);
+ setDefaultDate(undefined);
+ closeEditModal();
+ };
+
+ const handleCreateClick = () => {
+ setSelectedOccurrenceId(undefined);
+ setDuplicateFrom(undefined);
+ setDefaultDate(undefined);
+ openEditModal();
+ };
+
+ const handleCreateWithDate = (date: string) => {
+ setSelectedOccurrenceId(undefined);
+ setDuplicateFrom(undefined);
+ setDefaultDate(date);
+ openEditModal();
+ };
+
+ const handleDuplicate = (occ: EventOccurrence) => {
+ setSelectedOccurrenceId(undefined);
+ setDuplicateFrom(occ);
+ openEditModal();
+ };
+
+ const handleCancel = (occurrenceId: number) => {
+ const occ = occurrences?.find(o => o.id === occurrenceId);
+ openCancelOccurrenceDialog({
+ eventId,
+ occurrenceId,
+ orderCount: occ?.statistics?.orders_created ?? 0,
+ mutation: cancelMutation,
+ });
+ };
+
+ const handleDelete = (occurrenceId: number) => {
+ confirmationDialog(t`Are you sure you want to delete this date? This action cannot be undone.`, () => {
+ deleteMutation.mutate({eventId, occurrenceId}, {
+ onSuccess: () => showSuccess(t`Date deleted`),
+ onError: (error: any) => showError(error?.response?.data?.message || t`Failed to delete date`),
+ });
+ });
+ };
+
+ const handleReactivate = (occ: EventOccurrence) => {
+ confirmationDialog(
+ t`Reopen this date for new sales? Previously cancelled tickets will not be restored — affected attendees stay cancelled and any refunds already issued are not reversed.`,
+ () => {
+ reactivateMutation.mutate({
+ eventId,
+ occurrenceId: occ.id,
+ }, {
+ onSuccess: () => showSuccess(t`Date reopened for new sales`),
+ onError: (error: any) => showError(error?.response?.data?.message || t`Failed to reopen date`),
+ });
+ },
+ );
+ };
+
+ const handleBulkCancel = () => {
+ const count = selectedIds.size;
+ refundRef.current = false;
+ modals.openConfirmModal({
+ title: t`Cancel ${count} date(s)`,
+ children: (
+ <>
+
+ {t`Are you sure you want to cancel ${count} date(s)? Affected attendees will be notified by email.`}
+
+ { refundRef.current = e.currentTarget.checked; }}
+ />
+ >
+ ),
+ labels: {confirm: t`Cancel ${count} date(s)`, cancel: t`Go Back`},
+ confirmProps: {color: 'red'},
+ onConfirm: () => {
+ bulkUpdateMutation.mutate({
+ eventId,
+ data: {
+ action: 'cancel',
+ occurrence_ids: [...selectedIds],
+ future_only: false,
+ skip_overridden: false,
+ refund_orders: refundRef.current,
+ },
+ }, {
+ onSuccess: (response) => {
+ showSuccess(t`Cancelled ${response.updated_count} date(s)`);
+ setSelectedIds(new Set());
+ },
+ onError: (error: any) => showError(error?.response?.data?.message || t`Failed to cancel dates`),
+ });
+ },
+ });
+ };
+
+ const handleBulkDelete = () => {
+ const count = selectedIds.size;
+ confirmationDialog(t`Delete ${count} selected date(s)? Dates with orders will be skipped. This cannot be undone.`, () => {
+ bulkUpdateMutation.mutate({
+ eventId,
+ data: {action: 'delete', occurrence_ids: [...selectedIds], future_only: false, skip_overridden: false},
+ }, {
+ onSuccess: (response) => {
+ showSuccess(t`Deleted ${response.updated_count} date(s)`);
+ setSelectedIds(new Set());
+ },
+ onError: (error: any) => showError(error?.response?.data?.message || t`Failed to delete dates`),
+ });
+ });
+ };
+
+ const handleCheckIn = useCallback((occurrenceId: number) => {
+ if (checkInListsQuery.isLoading || checkInListsQuery.isFetching) {
+ showError(t`Please try again.`);
+ return;
+ }
+
+ launchCheckInForOccurrence({
+ occurrenceId,
+ checkInLists,
+ onCreateForOccurrence: setCreateCheckInForOccurrenceId,
+ });
+ }, [checkInLists, checkInListsQuery.isFetching, checkInListsQuery.isLoading]);
+
+ const handlePageChange = (value: number) => {
+ setSelectedIds(new Set());
+ setSearchParams({pageNumber: value});
+ };
+
+ const handleTimePeriodChange = (val: string) => {
+ const newFilterFields: QueryFilterFields = {...(searchParams.filterFields || {})};
+ newFilterFields.time_period = {operator: QueryFilterOperator.Equals, value: val};
+ setSelectedIds(new Set());
+ setSearchParams({...searchParams, filterFields: newFilterFields, pageNumber: 1} as QueryFilters, true);
+ };
+
+ const menuActions: OccurrenceMenuActions = {
+ eventId: eventId!,
+ onEdit: handleEditClick,
+ onCancel: handleCancel,
+ onDelete: handleDelete,
+ onNavigate: navigate,
+ onDuplicate: handleDuplicate,
+ onMessage: (id: number) => setMessageOccurrenceId(id),
+ onCheckIn: handleCheckIn,
+ onReactivate: handleReactivate,
+ onShare: (occ: EventOccurrence) => setShareOccurrence(occ),
+ };
+
+ const columns = useMemo(
+ () => [
+ {
+ id: 'time',
+ header: t`Time`,
+ render: (occ: EventOccurrence) => {
+ if (!event) return null;
+ const startTime = formatDateWithLocale(occ.start_date, 'timeOnly', event.timezone);
+ const endTime = occ.end_date
+ ? formatDateWithLocale(occ.end_date, 'timeOnly', event.timezone)
+ : null;
+ return (
+ setSlideoutOccurrenceId(occ.id as number)}
+ >
+
+ {startTime}{endTime && <> — {endTime}>}
+ {occ.is_overridden && (
+
+
+
+ )}
+
+ {occ.label && (
+ {occ.label}
+ )}
+
+ );
+ },
+ headerStyle: {minWidth: 160},
+ },
+ {
+ id: 'status',
+ header: t`Status`,
+ render: (occ: EventOccurrence) => {
+ const isClickable = occ.status === EventOccurrenceStatus.ACTIVE
+ || occ.status === EventOccurrenceStatus.CANCELLED;
+ const tooltip = occ.status === EventOccurrenceStatus.ACTIVE
+ ? t`Click to cancel`
+ : occ.status === EventOccurrenceStatus.CANCELLED
+ ? t`Click to reopen for new sales`
+ : undefined;
+
+ const handleStatusClick = () => {
+ if (occ.status === EventOccurrenceStatus.ACTIVE) {
+ handleCancel(occ.id as number);
+ } else if (occ.status === EventOccurrenceStatus.CANCELLED) {
+ handleReactivate(occ);
+ }
+ };
+
+ const badge = (
+
+
+ {statusLabel(occ.status)}
+
+ );
+
+ if (tooltip) {
+ return (
+
+ {badge}
+
+ );
+ }
+ return badge;
+ },
+ headerStyle: {minWidth: 120},
+ },
+ {
+ id: 'ticketsSold',
+ header: t`Sold`,
+ render: (occ: EventOccurrence) => {
+ const used = occ.used_capacity ?? 0;
+ const total = occ.capacity;
+ const pct = total ? Math.min(100, Math.round((used / total) * 100)) : 0;
+
+ return (
+
+
+ {used}
+ {total != null && (
+ / {total}
+ )}
+
+ {total != null && (
+
= 90 ? 'red' : pct >= 70 ? 'orange' : 'blue'}
+ className={classes.ticketsProgress}
+ style={used === 0 ? {opacity: 0.3} : undefined}
+ />
+ )}
+
+ );
+ },
+ headerStyle: {minWidth: 120},
+ },
+ {
+ id: 'activity',
+ header: t`Activity`,
+ render: (occ: EventOccurrence) => {
+ const orders = occ.statistics?.orders_created ?? 0;
+ const gross = occ.statistics?.total_gross_sales ?? 0;
+ const refunded = occ.statistics?.total_refunded ?? 0;
+
+ if (orders === 0 && gross === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {orders} {orders === 1 ? t`order` : t`orders`}
+ {gross > 0 && (
+ <> · {formatCurrency(gross, event?.currency)}>
+ )}
+
+ {refunded > 0 && (
+
+ (-{formatCurrency(refunded, event?.currency)})
+
+ )}
+
+ );
+ },
+ headerStyle: {minWidth: 140},
+ },
+ {
+ id: 'actions',
+ header: '',
+ sticky: 'right',
+ render: (occ: EventOccurrence) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ headerStyle: {width: 70},
+ },
+ ],
+ [event]
+ );
+
+ const rowStyleFn = useCallback((occ: EventOccurrence) => {
+ return (timePeriod === 'all' && occ.is_past) ? {opacity: 0.5} : undefined;
+ }, [timePeriod]);
+
+ return (
+
+
+ {t`Occurrence Schedule`}
+
+
+
+
{t`List`}, value: 'list'},
+ {label: {t`Calendar`} , value: 'calendar'},
+ ]}
+ />
+
+ {viewMode === 'list' && (
+ <>
+
+
+ >
+ )}
+
+
+
+ {selectedIds.size > 0 && (
+
+
{selectedIds.size} {t`selected`}
+
+ {t`Cancel`}
+
+
+ {t`Delete`}
+
+
setSelectedIds(new Set())}>
+ {t`Clear`}
+
+
+
+ )}
+
+
}
+ onClick={openBulkEdit}
+ >
+ {t`Bulk Edit`}
+
+
+
+
+ }
+ >
+ {t`Add Dates`}
+
+
+
+ }
+ onClick={openGenerate}
+ >
+ {t`Set Up Schedule`}
+
+ }
+ onClick={handleCreateClick}
+ >
+ {t`Add a Single Date`}
+
+
+
+
+
+ {occurrences && occurrencesQuery.isFetching && !occurrencesQuery.isLoading && !occurrencesQuery.isPlaceholderData && (
+
+ )}
+
+
+
+ {viewMode === 'list' && (occurrencesQuery.isLoading || occurrencesQuery.isPlaceholderData) && (
+
+ )}
+
+ {viewMode === 'calendar' && (occurrencesQuery.isLoading || occurrencesQuery.isPlaceholderData || !event) && (
+
+ )}
+
+ {viewMode === 'list' && occurrences && occurrences.length === 0 && !occurrencesQuery.isFetching && !occurrencesQuery.isPlaceholderData && (() => {
+ const hasActiveFilters = !!(timePeriod === 'past' || timePeriod === 'all');
+ return (
+
+
+
+
+
+ {hasActiveFilters ? t`No dates match your filters` : t`No dates scheduled yet`}
+
+
+ {hasActiveFilters
+ ? t`Try adjusting your filters to see more dates.`
+ : t`Set up a recurring schedule to automatically create dates, or add them one at a time.`
+ }
+
+
+ {!hasActiveFilters && (
+
+ }
+ onClick={openGenerate}
+ >
+ {t`Set Up Schedule`}
+
+
+ {t`or add a single date`}
+
+
+ )}
+
+
+ );
+ })()}
+
+ {occurrences && occurrences.length > 0 && viewMode === 'list' && event && !occurrencesQuery.isPlaceholderData && (
+ <>
+
+ {pagination && (
+
+ {t`Showing ${pagination.from}–${pagination.to} of ${pagination.total}`}
+
+ )}
+ >
+ )}
+
+ {viewMode === 'calendar' && event && !occurrencesQuery.isLoading && !occurrencesQuery.isPlaceholderData && (
+ setSlideoutOccurrenceId(id)}
+ onCreate={handleCreateWithDate}
+ />
+ )}
+
+
+
+ {!!occurrences?.length && viewMode === 'list' && !occurrencesQuery.isPlaceholderData && (
+
+ )}
+
+ {editModalOpen && (
+
+ )}
+
+ {bulkEditOpen && (
+
+ )}
+
+ {generateOpen && (
+
+ )}
+
+ {slideoutOccurrenceId && (
+ setSlideoutOccurrenceId(undefined)}
+ />
+ )}
+
+ {messageOccurrenceId && (
+ setMessageOccurrenceId(undefined)}
+ messageType={MessageType.AllAttendees}
+ eventOccurrenceId={messageOccurrenceId}
+ />
+ )}
+
+ {createCheckInForOccurrenceId && (
+ setCreateCheckInForOccurrenceId(undefined)}
+ initialOccurrenceId={createCheckInForOccurrenceId}
+ />
+ )}
+
+ {shareOccurrence && event && (
+ setShareOccurrence(undefined)}
+ url={`${eventHomepageUrl(event)}?occurrence_id=${shareOccurrence.id}`}
+ title={event.title}
+ shareText={`${event.title} — ${formatDateWithLocale(shareOccurrence.start_date, 'shortDateTime', event.timezone)}`}
+ />
+ )}
+
+ );
+};
+
+export default OccurrencesTab;
diff --git a/frontend/src/components/routes/event/OccurrencesTab/rescheduleMessageTemplate.ts b/frontend/src/components/routes/event/OccurrencesTab/rescheduleMessageTemplate.ts
new file mode 100644
index 0000000000..ab6d53d3d2
--- /dev/null
+++ b/frontend/src/components/routes/event/OccurrencesTab/rescheduleMessageTemplate.ts
@@ -0,0 +1,98 @@
+import {t} from "@lingui/macro";
+import dayjs from "dayjs";
+import utc from "dayjs/plugin/utc";
+import timezone from "dayjs/plugin/timezone";
+import {Event, EventOccurrence} from "../../../../types.ts";
+import {formatDateWithLocale} from "../../../../utilites/dates.ts";
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
+
+/**
+ * Shared templates for the "notify attendees" follow-up when an organizer
+ * reschedules an occurrence. Two concerns this module handles carefully:
+ *
+ * 1. **Output format is HTML, not plain text.** The SendMessageModal uses a
+ * TipTap rich-text editor — plain-text newlines collapse into a single
+ * paragraph. Emails render the body via `{!! $message !!}` so HTML flows
+ * through end-to-end.
+ *
+ * 2. **Date format input differs between "old" and "new" values.** The old
+ * occurrence's start_date is a UTC string from the API; the new start/end
+ * come from the form as event-tz-local strings (YYYY-MM-DDTHH:mm, no tz
+ * marker). We format each appropriately so neither ends up double-offset.
+ */
+
+const escape = (value: string): string =>
+ value
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+
+/**
+ * Subject column is capped at 100 chars on the backend. Our template adds ~40
+ * chars of fixed overhead, so clip the event title for the subject line only.
+ */
+const clipForSubject = (title: string, maxTitleLength = 55): string =>
+ title.length <= maxTitleLength
+ ? title
+ : title.slice(0, maxTitleLength - 1).trimEnd() + '…';
+
+/** Format a naive local-tz datetime string (YYYY-MM-DDTHH:mm) in the event tz. */
+const formatLocal = (localString: string, tz: string): string =>
+ dayjs.tz(localString, tz).format('MMM D, YYYY · h:mm A');
+
+const formatLocalTime = (localString: string, tz: string): string =>
+ dayjs.tz(localString, tz).format('h:mm A');
+
+export const buildSingleRescheduleTemplate = (
+ event: Event,
+ oldOccurrence: EventOccurrence,
+ newStartDate: string,
+ newEndDate: string | null | undefined,
+): { subject: string; message: string } => {
+ const tz = event.timezone;
+ const oldStart = formatDateWithLocale(oldOccurrence.start_date, 'shortDateTime', tz);
+ const newStart = formatLocal(newStartDate, tz);
+ const newEnd = newEndDate ? formatLocalTime(newEndDate, tz) : null;
+ const newWhen = newEnd ? `${newStart} – ${newEnd}` : newStart;
+
+ const title = escape(event.title);
+ const organizer = escape(event.organizer?.name ?? '');
+
+ const subjectTitle = clipForSubject(event.title);
+ const subject = t`Update: ${subjectTitle} — session time changed`;
+
+ // Built as HTML so TipTap renders paragraphs and the email preserves them.
+ const message = [
+ `${t`Hi,`}
`,
+ `${t`The session for "${title}" originally scheduled for ${escape(oldStart)} has been rescheduled.`}
`,
+ `${t`New time:`} ${escape(newWhen)}
`,
+ `${t`Your ticket is still valid — no action is needed unless the new time doesn't work for you. Please reply to this email if you have any questions.`}
`,
+ `${t`Thanks,`} ${organizer}
`,
+ ].join('');
+
+ return {subject, message};
+};
+
+export const buildBulkRescheduleTemplate = (
+ event: Event,
+ affectedCount: number,
+ actionDescription: string,
+): { subject: string; message: string } => {
+ const title = escape(event.title);
+ const organizer = escape(event.organizer?.name ?? '');
+ const description = escape(actionDescription);
+
+ const subjectTitle = clipForSubject(event.title);
+ const subject = t`Update: ${subjectTitle} — schedule changes`;
+
+ const message = [
+ `${t`Hi,`}
`,
+ `${t`We've made changes to the schedule for "${title}" — ${description} affecting ${affectedCount} session(s).`}
`,
+ `${t`Please check your ticket for the updated time. Your tickets are still valid — no action is needed unless the new times don't work for you. Reply to this email if you have any questions.`}
`,
+ `${t`Thanks,`} ${organizer}
`,
+ ].join('');
+
+ return {subject, message};
+};
diff --git a/frontend/src/components/routes/event/Reports/OccurrenceSummaryReport/index.tsx b/frontend/src/components/routes/event/Reports/OccurrenceSummaryReport/index.tsx
new file mode 100644
index 0000000000..4eb3f159b5
--- /dev/null
+++ b/frontend/src/components/routes/event/Reports/OccurrenceSummaryReport/index.tsx
@@ -0,0 +1,113 @@
+import {useParams} from "react-router";
+import {t} from "@lingui/macro";
+import {useGetEvent} from "../../../../../queries/useGetEvent.ts";
+import {formatCurrency} from "../../../../../utilites/currency.ts";
+import {formatDateWithLocale} from "../../../../../utilites/dates.ts";
+import {Badge} from "@mantine/core";
+import ReportTable from "../../../../common/ReportTable";
+
+const statusColor = (status: string) => {
+ switch (status) {
+ case 'ACTIVE': return 'green';
+ case 'CANCELLED': return 'red';
+ case 'SOLD_OUT': return 'orange';
+ default: return 'gray';
+ }
+};
+
+const statusLabel = (status: string) => {
+ switch (status) {
+ case 'ACTIVE': return t`Active`;
+ case 'CANCELLED': return t`Cancelled`;
+ case 'SOLD_OUT': return t`Sold Out`;
+ default: return status;
+ }
+};
+
+const OccurrenceSummaryReport = () => {
+ const {eventId} = useParams();
+ const eventQuery = useGetEvent(eventId);
+ const event = eventQuery.data;
+
+ if (!event) {
+ return null;
+ }
+
+ const columns = [
+ {
+ key: 'start_date' as const,
+ label: t`Date`,
+ sortable: true,
+ render: (value: string) => formatDateWithLocale(value, 'fullDateTime', event.timezone),
+ },
+ {
+ key: 'label' as const,
+ label: t`Label`,
+ sortable: true,
+ render: (value: string) => value || '—',
+ },
+ {
+ key: 'status' as const,
+ label: t`Status`,
+ sortable: true,
+ render: (value: string) => (
+
+ {statusLabel(value)}
+
+ ),
+ },
+ {
+ key: 'attendees_registered' as const,
+ label: t`Attendees`,
+ sortable: true,
+ },
+ {
+ key: 'used_capacity' as const,
+ label: t`Capacity Used`,
+ sortable: true,
+ render: (value: number, row: Record) =>
+ row.capacity ? `${value}/${row.capacity}` : String(value),
+ },
+ {
+ key: 'products_sold' as const,
+ label: t`Products Sold`,
+ sortable: true,
+ },
+ {
+ key: 'orders_created' as const,
+ label: t`Orders`,
+ sortable: true,
+ },
+ {
+ key: 'total_gross' as const,
+ label: t`Gross Sales`,
+ sortable: true,
+ render: (value: string) => formatCurrency(value, event.currency),
+ },
+ {
+ key: 'total_tax' as const,
+ label: t`Tax`,
+ sortable: true,
+ render: (value: string) => formatCurrency(value, event.currency),
+ },
+ {
+ key: 'checked_in' as const,
+ label: t`Checked In`,
+ sortable: true,
+ },
+ ];
+
+ return (
+
+ );
+};
+
+export default OccurrenceSummaryReport;
diff --git a/frontend/src/components/routes/event/Reports/ReportLayout/index.tsx b/frontend/src/components/routes/event/Reports/ReportLayout/index.tsx
index b8fa47ae8e..943f38da35 100644
--- a/frontend/src/components/routes/event/Reports/ReportLayout/index.tsx
+++ b/frontend/src/components/routes/event/Reports/ReportLayout/index.tsx
@@ -6,6 +6,7 @@ import ProductSalesReport from "../ProductSalesReport";
import {ReportTypes} from "../../../../../types.ts";
import {DailySalesReport} from "../DailySalesReport";
import PromoCodesReport from "../PromoCodesReport";
+import OccurrenceSummaryReport from "../OccurrenceSummaryReport";
const renderReport = (reportType: string) => {
switch (reportType) {
@@ -15,6 +16,8 @@ const renderReport = (reportType: string) => {
return ;
case ReportTypes.PromoCodes:
return ;
+ case ReportTypes.OccurrenceSummary:
+ return ;
default:
return Report not found
;
}
diff --git a/frontend/src/components/routes/event/Reports/index.tsx b/frontend/src/components/routes/event/Reports/index.tsx
index c55b16ef88..bcb8d83497 100644
--- a/frontend/src/components/routes/event/Reports/index.tsx
+++ b/frontend/src/components/routes/event/Reports/index.tsx
@@ -1,15 +1,17 @@
import {PageTitle} from "../../../common/PageTitle";
import {t} from "@lingui/macro";
import {PageBody} from "../../../common/PageBody";
-import {IconChartBar, IconChevronRight, IconReportMoney} from "@tabler/icons-react";
+import {IconCalendarEvent, IconChartBar, IconChevronRight, IconReportMoney} from "@tabler/icons-react";
import classes from './Reports.module.scss';
import {Card} from "../../../common/Card";
import {Avatar, UnstyledButton} from "@mantine/core";
import {Link, useParams} from "react-router";
-import {ReportTypes} from "../../../../types.ts";
+import {EventType, ReportTypes} from "../../../../types.ts";
+import {useGetEvent} from "../../../../queries/useGetEvent.ts";
const Reports = () => {
const {eventId} = useParams();
+ const {data: event} = useGetEvent(eventId);
const reports = [
{
@@ -29,7 +31,13 @@ const Reports = () => {
title: t`Promo Codes Report`,
description: t`Promo code usage and discount breakdown`,
icon:
- }
+ },
+ ...(event?.type === EventType.RECURRING ? [{
+ id: ReportTypes.OccurrenceSummary,
+ title: t`Occurrence Summary`,
+ description: t`Sales, attendance, and check-in breakdown per occurrence`,
+ icon:
+ }] : []),
];
return (
diff --git a/frontend/src/components/routes/event/Settings/Sections/EmailSettings/TemplateSettings.tsx b/frontend/src/components/routes/event/Settings/Sections/EmailSettings/TemplateSettings.tsx
index ada9939b0d..a369c93956 100644
--- a/frontend/src/components/routes/event/Settings/Sections/EmailSettings/TemplateSettings.tsx
+++ b/frontend/src/components/routes/event/Settings/Sections/EmailSettings/TemplateSettings.tsx
@@ -9,9 +9,11 @@ import {usePreviewEmailTemplateForEvent} from '../../../../../../mutations/usePr
import {useUpdateEmailTemplateForEvent} from "../../../../../../mutations/useUpdateEmailTemplate.ts";
import {useDeleteEmailTemplateForEvent} from "../../../../../../mutations/useDeleteEmailTemplate.ts";
import {EmailTemplateSettingsBase} from '../../../../../common/EmailTemplateSettings';
+import {useGetEvent} from '../../../../../../queries/useGetEvent';
export const TemplateSettings = () => {
const {eventId} = useParams();
+ const {data: event} = useGetEvent(eventId);
const [shouldFetchDefaults, setShouldFetchDefaults] = useState(false);
// Queries
@@ -45,6 +47,7 @@ export const TemplateSettings = () => {
deleteMutation={deleteMutation}
previewMutation={previewMutation}
onCreateTemplate={handleCreateTemplate}
+ eventType={event?.type}
/>
);
};
diff --git a/frontend/src/components/routes/event/Settings/Sections/EventDetailsForm/index.tsx b/frontend/src/components/routes/event/Settings/Sections/EventDetailsForm/index.tsx
index ac7446d9f5..35f8ba6e18 100644
--- a/frontend/src/components/routes/event/Settings/Sections/EventDetailsForm/index.tsx
+++ b/frontend/src/components/routes/event/Settings/Sections/EventDetailsForm/index.tsx
@@ -1,11 +1,11 @@
import {t} from "@lingui/macro";
-import {Button, Select, TextInput} from "@mantine/core";
+import {Anchor, Button, Select, TextInput} from "@mantine/core";
import {useForm} from "@mantine/form";
-import {useParams} from "react-router";
+import {NavLink, useParams} from "react-router";
import {useGetEvent} from "../../../../../../queries/useGetEvent.ts";
import {useEffect} from "react";
import {useUpdateEvent} from "../../../../../../mutations/useUpdateEvent.ts";
-import {Event} from "../../../../../../types.ts";
+import {Event, EventType} from "../../../../../../types.ts";
import {InputGroup} from "../../../../../common/InputGroup";
import {Card} from "../../../../../common/Card";
import {Editor} from "../../../../../common/Editor";
@@ -21,6 +21,7 @@ export const EventDetailsForm = () => {
const {eventId} = useParams();
const eventQuery = useGetEvent(eventId);
const updateMutation = useUpdateEvent();
+ const isRecurring = eventQuery.data?.type === EventType.RECURRING;
const form = useForm({
initialValues: {
title: '',
@@ -66,7 +67,9 @@ export const EventDetailsForm = () => {
@@ -96,17 +99,33 @@ export const EventDetailsForm = () => {
error={form.errors?.description as string}
/>
-
-
-
-
+ {isRecurring ? (
+
+ {t`Dates and times are managed on the`}{' '}
+
+ {t`Occurrence Schedule page`}
+ .
+
+ ) : (
+
+
+
+
+ )}
{
const {eventId} = useParams();
const eventSettingsQuery = useGetEventSettings(eventId);
- const accountQuery = useGetAccount();
+ const eventQuery = useGetEvent(eventId);
+ const organizerQuery = useGetOrganizer(eventQuery.data?.organizer_id);
const updateMutation = useUpdateEventSettings();
const [currentValue, setCurrentValue] = useState(false);
const [previewPrice, setPreviewPrice] = useState(50);
@@ -38,7 +40,7 @@ export const PlatformFeesSettings = () => {
return (
{
const {data: account} = useGetAccount();
const isSaasMode = account?.is_saas_mode_enabled;
+ const {eventId} = useParams();
+ const {data: event} = useGetEvent(eventId);
+ const isRecurring = event?.type === EventType.RECURRING;
const SECTIONS = useMemo(() => {
const baseSections = [
@@ -71,12 +77,16 @@ export const Settings = () => {
icon: IconAdjustments,
component: MiscSettings
},
- {
+ // Waitlist isn't occurrence-aware yet — offer generation assumes
+ // product-level availability and would fire against aggregated
+ // capacity on recurring events. Hide it for recurring, same pattern
+ // as Capacity Management.
+ ...(!isRecurring ? [{
id: 'waitlist-settings',
label: t`Waitlist`,
icon: IconListCheck,
component: WaitlistSettings,
- },
+ }] : []),
{
id: 'payment-settings',
label: t`Payment & Invoicing`,
@@ -102,7 +112,7 @@ export const Settings = () => {
}
return baseSections;
- }, [isSaasMode]);
+ }, [isSaasMode, isRecurring]);
const isLargeScreen = useMediaQuery('(min-width: 1200px)', true);
const [activeSection, setActiveSection] = useState(() => {
diff --git a/frontend/src/components/routes/event/SoldOutWaitlist/index.tsx b/frontend/src/components/routes/event/SoldOutWaitlist/index.tsx
index fb4f5a4d9f..dc1cdc1686 100644
--- a/frontend/src/components/routes/event/SoldOutWaitlist/index.tsx
+++ b/frontend/src/components/routes/event/SoldOutWaitlist/index.tsx
@@ -6,18 +6,21 @@ import {WaitlistTable} from "../../../common/WaitlistTable";
import {WaitlistStatsCards} from "../../../common/WaitlistStats";
import {SearchBarWrapper} from "../../../common/SearchBar";
import {Pagination} from "../../../common/Pagination";
-import {Button, Select} from "@mantine/core";
+import {Button, Group, Select} from "@mantine/core";
import {ToolBar} from "../../../common/ToolBar";
import {useGetEventWaitlistEntries} from "../../../../queries/useGetEventWaitlistEntries.ts";
import {useGetWaitlistStats} from "../../../../queries/useGetWaitlistStats.ts";
import {useGetEventSettings} from "../../../../queries/useGetEventSettings.ts";
import {useFilterQueryParamSync} from "../../../../hooks/useFilterQueryParamSync.ts";
-import {QueryFilterOperator, QueryFilters, WaitlistEntryStatus} from "../../../../types.ts";
+import {EventType, QueryFilterOperator, QueryFilters, WaitlistEntryStatus} from "../../../../types.ts";
import {TableSkeleton} from "../../../common/TableSkeleton";
import {t} from "@lingui/macro";
import {useDisclosure} from "@mantine/hooks";
import {OfferWaitlistModal} from "../../../modals/OfferWaitlistModal";
import {IconSend} from "@tabler/icons-react";
+import {useGetEventOccurrences} from "../../../../queries/useGetEventOccurrences.ts";
+import {OccurrenceSelect} from "../../../common/OccurrenceSelect";
+import {SortSelector} from "../../../common/SortSelector";
export const SoldOutWaitlist = () => {
const {eventId} = useParams();
@@ -26,9 +29,30 @@ export const SoldOutWaitlist = () => {
const entriesQuery = useGetEventWaitlistEntries(eventId, searchParams as QueryFilters);
const entries = entriesQuery?.data?.data;
const pagination = entriesQuery?.data?.meta;
- const {data: stats} = useGetWaitlistStats(eventId);
+ const selectedOccurrenceId = (searchParams.filterFields?.event_occurrence_id as {value?: string})?.value || null;
+ const {data: stats} = useGetWaitlistStats(eventId, selectedOccurrenceId);
const {data: eventSettings} = useGetEventSettings(eventId);
const [offerModalOpen, {open: openOfferModal, close: closeOfferModal}] = useDisclosure(false);
+ const isRecurring = event?.type === EventType.RECURRING;
+ const {data: occurrencesData} = useGetEventOccurrences(
+ eventId,
+ {pageNumber: 1, perPage: 1200} as QueryFilters,
+ true,
+ {includeStats: false},
+ );
+ const occurrences = occurrencesData?.data || [];
+
+ const handleOccurrenceFilter = (value: string | null) => {
+ setSearchParams({
+ pageNumber: 1,
+ filterFields: {
+ ...(searchParams.filterFields || {}),
+ event_occurrence_id: value
+ ? {operator: QueryFilterOperator.Equals, value}
+ : undefined,
+ },
+ }, true);
+ };
const handleStatusFilter = (value: string | null) => {
setSearchParams({
@@ -56,26 +80,50 @@ export const SoldOutWaitlist = () => {
placeholder={t`Search by name or email...`}
setSearchParams={setSearchParams}
searchParams={searchParams}
- pagination={pagination}
/>
)}
filterComponent={(
-
+
+ {pagination?.allowed_sorts && (
+ {
+ setSearchParams({sortBy: key, sortDirection});
+ }}
+ />
+ )}
+ {isRecurring && occurrences.length > 0 && (
+