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/deploy.yml b/.github/workflows/deploy.yml
index 2d19756c06..be4d021e85 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -33,7 +33,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
- php-version: 8.3
+ php-version: 8.5
tools: composer:v2
coverage: none
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000000..5ecdb95f77
--- /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.3', '8.4', '8.5']
+
+ 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, gd
+ 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..0ed72ecbb1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,7 @@ prompts/
/plans/**
/plans
+
+.claude/worktrees/
+.claude/scheduled_tasks.lock
+tmp_translate/
diff --git a/CLAUDE.md b/CLAUDE.md
index f283207d7f..7abdd754e4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -45,6 +45,13 @@ cd docker/development
## Development Guidelines
+### Comments — hard rule for all code (backend, frontend, SCSS)
+- **DON'T** add explanatory comments. The code must speak for itself.
+- This includes "why" comments justifying a design choice ("X is intentionally omitted because…", "matches the rest of the section rhythm…", "Views live on the per-event-per-day table…", "Online events satisfy the where requirement…"). If you'd write that, rename a variable / extract a function / restructure the code instead, or just leave it implicit.
+- Functional annotations are fine: PHPDoc `@throws` / `@return` / `@param`, `// TODO(handle:owner)` linked to a tracked task, schema comments inside SQL migrations that future migrations depend on.
+- Never restate what the next line does. If a reviewer can read the diff and understand it, the comment is noise.
+- If you're tempted to leave a comment "for the next agent", **don't** — write it as a CLAUDE.md note instead.
+
### Backend
#### Architecture Flow
@@ -58,7 +65,8 @@ cd docker/development
- **ALWAYS** wrap all translatable strings in `__()` helper
- Domain Objects are auto-generated via `php artisan generate-domain-objects` - never edit manually
- **Always** create unit tests for new features in `backend/tests/Unit/`
-- **DON'T** add comments unless absolutely necessary
+- **DON'T** add comments — see the comments rule above. No exceptions for "this seems useful context".
+- **NEVER leave dead code.** Code that has no production callers — unused methods, unused DTO fields, unused constants, columns that are written but never read, classes only called from tests — must be deleted, not left "for future use". This applies to both backend and frontend. If you add a method speculatively, wire it to a real caller in the same change or remove it. The same rule applies after refactors: if something becomes unreferenced, it goes. Confirm with grep before claiming a method or class is reachable.
- **ALWAYS** sanitize user-provided content with `HtmlPurifierService` before storing, especially content rendered as HTML
#### DTOs
@@ -93,6 +101,9 @@ cd docker/development
- **DON'T** use `RefreshDatabase` - use `DatabaseTransactions` instead
- Unit tests extend Laravel's TestCase, not PHPUnit's TestCase
- Use Mockery for mocking
+- **Unit suite (`tests/Unit/`) is for pure isolation tests** — no DB, no HTTP, no real container resolution. If a test uses `DatabaseTransactions`, hits the DB (raw `DB::` calls, factories that persist, repository methods that query), or boots significant framework state, it's an integration test and belongs in `tests/Feature/` (mirror the path, e.g. `tests/Feature/Repository/Eloquent/`). Running `--testsuite=Unit` must stay fast and DB-free.
+- 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/Dockerfile.all-in-one b/Dockerfile.all-in-one
index 45b18a531b..b140532ad8 100644
--- a/Dockerfile.all-in-one
+++ b/Dockerfile.all-in-one
@@ -15,14 +15,14 @@ COPY ./VERSION /app/VERSION
RUN yarn install --network-timeout 600000 --frozen-lockfile && yarn build
# Use stable multi-arch serversideup/php image
-FROM serversideup/php:8.3-fpm-alpine
+FROM serversideup/php:8.5-fpm-alpine
ENV PHP_OPCACHE_ENABLE=1
# Switch to root for installing extensions and packages
USER root
-RUN install-php-extensions intl
+RUN install-php-extensions intl gd
RUN apk add --no-cache nodejs yarn nginx supervisor dos2unix
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/Dockerfile b/backend/Dockerfile
index 609a307da2..9d67d2f42c 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -1,4 +1,4 @@
-FROM serversideup/php:8.4-fpm-nginx-alpine
+FROM serversideup/php:8.5-fpm-nginx-alpine
ENV PHP_OPCACHE_ENABLE=1
@@ -9,7 +9,7 @@ RUN echo "" >> /usr/local/etc/php-fpm.d/docker-php-serversideup-pool.conf && \
echo "user = www-data" >> /usr/local/etc/php-fpm.d/docker-php-serversideup-pool.conf && \
echo "group = www-data" >> /usr/local/etc/php-fpm.d/docker-php-serversideup-pool.conf
-RUN install-php-extensions intl imagick
+RUN install-php-extensions intl imagick gd
COPY --chown=www-data:www-data . .
diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev
index e4f9153f54..668cc0e892 100644
--- a/backend/Dockerfile.dev
+++ b/backend/Dockerfile.dev
@@ -1,4 +1,4 @@
-FROM serversideup/php:8.4-fpm-nginx-alpine
+FROM serversideup/php:8.5-fpm-nginx-alpine
ENV PHP_OPCACHE_ENABLE=1
ENV NGINX_WEBROOT=/var/www/html/public
@@ -12,7 +12,7 @@ COPY --chown=www-data:www-data . /var/www/html
# Switch to root user to install PHP extensions
USER root
-RUN install-php-extensions intl imagick
+RUN install-php-extensions intl imagick gd
USER www-data
RUN chmod -R 755 /var/www/html/storage \
diff --git a/backend/VERSION b/backend/VERSION
new file mode 100755
index 0000000000..e69de29bb2
diff --git a/backend/app/Console/Commands/SeedDevDashboardDataCommand.php b/backend/app/Console/Commands/SeedDevDashboardDataCommand.php
new file mode 100644
index 0000000000..5cfe4f460d
--- /dev/null
+++ b/backend/app/Console/Commands/SeedDevDashboardDataCommand.php
@@ -0,0 +1,262 @@
+environment('production') && !$this->option('force')) {
+ $this->error('Refusing to run in production. Pass --force to override.');
+ return self::FAILURE;
+ }
+
+ $eventId = (int)$this->argument('eventId');
+ $days = (int)$this->option('days');
+
+ $event = DB::table('events')->where('id', $eventId)->first();
+ if ($event === null) {
+ $this->error("Event {$eventId} not found.");
+ return self::FAILURE;
+ }
+
+ $occurrence = DB::table('event_occurrences')
+ ->where('event_id', $eventId)
+ ->whereNull('deleted_at')
+ ->first();
+
+ if ($occurrence === null) {
+ $this->error("Event {$eventId} has no event_occurrence rows. Cannot seed daily statistics.");
+ return self::FAILURE;
+ }
+
+ $this->info("Seeding {$days} days of dummy data for event {$eventId} ({$event->title}, {$event->currency})");
+
+ DB::transaction(function () use ($eventId, $days, $event, $occurrence) {
+ $this->cleanup($eventId);
+
+ $today = CarbonImmutable::today();
+ $aggregateGross = 0.0;
+ $aggregateTax = 0.0;
+ $aggregateFee = 0.0;
+ $aggregateRefunded = 0.0;
+ $aggregateOrders = 0;
+ $aggregateProducts = 0;
+ $aggregateAttendees = 0;
+ $aggregateViews = 0;
+ $aggregateCancelled = 0;
+
+ $bar = $this->output->createProgressBar($days);
+ $bar->start();
+
+ for ($i = $days - 1; $i >= 0; $i--) {
+ $date = $today->subDays($i);
+
+ $isWeekend = in_array($date->dayOfWeek, [0, 6], true);
+ $orderCount = $this->randomOrderCount($i, $isWeekend);
+
+ $dayProducts = 0;
+ $dayAttendees = 0;
+ $dayGross = 0.0;
+ $dayTax = 0.0;
+ $dayFee = 0.0;
+ $dayRefunded = 0.0;
+ $dayOrdersCreated = 0;
+ $dayOrdersCancelled = 0;
+ $dayViews = random_int(20, 180) + ($isWeekend ? 50 : 0);
+
+ for ($n = 0; $n < $orderCount; $n++) {
+ $items = random_int(1, 4);
+ $unitPrice = $this->randomChoice([15.00, 25.00, 35.00, 45.00, 75.00]);
+ $beforeAdditions = round($unitPrice * $items, 2);
+ $tax = round($beforeAdditions * 0.135, 2);
+ $fee = round($beforeAdditions * 0.025, 2);
+ $gross = round($beforeAdditions + $tax + $fee, 2);
+
+ $isCancelled = random_int(1, 100) <= 8;
+ $refundedAmount = 0.0;
+ if (!$isCancelled && random_int(1, 100) <= 6) {
+ $refundedAmount = $gross;
+ }
+
+ $createdAt = $date->setTime(random_int(8, 22), random_int(0, 59), random_int(0, 59));
+
+ $status = $isCancelled ? 'CANCELLED' : 'COMPLETED';
+ $paymentStatus = $isCancelled ? null : 'PAYMENT_RECEIVED';
+ $refundStatus = $refundedAmount > 0 ? 'REFUNDED' : null;
+
+ DB::table('orders')->insert([
+ 'short_id' => IdHelper::shortId(IdHelper::ORDER_PREFIX),
+ 'public_id' => IdHelper::publicId(IdHelper::ORDER_PREFIX),
+ 'event_id' => $eventId,
+ 'currency' => $event->currency,
+ 'first_name' => $this->randomChoice(self::FIRST_NAMES),
+ 'last_name' => $this->randomChoice(self::LAST_NAMES),
+ 'email' => 'seed' . random_int(1000, 9999) . '@example.com',
+ 'status' => $status,
+ 'payment_status' => $paymentStatus,
+ 'refund_status' => $refundStatus,
+ 'total_before_additions' => $beforeAdditions,
+ 'total_gross' => $gross,
+ 'total_tax' => $tax,
+ 'total_fee' => $fee,
+ 'total_refunded' => $refundedAmount,
+ 'is_manually_created' => false,
+ 'notes' => self::SEED_NOTE,
+ 'locale' => 'en',
+ 'payment_provider' => 'STRIPE',
+ 'created_at' => $createdAt,
+ 'updated_at' => $createdAt,
+ ]);
+
+ if ($isCancelled) {
+ $dayOrdersCancelled++;
+ continue;
+ }
+
+ $dayOrdersCreated++;
+ $dayProducts += $items;
+ $dayAttendees += $items;
+ $dayGross += $gross;
+ $dayTax += $tax;
+ $dayFee += $fee;
+ $dayRefunded += $refundedAmount;
+ }
+
+ DB::table('event_occurrence_daily_statistics')->upsert(
+ [
+ [
+ 'event_id' => $eventId,
+ 'event_occurrence_id' => $occurrence->id,
+ 'date' => $date->toDateString(),
+ 'products_sold' => $dayProducts,
+ 'attendees_registered' => $dayAttendees,
+ 'sales_total_gross' => $dayGross,
+ 'sales_total_before_additions' => round($dayGross - $dayTax - $dayFee, 2),
+ 'total_tax' => $dayTax,
+ 'total_fee' => $dayFee,
+ 'orders_created' => $dayOrdersCreated,
+ 'orders_cancelled' => $dayOrdersCancelled,
+ 'total_refunded' => $dayRefunded,
+ 'version' => 0,
+ 'created_at' => $date,
+ 'updated_at' => $date,
+ ],
+ ],
+ ['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',
+ 'updated_at',
+ ],
+ );
+
+ $aggregateGross += $dayGross;
+ $aggregateTax += $dayTax;
+ $aggregateFee += $dayFee;
+ $aggregateRefunded += $dayRefunded;
+ $aggregateOrders += $dayOrdersCreated;
+ $aggregateProducts += $dayProducts;
+ $aggregateAttendees += $dayAttendees;
+ $aggregateViews += $dayViews;
+ $aggregateCancelled += $dayOrdersCancelled;
+
+ $bar->advance();
+ }
+
+ $bar->finish();
+ $this->newLine();
+
+ DB::table('event_statistics')
+ ->where('event_id', $eventId)
+ ->update([
+ 'sales_total_gross' => $aggregateGross,
+ 'sales_total_before_additions' => round($aggregateGross - $aggregateTax - $aggregateFee, 2),
+ 'total_tax' => $aggregateTax,
+ 'total_fee' => $aggregateFee,
+ 'total_refunded' => $aggregateRefunded,
+ 'orders_created' => $aggregateOrders,
+ 'orders_cancelled' => $aggregateCancelled,
+ 'products_sold' => $aggregateProducts,
+ 'attendees_registered' => $aggregateAttendees,
+ 'total_views' => $aggregateViews,
+ 'unique_views' => (int)round($aggregateViews * 0.65),
+ 'updated_at' => now(),
+ ]);
+
+ $this->table(
+ ['Metric', 'Total over period'],
+ [
+ ['Orders (completed)', $aggregateOrders],
+ ['Orders (cancelled)', $aggregateCancelled],
+ ['Products sold', $aggregateProducts],
+ ['Attendees', $aggregateAttendees],
+ ['Gross sales', number_format($aggregateGross, 2) . ' ' . $event->currency],
+ ['Tax', number_format($aggregateTax, 2) . ' ' . $event->currency],
+ ['Fees', number_format($aggregateFee, 2) . ' ' . $event->currency],
+ ['Refunded', number_format($aggregateRefunded, 2) . ' ' . $event->currency],
+ ['Page views', $aggregateViews],
+ ],
+ );
+ });
+
+ $this->info('Done.');
+ return self::SUCCESS;
+ }
+
+ private function cleanup(int $eventId): void
+ {
+ $deletedOrders = DB::table('orders')
+ ->where('event_id', $eventId)
+ ->where('notes', self::SEED_NOTE)
+ ->delete();
+
+ $this->line("Cleaned up {$deletedOrders} prior seed orders. Daily statistics will be upserted for the seeded window.");
+ }
+
+ private function randomOrderCount(int $daysAgo, bool $isWeekend): int
+ {
+ $base = $isWeekend ? random_int(4, 12) : random_int(1, 7);
+
+ if ($daysAgo > 30) {
+ $base = (int)round($base * 0.6);
+ }
+ if (random_int(1, 100) <= 4) {
+ $base += random_int(8, 20);
+ }
+ return max(0, $base);
+ }
+
+ private function randomChoice(array $items)
+ {
+ return $items[array_rand($items)];
+ }
+}
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 +75,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
@@ -102,6 +98,7 @@ public function getProducts(): ?Collection
public function setQuestions(?Collection $questions): EventDomainObject
{
$this->questions = $questions;
+
return $this;
}
@@ -118,6 +115,7 @@ public function getSlug(): string
public function setImages(?Collection $images): EventDomainObject
{
$this->images = $images;
+
return $this;
}
@@ -134,6 +132,7 @@ public function getEventSettings(): ?EventSettingDomainObject
public function setEventSettings(?EventSettingDomainObject $settings): EventDomainObject
{
$this->settings = $settings;
+
return $this;
}
@@ -157,6 +156,7 @@ public function getAccount(): ?AccountDomainObject
public function setAccount(?AccountDomainObject $account): self
{
$this->account = $account;
+
return $this;
}
@@ -178,58 +178,136 @@ 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;
+ }
+
+ $start = Carbon::parse($occurrence->getStartDate(), 'UTC');
+ $end = $occurrence->getEndDate() ? Carbon::parse($occurrence->getEndDate(), 'UTC') : null;
- return $startDate->isPast() && $endDate->isFuture();
+ 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
@@ -276,12 +354,14 @@ public function getEventStatistics(): ?EventStatisticDomainObject
public function setEventStatistics(?EventStatisticDomainObject $eventStatistics): self
{
$this->eventStatistics = $eventStatistics;
+
return $this;
}
public function setProductCategories(?Collection $productCategories): EventDomainObject
{
$this->productCategories = $productCategories;
+
return $this;
}
@@ -298,6 +378,7 @@ public function getWebhooks(): ?Collection
public function setWebhooks(?Collection $webhooks): EventDomainObject
{
$this->webhooks = $webhooks;
+
return $this;
}
@@ -309,6 +390,19 @@ public function getAffiliates(): ?Collection
public function setAffiliates(?Collection $affiliates): EventDomainObject
{
$this->affiliates = $affiliates;
+
+ return $this;
+ }
+
+ public function getEventLocation(): ?EventLocationDomainObject
+ {
+ return $this->eventLocation;
+ }
+
+ public function setEventLocation(?EventLocationDomainObject $eventLocation): self
+ {
+ $this->eventLocation = $eventLocation;
+
return $this;
}
}
diff --git a/backend/app/DomainObjects/EventLocationDomainObject.php b/backend/app/DomainObjects/EventLocationDomainObject.php
new file mode 100644
index 0000000000..0d1d2dbf51
--- /dev/null
+++ b/backend/app/DomainObjects/EventLocationDomainObject.php
@@ -0,0 +1,24 @@
+location;
+ }
+
+ public function setLocation(?LocationDomainObject $location): self
+ {
+ $this->location = $location;
+
+ return $this;
+ }
+}
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());
+ }
+
+ public function setEventLocation(?EventLocationDomainObject $eventLocation): self
+ {
+ $this->eventLocation = $eventLocation;
+
+ return $this;
+ }
+
+ public function getEventLocation(): ?EventLocationDomainObject
+ {
+ return $this->eventLocation;
+ }
+}
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 @@
+
HTML;
}
-
- public function getAddressString(): string
- {
- return AddressHelper::formatAddress($this->getLocationDetails());
- }
-
- public function getAddress(): AddressDTO
- {
- return new AddressDTO(
- venue_name: $this->getLocationDetails()['venue_name'] ?? null,
- address_line_1: $this->getLocationDetails()['address_line_1'] ?? null,
- address_line_2: $this->getLocationDetails()['address_line_2'] ?? null,
- city: $this->getLocationDetails()['city'] ?? null,
- state_or_region: $this->getLocationDetails()['state_or_region'] ?? null,
- zip_or_postal_code: $this->getLocationDetails()['zip_or_postal_code'] ?? null,
- country: $this->getLocationDetails()['country'] ?? null,
- );
- }
}
diff --git a/backend/app/DomainObjects/Generated/AttendeeCheckInDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/AttendeeCheckInDomainObjectAbstract.php
index 5afbf8ab78..6c54be0c4e 100644
--- a/backend/app/DomainObjects/Generated/AttendeeCheckInDomainObjectAbstract.php
+++ b/backend/app/DomainObjects/Generated/AttendeeCheckInDomainObjectAbstract.php
@@ -16,6 +16,7 @@ abstract class AttendeeCheckInDomainObjectAbstract extends \HiEvents\DomainObjec
final public const ATTENDEE_ID = 'attendee_id';
final public const EVENT_ID = 'event_id';
final public const ORDER_ID = 'order_id';
+ final public const EVENT_OCCURRENCE_ID = 'event_occurrence_id';
final public const SHORT_ID = 'short_id';
final public const IP_ADDRESS = 'ip_address';
final public const DELETED_AT = 'deleted_at';
@@ -28,6 +29,7 @@ abstract class AttendeeCheckInDomainObjectAbstract extends \HiEvents\DomainObjec
protected int $attendee_id;
protected int $event_id;
protected ?int $order_id = null;
+ protected ?int $event_occurrence_id = null;
protected string $short_id;
protected string $ip_address;
protected ?string $deleted_at = null;
@@ -43,6 +45,7 @@ public function toArray(): array
'attendee_id' => $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..aa28369236 100644
--- a/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php
+++ b/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php
@@ -14,9 +14,8 @@ abstract class EventDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac
final public const ACCOUNT_ID = 'account_id';
final public const USER_ID = 'user_id';
final public const ORGANIZER_ID = 'organizer_id';
+ final public const EVENT_LOCATION_ID = 'event_location_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 +29,15 @@ 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 ?int $event_location_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 +51,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
{
@@ -59,9 +61,8 @@ public function toArray(): array
'account_id' => $this->account_id ?? null,
'user_id' => $this->user_id ?? null,
'organizer_id' => $this->organizer_id ?? null,
+ 'event_location_id' => $this->event_location_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 +76,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,
];
}
@@ -122,37 +125,26 @@ public function getOrganizerId(): ?int
return $this->organizer_id;
}
- public function setTitle(string $title): self
+ public function setEventLocationId(?int $event_location_id): self
{
- $this->title = $title;
+ $this->event_location_id = $event_location_id;
return $this;
}
- public function getTitle(): string
- {
- return $this->title;
- }
-
- public function setStartDate(?string $start_date): self
+ public function getEventLocationId(): ?int
{
- $this->start_date = $start_date;
- return $this;
+ return $this->event_location_id;
}
- public function getStartDate(): ?string
- {
- return $this->start_date;
- }
-
- public function setEndDate(?string $end_date): self
+ public function setTitle(string $title): self
{
- $this->end_date = $end_date;
+ $this->title = $title;
return $this;
}
- public function getEndDate(): ?string
+ public function getTitle(): string
{
- return $this->end_date;
+ return $this->title;
}
public function setDescription(?string $description): self
@@ -297,4 +289,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/EventLocationDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventLocationDomainObjectAbstract.php
new file mode 100644
index 0000000000..c550b8bce9
--- /dev/null
+++ b/backend/app/DomainObjects/Generated/EventLocationDomainObjectAbstract.php
@@ -0,0 +1,146 @@
+ $this->id ?? null,
+ 'event_id' => $this->event_id ?? null,
+ 'location_id' => $this->location_id ?? null,
+ 'short_id' => $this->short_id ?? null,
+ 'type' => $this->type ?? null,
+ 'online_event_connection_details' => $this->online_event_connection_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 setEventId(int $event_id): self
+ {
+ $this->event_id = $event_id;
+ return $this;
+ }
+
+ public function getEventId(): int
+ {
+ return $this->event_id;
+ }
+
+ public function setLocationId(?int $location_id): self
+ {
+ $this->location_id = $location_id;
+ return $this;
+ }
+
+ public function getLocationId(): ?int
+ {
+ return $this->location_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 setType(string $type): self
+ {
+ $this->type = $type;
+ return $this;
+ }
+
+ public function getType(): string
+ {
+ return $this->type;
+ }
+
+ public function setOnlineEventConnectionDetails(?string $online_event_connection_details): self
+ {
+ $this->online_event_connection_details = $online_event_connection_details;
+ return $this;
+ }
+
+ public function getOnlineEventConnectionDetails(): ?string
+ {
+ return $this->online_event_connection_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/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..2c4cdcb196
--- /dev/null
+++ b/backend/app/DomainObjects/Generated/EventOccurrenceDomainObjectAbstract.php
@@ -0,0 +1,216 @@
+ $this->id ?? null,
+ 'event_id' => $this->event_id ?? null,
+ 'event_location_id' => $this->event_location_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 setEventLocationId(?int $event_location_id): self
+ {
+ $this->event_location_id = $event_location_id;
+ return $this;
+ }
+
+ public function getEventLocationId(): ?int
+ {
+ return $this->event_location_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/EventSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php
index e9b7b492e5..fca6299a61 100644
--- a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php
+++ b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php
@@ -41,7 +41,6 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\
final public const SEO_KEYWORDS = 'seo_keywords';
final public const NOTIFY_ORGANIZER_OF_NEW_ORDERS = 'notify_organizer_of_new_orders';
final public const PRICE_DISPLAY_MODE = 'price_display_mode';
- final public const HIDE_GETTING_STARTED_PAGE = 'hide_getting_started_page';
final public const SHOW_SHARE_BUTTONS = 'show_share_buttons';
final public const HOMEPAGE_BODY_BACKGROUND_COLOR = 'homepage_body_background_color';
final public const HOMEPAGE_BACKGROUND_TYPE = 'homepage_background_type';
@@ -99,7 +98,6 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\
protected ?string $seo_keywords = null;
protected bool $notify_organizer_of_new_orders = true;
protected string $price_display_mode = 'INCLUSIVE';
- protected bool $hide_getting_started_page = false;
protected bool $show_share_buttons = true;
protected ?string $homepage_body_background_color = null;
protected string $homepage_background_type = 'COLOR';
@@ -160,7 +158,6 @@ public function toArray(): array
'seo_keywords' => $this->seo_keywords ?? null,
'notify_organizer_of_new_orders' => $this->notify_organizer_of_new_orders ?? null,
'price_display_mode' => $this->price_display_mode ?? null,
- 'hide_getting_started_page' => $this->hide_getting_started_page ?? null,
'show_share_buttons' => $this->show_share_buttons ?? null,
'homepage_body_background_color' => $this->homepage_body_background_color ?? null,
'homepage_background_type' => $this->homepage_background_type ?? null,
@@ -530,17 +527,6 @@ public function getPriceDisplayMode(): string
return $this->price_display_mode;
}
- public function setHideGettingStartedPage(bool $hide_getting_started_page): self
- {
- $this->hide_getting_started_page = $hide_getting_started_page;
- return $this;
- }
-
- public function getHideGettingStartedPage(): bool
- {
- return $this->hide_getting_started_page;
- }
-
public function setShowShareButtons(bool $show_share_buttons): self
{
$this->show_share_buttons = $show_share_buttons;
diff --git a/backend/app/DomainObjects/Generated/LocationDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/LocationDomainObjectAbstract.php
new file mode 100644
index 0000000000..082a9ac2cd
--- /dev/null
+++ b/backend/app/DomainObjects/Generated/LocationDomainObjectAbstract.php
@@ -0,0 +1,216 @@
+ $this->id ?? null,
+ 'account_id' => $this->account_id ?? null,
+ 'organizer_id' => $this->organizer_id ?? null,
+ 'short_id' => $this->short_id ?? null,
+ 'name' => $this->name ?? null,
+ 'structured_address' => $this->structured_address ?? null,
+ 'latitude' => $this->latitude ?? null,
+ 'longitude' => $this->longitude ?? null,
+ 'provider' => $this->provider ?? null,
+ 'provider_place_id' => $this->provider_place_id ?? null,
+ 'created_at' => $this->created_at ?? null,
+ 'updated_at' => $this->updated_at ?? null,
+ 'deleted_at' => $this->deleted_at ?? null,
+ 'raw_provider_response' => $this->raw_provider_response ?? null,
+ ];
+ }
+
+ public function setId(int $id): self
+ {
+ $this->id = $id;
+ return $this;
+ }
+
+ public function getId(): int
+ {
+ return $this->id;
+ }
+
+ public function setAccountId(int $account_id): self
+ {
+ $this->account_id = $account_id;
+ return $this;
+ }
+
+ public function getAccountId(): int
+ {
+ return $this->account_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 setShortId(string $short_id): self
+ {
+ $this->short_id = $short_id;
+ return $this;
+ }
+
+ public function getShortId(): string
+ {
+ return $this->short_id;
+ }
+
+ public function setName(?string $name): self
+ {
+ $this->name = $name;
+ return $this;
+ }
+
+ public function getName(): ?string
+ {
+ return $this->name;
+ }
+
+ public function setStructuredAddress(array|string|null $structured_address): self
+ {
+ $this->structured_address = $structured_address;
+ return $this;
+ }
+
+ public function getStructuredAddress(): array|string|null
+ {
+ return $this->structured_address;
+ }
+
+ public function setLatitude(?float $latitude): self
+ {
+ $this->latitude = $latitude;
+ return $this;
+ }
+
+ public function getLatitude(): ?float
+ {
+ return $this->latitude;
+ }
+
+ public function setLongitude(?float $longitude): self
+ {
+ $this->longitude = $longitude;
+ return $this;
+ }
+
+ public function getLongitude(): ?float
+ {
+ return $this->longitude;
+ }
+
+ public function setProvider(?string $provider): self
+ {
+ $this->provider = $provider;
+ return $this;
+ }
+
+ public function getProvider(): ?string
+ {
+ return $this->provider;
+ }
+
+ public function setProviderPlaceId(?string $provider_place_id): self
+ {
+ $this->provider_place_id = $provider_place_id;
+ return $this;
+ }
+
+ public function getProviderPlaceId(): ?string
+ {
+ return $this->provider_place_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;
+ }
+
+ public function setRawProviderResponse(array|string|null $raw_provider_response): self
+ {
+ $this->raw_provider_response = $raw_provider_response;
+ return $this;
+ }
+
+ public function getRawProviderResponse(): array|string|null
+ {
+ return $this->raw_provider_response;
+ }
+}
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..eeec006f3c 100644
--- a/backend/app/DomainObjects/Generated/OrganizerDomainObjectAbstract.php
+++ b/backend/app/DomainObjects/Generated/OrganizerDomainObjectAbstract.php
@@ -12,6 +12,8 @@ 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 LOCATION_ID = 'location_id';
final public const NAME = 'name';
final public const EMAIL = 'email';
final public const PHONE = 'phone';
@@ -26,6 +28,8 @@ abstract class OrganizerDomainObjectAbstract extends \HiEvents\DomainObjects\Abs
protected int $id;
protected int $account_id;
+ protected ?int $organizer_configuration_id = null;
+ protected ?int $location_id = null;
protected string $name;
protected string $email;
protected ?string $phone = null;
@@ -43,6 +47,8 @@ public function toArray(): array
return [
'id' => $this->id ?? null,
'account_id' => $this->account_id ?? null,
+ 'organizer_configuration_id' => $this->organizer_configuration_id ?? null,
+ 'location_id' => $this->location_id ?? null,
'name' => $this->name ?? null,
'email' => $this->email ?? null,
'phone' => $this->phone ?? null,
@@ -79,6 +85,28 @@ 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 setLocationId(?int $location_id): self
+ {
+ $this->location_id = $location_id;
+ return $this;
+ }
+
+ public function getLocationId(): ?int
+ {
+ return $this->location_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/LocationDomainObject.php b/backend/app/DomainObjects/LocationDomainObject.php
new file mode 100644
index 0000000000..053f362454
--- /dev/null
+++ b/backend/app/DomainObjects/LocationDomainObject.php
@@ -0,0 +1,50 @@
+ [
+ 'desc' => __('Newest first'),
+ 'asc' => __('Oldest first'),
+ ],
+ self::UPDATED_AT => [
+ 'desc' => __('Recently Updated'),
+ 'asc' => __('Least Recently Updated'),
+ ],
+ self::NAME => [
+ 'asc' => __('Name A-Z'),
+ 'desc' => __('Name Z-A'),
+ ],
+ ]
+ );
+ }
+
+ public static function getDefaultSort(): string
+ {
+ return self::CREATED_AT;
+ }
+
+ public static function getDefaultSortDirection(): string
+ {
+ return 'desc';
+ }
+}
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..650ee1948a 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,15 @@ class OrganizerDomainObject extends Generated\OrganizerDomainObjectAbstract
private ?OrganizerSettingDomainObject $settings = null;
+ /** @var Collection|null */
+ private ?Collection $stripePlatforms = null;
+
+ private ?OrganizerVatSettingDomainObject $vatSetting = null;
+
+ private ?OrganizerConfigurationDomainObject $configuration = null;
+
+ private ?LocationDomainObject $locationRecord = null;
+
public function getImages(): ?Collection
{
return $this->images;
@@ -52,8 +62,104 @@ 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;
+ }
+
+ public function getLocationRecord(): ?LocationDomainObject
+ {
+ return $this->locationRecord;
+ }
+
+ public function setLocationRecord(?LocationDomainObject $locationRecord): self
+ {
+ $this->locationRecord = $locationRecord;
+
+ return $this;
+ }
+
+ public function getLocation(): ?LocationDomainObject
+ {
+ return $this->locationRecord;
+ }
}
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 @@
+affiliates);
+ return collect($this->affiliates->items());
}
public function headings(): array
diff --git a/backend/app/Exports/AttendeesExport.php b/backend/app/Exports/AttendeesExport.php
index d617a5ca5e..0a01087a5c 100644
--- a/backend/app/Exports/AttendeesExport.php
+++ b/backend/app/Exports/AttendeesExport.php
@@ -10,10 +10,8 @@
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\DomainObjects\QuestionDomainObject;
-use HiEvents\Resources\Attendee\AttendeeResource;
use HiEvents\Services\Domain\Question\QuestionAnswerFormatter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
-use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
@@ -39,9 +37,11 @@ public function withData(LengthAwarePaginator|Collection $data, Collection $prod
return $this;
}
- public function collection(): AnonymousResourceCollection
+ public function collection(): Collection
{
- return AttendeeResource::collection($this->data);
+ return $this->data instanceof Collection
+ ? $this->data
+ : collect($this->data->items());
}
public function headings(): array
@@ -59,6 +59,7 @@ public function headings(): array
__('Product ID'),
__('Product Name'),
__('Event ID'),
+ __('Occurrence Date'),
__('Public ID'),
__('Short ID'),
__('Created Date'),
@@ -119,6 +120,10 @@ public function map($attendee): array
->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..66160fecc3 100644
--- a/backend/app/Exports/OrdersExport.php
+++ b/backend/app/Exports/OrdersExport.php
@@ -4,11 +4,11 @@
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;
-use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection;
@@ -20,27 +20,27 @@
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;
}
- public function collection(): AnonymousResourceCollection
+ public function collection(): Collection
{
- return OrderResource::collection($this->orders);
+ return collect($this->orders->items());
}
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 +58,7 @@ public function headings(): array
__('Currency'),
__('Created At'),
__('Public ID'),
+ __('Occurrence Date'),
__('Payment Provider'),
__('Is Partially Refunded'),
__('Is Fully Refunded'),
@@ -71,14 +72,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 +86,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 +112,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/Exports/PromoCodesExport.php b/backend/app/Exports/PromoCodesExport.php
index f75e7bee24..512592b684 100644
--- a/backend/app/Exports/PromoCodesExport.php
+++ b/backend/app/Exports/PromoCodesExport.php
@@ -2,7 +2,7 @@
namespace HiEvents\Exports;
-use HiEvents\Resources\PromoCode\PromoCodeResource;
+use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
@@ -19,9 +19,11 @@ public function withData($data): PromoCodesExport
return $this;
}
- public function collection()
+ public function collection(): Collection
{
- return PromoCodeResource::collection($this->data);
+ return $this->data instanceof Collection
+ ? $this->data
+ : collect(is_array($this->data) ? $this->data : $this->data->items());
}
public function headings(): array
diff --git a/backend/app/Helper/IdHelper.php b/backend/app/Helper/IdHelper.php
index 5a124effb8..653fdfed75 100644
--- a/backend/app/Helper/IdHelper.php
+++ b/backend/app/Helper/IdHelper.php
@@ -13,6 +13,9 @@ class IdHelper
public const CHECK_IN_LIST_PREFIX = 'cil';
public const CHECK_IN_PREFIX = 'ci';
+ public const OCCURRENCE_PREFIX = 'oc';
+ public const LOCATION_PREFIX = 'loc';
+ public const EVENT_LOCATION_PREFIX = 'el';
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..9fc5297890
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/BulkUpdateOccurrencesAction.php
@@ -0,0 +1,63 @@
+isActionAuthorized($eventId, EventDomainObject::class);
+
+ $event = $this->eventRepository->findById($eventId);
+
+ $eventLocationPayload = $request->validated('event_location');
+
+ $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,
+ event_location: $eventLocationPayload !== null ? EventLocationData::fromArray($eventLocationPayload) : null,
+ clear_event_location: (bool) $request->validated('clear_event_location', false),
+ )
+ );
+
+ 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..63d4951dd6
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/CreateEventOccurrenceAction.php
@@ -0,0 +1,51 @@
+isActionAuthorized($eventId, EventDomainObject::class);
+
+ $event = $this->eventRepository->findById($eventId);
+ $timezone = $event->getTimezone();
+
+ $startDate = $request->validated('start_date');
+ $endDate = $request->validated('end_date');
+ $eventLocationPayload = $request->validated('event_location');
+
+ $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,
+ event_location: $eventLocationPayload !== null ? EventLocationData::fromArray($eventLocationPayload) : null,
+ )
+ );
+
+ 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..3c8fabda09
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/DeleteEventOccurrenceAction.php
@@ -0,0 +1,24 @@
+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..66da13e8d8
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/DeletePriceOverrideAction.php
@@ -0,0 +1,24 @@
+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..b3f80ed88c
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/GetEventOccurrenceAction.php
@@ -0,0 +1,28 @@
+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..950ee0ad10
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/GetPriceOverridesAction.php
@@ -0,0 +1,28 @@
+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..1bb6b6adbd
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/GetProductVisibilityAction.php
@@ -0,0 +1,28 @@
+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..553b318f65
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/UpdateEventOccurrenceAction.php
@@ -0,0 +1,52 @@
+isActionAuthorized($eventId, EventDomainObject::class);
+
+ $event = $this->eventRepository->findById($eventId);
+ $timezone = $event->getTimezone();
+
+ $startDate = $request->validated('start_date');
+ $endDate = $request->validated('end_date');
+ $eventLocationPayload = $request->validated('event_location');
+
+ $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'),
+ event_location: $eventLocationPayload !== null ? EventLocationData::fromArray($eventLocationPayload) : null,
+ clear_event_location: (bool) $request->validated('clear_event_location', false),
+ )
+ );
+
+ 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..2a5910689d
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/UpdateProductVisibilityAction.php
@@ -0,0 +1,36 @@
+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..a50a49a8b5
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/UpsertPriceOverrideAction.php
@@ -0,0 +1,37 @@
+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/CreateEventAction.php b/backend/app/Http/Actions/Events/CreateEventAction.php
index 088a90d09f..46942bf1ea 100644
--- a/backend/app/Http/Actions/Events/CreateEventAction.php
+++ b/backend/app/Http/Actions/Events/CreateEventAction.php
@@ -8,6 +8,7 @@
use HiEvents\Resources\Event\EventResource;
use HiEvents\Services\Application\Handlers\Event\CreateEventHandler;
use HiEvents\Services\Application\Handlers\Event\DTO\CreateEventDTO;
+use HiEvents\Services\Domain\EventLocation\EventLocationData;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
use Throwable;
@@ -16,9 +17,7 @@ class CreateEventAction extends BaseAction
{
public function __construct(
private readonly CreateEventHandler $createEventHandler
- )
- {
- }
+ ) {}
/**
* @throws ValidationException|Throwable
@@ -27,11 +26,18 @@ public function __invoke(CreateEventRequest $request): JsonResponse
{
$authorisedUser = $this->getAuthenticatedUser();
+ $validated = $request->validated();
+ $eventLocationPayload = $validated['event_location'] ?? null;
+ unset($validated['event_location']);
+
$eventData = array_merge(
- $request->validated(),
+ $validated,
[
'account_id' => $this->getAuthenticatedAccountId(),
'user_id' => $authorisedUser->getId(),
+ 'event_location' => $eventLocationPayload !== null
+ ? EventLocationData::fromArray($eventLocationPayload)
+ : null,
]
);
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..0662641308 100644
--- a/backend/app/Http/Actions/Events/GetEventAction.php
+++ b/backend/app/Http/Actions/Events/GetEventAction.php
@@ -5,7 +5,10 @@
namespace HiEvents\Http\Actions\Events;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventLocationDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\ImageDomainObject;
+use HiEvents\DomainObjects\LocationDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\DomainObjects\ProductCategoryDomainObject;
use HiEvents\DomainObjects\TaxAndFeesDomainObject;
@@ -33,6 +36,14 @@ 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(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]))
+ ->loadRelation(new Relationship(domainObject: EventOccurrenceDomainObject::class, nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]),
+ ]))
->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..6868a622f9 100644
--- a/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php
+++ b/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php
@@ -9,26 +9,68 @@
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\ValidationException;
class GetEventStatsAction extends BaseAction
{
+ private const MAX_RANGE_DAYS = 370;
+
public function __construct(
private readonly GetEventStatsHandler $eventStatsHandler
)
{
}
+ /**
+ * @throws ValidationException
+ */
public function __invoke(int $eventId, Request $request): JsonResponse
{
$this->isActionAuthorized($eventId, EventDomainObject::class);
- $dateRangePreset = $request->query('date_range', 'month');
+ $validated = $this->validateDateRange($request);
+
+ $occurrenceIdQuery = $request->query('occurrence_id');
$stats = $this->eventStatsHandler->handle(EventStatsRequestDTO::fromArray([
'event_id' => $eventId,
- 'date_range_preset' => $dateRangePreset,
+ 'date_range_preset' => $request->query('date_range', 'month'),
+ 'start_date' => $validated['start_date'] ?? null,
+ 'end_date' => $validated['end_date'] ?? null,
+ 'occurrence_id' => $occurrenceIdQuery !== null ? (int)$occurrenceIdQuery : null,
]));
return $this->resourceResponse(JsonResource::class, $stats);
}
+
+ /**
+ * @return array{start_date: ?string, end_date: ?string}
+ * @throws ValidationException
+ */
+ private function validateDateRange(Request $request): array
+ {
+ $validated = Validator::make(
+ $request->only(['start_date', 'end_date']),
+ [
+ 'start_date' => 'nullable|date|required_with:end_date|before_or_equal:end_date',
+ 'end_date' => 'nullable|date|required_with:start_date|after_or_equal:start_date',
+ ],
+ )->validate();
+
+ if (!empty($validated['start_date']) && !empty($validated['end_date'])) {
+ $days = Carbon::parse($validated['start_date'])->diffInDays(Carbon::parse($validated['end_date']));
+ if ($days > self::MAX_RANGE_DAYS) {
+ throw ValidationException::withMessages([
+ 'start_date' => __('Date range must be less than :days days.', ['days' => self::MAX_RANGE_DAYS]),
+ ]);
+ }
+ }
+
+ return [
+ 'start_date' => $validated['start_date'] ?? null,
+ 'end_date' => $validated['end_date'] ?? null,
+ ];
+ }
}
diff --git a/backend/app/Http/Actions/Events/UpdateEventAction.php b/backend/app/Http/Actions/Events/UpdateEventAction.php
index 87b2c788cc..d50771851a 100644
--- a/backend/app/Http/Actions/Events/UpdateEventAction.php
+++ b/backend/app/Http/Actions/Events/UpdateEventAction.php
@@ -17,9 +17,7 @@ class UpdateEventAction extends BaseAction
{
public function __construct(
private readonly UpdateEventHandler $updateEventHandler
- )
- {
- }
+ ) {}
/**
* @throws Throwable|ValidationException
diff --git a/backend/app/Http/Actions/Events/UpdateEventLocationAction.php b/backend/app/Http/Actions/Events/UpdateEventLocationAction.php
new file mode 100644
index 0000000000..1f49b8fa1e
--- /dev/null
+++ b/backend/app/Http/Actions/Events/UpdateEventLocationAction.php
@@ -0,0 +1,43 @@
+isActionAuthorized($eventId, EventDomainObject::class);
+
+ $eventLocationPayload = $request->validated('event_location');
+
+ $event = $this->handler->handle(new UpdateEventLocationDTO(
+ event_id: $eventId,
+ account_id: $this->getAuthenticatedAccountId(),
+ event_location: $eventLocationPayload !== null
+ ? EventLocationData::fromArray($eventLocationPayload)
+ : null,
+ clear_event_location: (bool) $request->validated('clear_event_location', false),
+ ));
+
+ return $this->resourceResponse(EventResource::class, $event);
+ }
+}
diff --git a/backend/app/Http/Actions/Locations/CreateLocationAction.php b/backend/app/Http/Actions/Locations/CreateLocationAction.php
new file mode 100644
index 0000000000..55dad4af76
--- /dev/null
+++ b/backend/app/Http/Actions/Locations/CreateLocationAction.php
@@ -0,0 +1,44 @@
+isActionAuthorized($organizerId, OrganizerDomainObject::class);
+
+ $location = $this->handler->handle(new UpsertLocationDTO(
+ organizer_id: $organizerId,
+ account_id: $this->getAuthenticatedAccountId(),
+ name: $request->validated('name'),
+ structured_address: AddressDTO::from($request->validated('structured_address')),
+ latitude: $request->validated('latitude'),
+ longitude: $request->validated('longitude'),
+ provider: $request->validated('provider'),
+ provider_place_id: $request->validated('provider_place_id'),
+ ));
+
+ return $this->resourceResponse(
+ resource: LocationResource::class,
+ data: $location,
+ statusCode: ResponseCodes::HTTP_CREATED,
+ );
+ }
+}
diff --git a/backend/app/Http/Actions/Locations/DeleteLocationAction.php b/backend/app/Http/Actions/Locations/DeleteLocationAction.php
new file mode 100644
index 0000000000..3a5d7c4152
--- /dev/null
+++ b/backend/app/Http/Actions/Locations/DeleteLocationAction.php
@@ -0,0 +1,35 @@
+isActionAuthorized($organizerId, OrganizerDomainObject::class);
+
+ try {
+ $this->handler->handle($organizerId, $this->getAuthenticatedAccountId(), $locationId);
+ } catch (ResourceConflictException $e) {
+ return $this->errorResponse(
+ message: $e->getMessage(),
+ statusCode: ResponseCodes::HTTP_CONFLICT,
+ );
+ }
+
+ return $this->deletedResponse();
+ }
+}
diff --git a/backend/app/Http/Actions/Locations/GeoAutocompleteAction.php b/backend/app/Http/Actions/Locations/GeoAutocompleteAction.php
new file mode 100644
index 0000000000..de02371034
--- /dev/null
+++ b/backend/app/Http/Actions/Locations/GeoAutocompleteAction.php
@@ -0,0 +1,54 @@
+isActionAuthorized($organizerId, OrganizerDomainObject::class);
+
+ $query = mb_substr((string) $request->query('query', ''), 0, self::MAX_QUERY_LENGTH);
+ $locale = $request->query('locale');
+ $country = $request->query('country');
+
+ try {
+ $suggestions = $this->handler->handle(
+ query: $query,
+ locale: is_string($locale) ? $locale : null,
+ country: is_string($country) ? $country : null,
+ );
+ } catch (GeoProviderQuotaExceededException) {
+ return $this->errorResponse(
+ message: __('Address suggestions are rate limited. Try again shortly or enter the address manually.'),
+ statusCode: ResponseCodes::HTTP_TOO_MANY_REQUESTS,
+ );
+ } catch (GeoProviderException) {
+ return $this->errorResponse(
+ message: __('Address suggestions are temporarily unavailable. Try again or enter the address manually.'),
+ statusCode: ResponseCodes::HTTP_BAD_GATEWAY,
+ );
+ }
+
+ return $this->jsonResponse([
+ 'data' => array_map(fn ($s) => $s->toArray(), $suggestions),
+ ]);
+ }
+}
diff --git a/backend/app/Http/Actions/Locations/GeoPlaceDetailsAction.php b/backend/app/Http/Actions/Locations/GeoPlaceDetailsAction.php
new file mode 100644
index 0000000000..04cc26bd2f
--- /dev/null
+++ b/backend/app/Http/Actions/Locations/GeoPlaceDetailsAction.php
@@ -0,0 +1,56 @@
+isActionAuthorized($organizerId, OrganizerDomainObject::class);
+
+ $locale = $request->query('locale');
+ try {
+ $place = $this->handler->handle(
+ providerPlaceId: $placeId,
+ locale: is_string($locale) ? $locale : null,
+ );
+ } catch (GeoProviderQuotaExceededException) {
+ return $this->errorResponse(
+ message: __('Place lookups are rate limited. Try again shortly or enter the address manually.'),
+ statusCode: ResponseCodes::HTTP_TOO_MANY_REQUESTS,
+ );
+ } catch (GeoProviderException) {
+ return $this->errorResponse(
+ message: __('Place details are temporarily unavailable. Try again or enter the address manually.'),
+ statusCode: ResponseCodes::HTTP_BAD_GATEWAY,
+ );
+ }
+
+ if ($place === null) {
+ return $this->errorResponse(
+ message: __('Place not found or geo provider unavailable'),
+ statusCode: ResponseCodes::HTTP_NOT_FOUND,
+ );
+ }
+
+ $payload = $place->toArray();
+ unset($payload['raw_response']);
+
+ return $this->jsonResponse(['data' => $payload]);
+ }
+}
diff --git a/backend/app/Http/Actions/Locations/GetGeoStatusAction.php b/backend/app/Http/Actions/Locations/GetGeoStatusAction.php
new file mode 100644
index 0000000000..5fadde6675
--- /dev/null
+++ b/backend/app/Http/Actions/Locations/GetGeoStatusAction.php
@@ -0,0 +1,23 @@
+jsonResponse([
+ 'data' => [
+ 'available' => $provider === 'google' && ! empty($googleKey),
+ ],
+ ]);
+ }
+}
diff --git a/backend/app/Http/Actions/Locations/GetLocationsAction.php b/backend/app/Http/Actions/Locations/GetLocationsAction.php
new file mode 100644
index 0000000000..e3b6f0b31b
--- /dev/null
+++ b/backend/app/Http/Actions/Locations/GetLocationsAction.php
@@ -0,0 +1,37 @@
+isActionAuthorized($organizerId, OrganizerDomainObject::class);
+
+ $locations = $this->handler->handle(
+ organizerId: $organizerId,
+ accountId: $this->getAuthenticatedAccountId(),
+ params: QueryParamsDTO::fromArray($request->query()),
+ );
+
+ return $this->filterableResourceResponse(
+ resource: LocationResource::class,
+ data: $locations,
+ domainObject: \HiEvents\DomainObjects\LocationDomainObject::class,
+ );
+ }
+}
diff --git a/backend/app/Http/Actions/Locations/UpdateLocationAction.php b/backend/app/Http/Actions/Locations/UpdateLocationAction.php
new file mode 100644
index 0000000000..d788337727
--- /dev/null
+++ b/backend/app/Http/Actions/Locations/UpdateLocationAction.php
@@ -0,0 +1,42 @@
+isActionAuthorized($organizerId, OrganizerDomainObject::class);
+
+ $location = $this->handler->handle($locationId, new UpsertLocationDTO(
+ organizer_id: $organizerId,
+ account_id: $this->getAuthenticatedAccountId(),
+ name: $request->validated('name'),
+ structured_address: AddressDTO::from($request->validated('structured_address')),
+ latitude: $request->validated('latitude'),
+ longitude: $request->validated('longitude'),
+ provider: $request->validated('provider'),
+ provider_place_id: $request->validated('provider_place_id'),
+ ));
+
+ return $this->resourceResponse(
+ resource: LocationResource::class,
+ data: $location,
+ );
+ }
+}
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/Orders/ResendOrderConfirmationAction.php b/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php
index c38c6cbac3..01d8d81219 100644
--- a/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php
+++ b/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php
@@ -3,9 +3,12 @@
namespace HiEvents\Http\Actions\Orders;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventLocationDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract;
use HiEvents\DomainObjects\InvoiceDomainObject;
+use HiEvents\DomainObjects\LocationDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\Http\Actions\BaseAction;
@@ -36,7 +39,17 @@ public function __invoke(int $eventId, int $orderId): Response
$this->isActionAuthorized($eventId, EventDomainObject::class);
$order = $this->orderRepository
- ->loadRelation(OrderItemDomainObject::class)
+ ->loadRelation(new Relationship(domainObject: OrderItemDomainObject::class, nested: [
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]),
+ ],
+ name: 'event_occurrence',
+ ),
+ ]))
->loadRelation(InvoiceDomainObject::class)
->findFirstWhere([
OrderDomainObjectAbstract::EVENT_ID => $eventId,
@@ -51,6 +64,14 @@ public function __invoke(int $eventId, int $orderId): Response
$event = $this->eventRepository
->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer'))
->loadRelation(new Relationship(EventSettingDomainObject::class))
+ ->loadRelation(new Relationship(domainObject: EventOccurrenceDomainObject::class, nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]),
+ ]))
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]))
->findById($order->getEventId());
$mail = $this->mailBuilderService->buildOrderSummaryMail(
diff --git a/backend/app/Http/Actions/Organizers/GetOrganizerAction.php b/backend/app/Http/Actions/Organizers/GetOrganizerAction.php
index 896c7cd0cc..74692bee31 100644
--- a/backend/app/Http/Actions/Organizers/GetOrganizerAction.php
+++ b/backend/app/Http/Actions/Organizers/GetOrganizerAction.php
@@ -3,7 +3,11 @@
namespace HiEvents\Http\Actions\Organizers;
use HiEvents\DomainObjects\ImageDomainObject;
+use HiEvents\DomainObjects\LocationDomainObject;
+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 +28,12 @@ public function __invoke(int $organizerId): Response
$organizer = $this->organizerRepository
->loadRelation(ImageDomainObject::class)
+ ->loadRelation(OrganizerStripePlatformDomainObject::class)
+ ->loadRelation(new Relationship(
+ domainObject: OrganizerConfigurationDomainObject::class,
+ name: 'organizer_configuration',
+ ))
+ ->loadRelation(new Relationship(LocationDomainObject::class, name: 'location_record'))
->findFirstWhere([
'id' => $organizerId,
'account_id' => $this->getAuthenticatedAccountId(),
diff --git a/backend/app/Http/Actions/Organizers/Stats/GetOrganizerStatsAction.php b/backend/app/Http/Actions/Organizers/Stats/GetOrganizerStatsAction.php
index 474bf84cc5..fdb3ac9fc4 100644
--- a/backend/app/Http/Actions/Organizers/Stats/GetOrganizerStatsAction.php
+++ b/backend/app/Http/Actions/Organizers/Stats/GetOrganizerStatsAction.php
@@ -8,23 +8,34 @@
use HiEvents\Services\Application\Handlers\Organizer\GetOrganizerStatsHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\ValidationException;
class GetOrganizerStatsAction extends BaseAction
{
+ private const MAX_RANGE_DAYS = 370;
+
public function __construct(
private readonly GetOrganizerStatsHandler $getOrganizerStatsHandler,
- )
- {
- }
+ ) {}
+ /**
+ * @throws ValidationException
+ */
public function __invoke(Request $request, int $organizerId): JsonResponse
{
$this->isActionAuthorized($organizerId, OrganizerDomainObject::class);
+ $validated = $this->validateDateRange($request);
+
$organizerStats = $this->getOrganizerStatsHandler->handle(new GetOrganizerStatsRequestDTO(
organizerId: $organizerId,
accountId: $this->getAuthenticatedAccountId(),
currencyCode: $request->get('currency_code'),
+ startDate: $validated['start_date'],
+ endDate: $validated['end_date'],
+ dateRangePreset: $request->query('date_range', 'month'),
));
return $this->jsonResponse(
@@ -32,4 +43,33 @@ public function __invoke(Request $request, int $organizerId): JsonResponse
wrapInData: true,
);
}
+
+ /**
+ * @return array{start_date: ?string, end_date: ?string}
+ * @throws ValidationException
+ */
+ private function validateDateRange(Request $request): array
+ {
+ $validated = Validator::make(
+ $request->only(['start_date', 'end_date']),
+ [
+ 'start_date' => 'nullable|date|required_with:end_date|before_or_equal:end_date',
+ 'end_date' => 'nullable|date|required_with:start_date|after_or_equal:start_date',
+ ],
+ )->validate();
+
+ if (!empty($validated['start_date']) && !empty($validated['end_date'])) {
+ $days = Carbon::parse($validated['start_date'])->diffInDays(Carbon::parse($validated['end_date']));
+ if ($days > self::MAX_RANGE_DAYS) {
+ throw ValidationException::withMessages([
+ 'start_date' => __('Date range must be less than :days days.', ['days' => self::MAX_RANGE_DAYS]),
+ ]);
+ }
+ }
+
+ return [
+ 'start_date' => $validated['start_date'] ?? null,
+ 'end_date' => $validated['end_date'] ?? null,
+ ];
+ }
}
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/UpdateOrganizerLocationAction.php b/backend/app/Http/Actions/Organizers/UpdateOrganizerLocationAction.php
new file mode 100644
index 0000000000..a96e66b8b2
--- /dev/null
+++ b/backend/app/Http/Actions/Organizers/UpdateOrganizerLocationAction.php
@@ -0,0 +1,33 @@
+isActionAuthorized($organizerId, OrganizerDomainObject::class);
+
+ $organizer = $this->handler->handle(new UpdateOrganizerLocationDTO(
+ organizer_id: $organizerId,
+ account_id: $this->getAuthenticatedAccountId(),
+ location_id: $request->validated('location_id'),
+ ));
+
+ return $this->resourceResponse(OrganizerResource::class, $organizer);
+ }
+}
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/Kernel.php b/backend/app/Http/Kernel.php
index 4e0dd1e283..e91a53aafe 100644
--- a/backend/app/Http/Kernel.php
+++ b/backend/app/Http/Kernel.php
@@ -6,6 +6,7 @@
use HiEvents\Http\Middleware\EncryptCookies;
use HiEvents\Http\Middleware\HandleDeprecatedTimezones;
use HiEvents\Http\Middleware\LogImpersonationMiddleware;
+use HiEvents\Http\Middleware\PreventRequestForgery;
use HiEvents\Http\Middleware\PreventRequestsDuringMaintenance;
use HiEvents\Http\Middleware\RedirectIfAuthenticated;
use HiEvents\Http\Middleware\SetAccountContext;
@@ -14,7 +15,6 @@
use HiEvents\Http\Middleware\TrustProxies;
use HiEvents\Http\Middleware\ValidateSignature;
use HiEvents\Http\Middleware\VaporBinaryResponseMiddleware;
-use HiEvents\Http\Middleware\VerifyCsrfToken;
use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth;
use Illuminate\Auth\Middleware\Authorize;
use Illuminate\Auth\Middleware\EnsureEmailIsVerified;
@@ -63,12 +63,12 @@ class Kernel extends HttpKernel
AddQueuedCookiesToResponse::class,
StartSession::class,
ShareErrorsFromSession::class,
- VerifyCsrfToken::class,
+ PreventRequestForgery::class,
SubstituteBindings::class,
],
'api' => [
- ThrottleRequests::class . ':api',
+ ThrottleRequests::class.':api',
SubstituteBindings::class,
SetAccountContext::class,
SetUserLocaleMiddleware::class,
diff --git a/backend/app/Http/Middleware/VerifyCsrfToken.php b/backend/app/Http/Middleware/PreventRequestForgery.php
similarity index 62%
rename from backend/app/Http/Middleware/VerifyCsrfToken.php
rename to backend/app/Http/Middleware/PreventRequestForgery.php
index 2c4ab515f0..68fdfbca99 100644
--- a/backend/app/Http/Middleware/VerifyCsrfToken.php
+++ b/backend/app/Http/Middleware/PreventRequestForgery.php
@@ -2,9 +2,9 @@
namespace HiEvents\Http\Middleware;
-use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
+use Illuminate\Foundation\Http\Middleware\PreventRequestForgery as Middleware;
-class VerifyCsrfToken extends Middleware
+class PreventRequestForgery extends Middleware
{
/**
* The URIs that should be excluded from CSRF verification.
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/Event/UpdateEventLocationRequest.php b/backend/app/Http/Request/Event/UpdateEventLocationRequest.php
new file mode 100644
index 0000000000..0cee9ffa92
--- /dev/null
+++ b/backend/app/Http/Request/Event/UpdateEventLocationRequest.php
@@ -0,0 +1,36 @@
+ ['nullable', 'array'],
+ 'event_location.type' => ['required_with:event_location', Rule::in(LocationType::valuesArray())],
+ 'event_location.location_id' => [
+ 'nullable', 'integer',
+ 'required_if:event_location.type,'.LocationType::IN_PERSON->name,
+ ],
+ 'event_location.online_event_connection_details' => [
+ 'nullable', 'string', 'max:10000',
+ 'required_if:event_location.type,'.LocationType::ONLINE->name,
+ ],
+ 'clear_event_location' => ['sometimes', 'boolean'],
+ ];
+ }
+
+ public function messages(): array
+ {
+ return [
+ 'event_location.location_id.required_if' => __('A saved location must be selected for in-person events'),
+ ];
+ }
+}
diff --git a/backend/app/Http/Request/Event/UpdateEventRequest.php b/backend/app/Http/Request/Event/UpdateEventRequest.php
index 64861ea23b..1b94095b89 100644
--- a/backend/app/Http/Request/Event/UpdateEventRequest.php
+++ b/backend/app/Http/Request/Event/UpdateEventRequest.php
@@ -13,8 +13,8 @@ class UpdateEventRequest extends BaseRequest
public function rules(): array
{
- $rules = $this->eventRules();
- unset($rules['organizer_id']);
+ $rules = $this->eventRules();
+ unset($rules['organizer_id'], $rules['event_location'], $rules['event_location.type'], $rules['event_location.location_id'], $rules['event_location.online_event_connection_details']);
return $rules;
}
diff --git a/backend/app/Http/Request/EventOccurrence/BulkUpdateOccurrencesRequest.php b/backend/app/Http/Request/EventOccurrence/BulkUpdateOccurrencesRequest.php
new file mode 100644
index 0000000000..13fc9e2fda
--- /dev/null
+++ b/backend/app/Http/Request/EventOccurrence/BulkUpdateOccurrencesRequest.php
@@ -0,0 +1,71 @@
+ ['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 occurrence_ids explicitly or set
+ // apply_to_all=true. An absent set is rejected by withValidator().
+ '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'],
+ 'event_location' => ['nullable', 'array'],
+ 'event_location.type' => ['required_with:event_location', Rule::in(LocationType::valuesArray())],
+ 'event_location.location_id' => [
+ 'nullable', 'integer',
+ 'required_if:event_location.type,'.LocationType::IN_PERSON->name,
+ ],
+ 'event_location.online_event_connection_details' => [
+ 'nullable', 'string', 'max:10000',
+ 'required_if:event_location.type,'.LocationType::ONLINE->name,
+ ],
+ 'clear_event_location' => ['sometimes', 'boolean'],
+ ];
+ }
+
+ public function messages(): array
+ {
+ return [
+ 'event_location.location_id.required_if' => __('A saved location must be selected for in-person occurrences'),
+ ];
+ }
+
+ 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..dd5fb6068f
--- /dev/null
+++ b/backend/app/Http/Request/EventOccurrence/GenerateOccurrencesRequest.php
@@ -0,0 +1,71 @@
+ ['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..627046bac2
--- /dev/null
+++ b/backend/app/Http/Request/EventOccurrence/UpsertEventOccurrenceRequest.php
@@ -0,0 +1,43 @@
+ ['required', 'date'],
+ 'end_date' => ['nullable', 'date', 'after:start_date'],
+ 'capacity' => ['nullable', 'integer', 'min:0'],
+ 'label' => ['nullable', 'string', 'max:255'],
+ 'event_location' => ['nullable', 'array'],
+ 'event_location.type' => ['required_with:event_location', Rule::in(LocationType::valuesArray())],
+ 'event_location.location_id' => [
+ 'nullable', 'integer',
+ 'required_if:event_location.type,'.LocationType::IN_PERSON->name,
+ ],
+ 'event_location.online_event_connection_details' => [
+ 'nullable', 'string', 'max:10000',
+ 'required_if:event_location.type,'.LocationType::ONLINE->name,
+ ],
+ 'clear_event_location' => ['sometimes', 'boolean'],
+ ];
+ }
+
+ public function messages(): array
+ {
+ return [
+ 'event_location.location_id.required_if' => __('A saved location must be selected for in-person occurrences'),
+ ];
+ }
+}
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/EventSettings/UpdateEventSettingsRequest.php b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php
index 213038a74a..36ea577458 100644
--- a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php
+++ b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php
@@ -16,15 +16,15 @@ class UpdateEventSettingsRequest extends BaseRequest
public function rules(): array
{
return [
- 'post_checkout_message' => ['string', "nullable"],
- 'pre_checkout_message' => ['string', "nullable"],
- 'email_footer_message' => ['string', "nullable"],
+ 'post_checkout_message' => ['string', 'nullable'],
+ 'pre_checkout_message' => ['string', 'nullable'],
+ 'email_footer_message' => ['string', 'nullable'],
'continue_button_text' => ['string', 'nullable', 'max:100'],
'support_email' => ['email', 'nullable'],
'require_attendee_details' => ['boolean'],
'attendee_details_collection_method' => [Rule::in(AttendeeDetailsCollectionMethod::valuesArray())],
- 'order_timeout_in_minutes' => ['numeric', "min:1", "max:120"],
+ 'order_timeout_in_minutes' => ['numeric', 'min:1', 'max:120'],
'homepage_background_color' => ['nullable', ...RulesHelper::HEX_COLOR],
'homepage_primary_color' => ['nullable', ...RulesHelper::HEX_COLOR],
@@ -37,18 +37,6 @@ public function rules(): array
'website_url' => ['url', 'nullable'],
'maps_url' => ['url', 'nullable'],
- 'location_details' => ['array'],
- 'location_details.venue_name' => ['string', 'max:255', 'nullable'],
- 'location_details.address_line_1' => ['required_with:location_details', 'string', 'max:255'],
- 'location_details.address_line_2' => ['string', 'max:255', 'nullable'],
- 'location_details.city' => ['required_with:location_details', 'string', 'max:85'],
- 'location_details.state_or_region' => ['string', 'max:85', 'nullable'],
- 'location_details.zip_or_postal_code' => ['required_with:location_details', 'string', 'max:85'],
- 'location_details.country' => ['required_with:location_details', 'string', 'max:2'],
-
- 'is_online_event' => ['boolean'],
- 'online_event_connection_details' => ['string', 'nullable'],
-
'seo_title' => ['string', 'max:255', 'nullable'],
'seo_description' => ['string', 'max:255', 'nullable'],
'seo_keywords' => ['string', 'max:255', 'nullable'],
@@ -58,12 +46,10 @@ public function rules(): array
'price_display_mode' => [Rule::in(PriceDisplayMode::valuesArray())],
- 'hide_getting_started_page' => ['boolean'],
-
// Payment settings
'payment_providers' => ['array'],
'payment_providers.*' => ['string', Rule::in(PaymentProviders::valuesArray())],
- 'offline_payment_instructions' => ['string', 'nullable', Rule::requiredIf(fn() => in_array(PaymentProviders::OFFLINE->name, $this->input('payment_providers', []), true))],
+ 'offline_payment_instructions' => ['string', 'nullable', Rule::requiredIf(fn () => in_array(PaymentProviders::OFFLINE->name, $this->input('payment_providers', []), true))],
'allow_orders_awaiting_offline_payment_to_check_in' => ['boolean'],
// Invoice settings
@@ -120,11 +106,6 @@ public function messages(): array
'homepage_link_color' => $colorMessage,
'homepage_product_widget_background_color' => $colorMessage,
'homepage_product_widget_text_color' => $colorMessage,
- 'location_details.address_line_1.required_with' => __('The address line 1 field is required'),
- 'location_details.city.required_with' => __('The city field is required'),
- 'location_details.zip_or_postal_code.required_with' => __('The zip or postal code field is required'),
- 'location_details.country.required_with' => __('The country field is required'),
- 'location_details.country.max' => __('The country field should be a 2 character ISO 3166 code'),
'price_display_mode.in' => 'The price display mode must be either inclusive or exclusive.',
// Payment messages
diff --git a/backend/app/Http/Request/Location/UpsertLocationRequest.php b/backend/app/Http/Request/Location/UpsertLocationRequest.php
new file mode 100644
index 0000000000..2e65787afd
--- /dev/null
+++ b/backend/app/Http/Request/Location/UpsertLocationRequest.php
@@ -0,0 +1,61 @@
+ ['nullable', 'string', 'max:255'],
+ 'structured_address' => ['required', 'array'],
+ 'structured_address.venue_name' => ['nullable', 'string', 'max:255'],
+ 'structured_address.address_line_1' => ['nullable', 'string', 'max:255'],
+ 'structured_address.address_line_2' => ['nullable', 'string', 'max:255'],
+ 'structured_address.city' => ['nullable', 'string', 'max:85'],
+ 'structured_address.state_or_region' => ['nullable', 'string', 'max:85'],
+ 'structured_address.zip_or_postal_code' => ['nullable', 'string', 'max:85'],
+ 'structured_address.country' => ['nullable', 'string', 'max:2'],
+ 'latitude' => ['nullable', 'numeric', 'between:-90,90'],
+ 'longitude' => ['nullable', 'numeric', 'between:-180,180'],
+ 'provider' => [
+ 'nullable',
+ 'string',
+ Rule::in([GooglePlacesGeoProvider::PROVIDER_NAME]),
+ 'required_with:provider_place_id',
+ ],
+ 'provider_place_id' => [
+ 'nullable',
+ 'string',
+ 'max:255',
+ 'required_with:provider',
+ ],
+ ];
+ }
+
+ public function withValidator($validator): void
+ {
+ $validator->after(function ($validator) {
+ $address = $this->input('structured_address', []);
+ $hasAny = false;
+ foreach (['venue_name', 'address_line_1', 'city', 'state_or_region', 'zip_or_postal_code', 'country'] as $key) {
+ if (! empty($address[$key] ?? null)) {
+ $hasAny = true;
+ break;
+ }
+ }
+ if (! $hasAny) {
+ $validator->errors()->add(
+ 'structured_address',
+ __('Provide at least one address field (venue, street, city, or country).'),
+ );
+ }
+ });
+ }
+}
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/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php b/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php
index 5441436925..1b64811e65 100644
--- a/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php
+++ b/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php
@@ -91,16 +91,6 @@ public static function rules(): array
'website_url' => ['sometimes', 'nullable', 'url'],
- // Location details
- 'location_details' => ['sometimes', 'array'],
- 'location_details.venue_name' => ['sometimes', 'nullable', 'string', 'max:255'],
- 'location_details.address_line_1' => ['sometimes', 'nullable', 'string', 'max:255'],
- 'location_details.address_line_2' => ['sometimes', 'nullable', 'string', 'max:255'],
- 'location_details.city' => ['sometimes', 'nullable', 'string', 'max:85'],
- 'location_details.state_or_region' => ['sometimes', 'nullable', 'string', 'max:85'],
- 'location_details.zip_or_postal_code' => ['sometimes', 'nullable', 'string', 'max:85'],
- 'location_details.country' => ['sometimes', 'nullable', 'string', 'max:2'],
-
// Homepage
'homepage_visibility' => ['nullable', Rule::in(OrganizerHomepageVisibility::valuesArray())],
diff --git a/backend/app/Http/Request/Organizer/UpdateOrganizerLocationRequest.php b/backend/app/Http/Request/Organizer/UpdateOrganizerLocationRequest.php
new file mode 100644
index 0000000000..5334a357d5
--- /dev/null
+++ b/backend/app/Http/Request/Organizer/UpdateOrganizerLocationRequest.php
@@ -0,0 +1,17 @@
+ ['nullable', 'integer'],
+ ];
+ }
+}
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..7a59fd5cac
--- /dev/null
+++ b/backend/app/Jobs/Occurrence/SendOccurrenceCancellationEmailJob.php
@@ -0,0 +1,116 @@
+onQueue('occurrences');
+ }
+
+ public function handle(
+ EventRepositoryInterface $eventRepository,
+ EventOccurrenceRepositoryInterface $occurrenceRepository,
+ AttendeeRepositoryInterface $attendeeRepository,
+ Mailer $mailer,
+ MailBuilderService $mailBuilderService,
+ ): void {
+ $occurrence = $occurrenceRepository
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ], name: 'event_location'))
+ ->findById($this->occurrenceId);
+
+ $event = $eventRepository
+ ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer'))
+ ->loadRelation(new Relationship(EventSettingDomainObject::class))
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ], name: 'event_location'))
+ ->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..54037d8fa4 100644
--- a/backend/app/Mail/Attendee/AttendeeTicketMail.php
+++ b/backend/app/Mail/Attendee/AttendeeTicketMail.php
@@ -4,10 +4,15 @@
use Carbon\Carbon;
use HiEvents\DomainObjects\AttendeeDomainObject;
+use HiEvents\DomainObjects\Enums\LocationType;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventLocationDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
+use HiEvents\DomainObjects\LocationDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
+use HiEvents\Helper\AddressHelper;
use HiEvents\Helper\StringHelper;
use HiEvents\Helper\Url;
use HiEvents\Mail\BaseMail;
@@ -27,14 +32,14 @@ class AttendeeTicketMail extends BaseMail
private readonly ?RenderedEmailTemplateDTO $renderedTemplate;
public function __construct(
- private readonly OrderDomainObject $order,
- private readonly AttendeeDomainObject $attendee,
- private readonly EventDomainObject $event,
+ private readonly OrderDomainObject $order,
+ private readonly AttendeeDomainObject $attendee,
+ private readonly EventDomainObject $event,
private readonly EventSettingDomainObject $eventSettings,
- private readonly OrganizerDomainObject $organizer,
- ?RenderedEmailTemplateDTO $renderedTemplate = null,
- )
- {
+ private readonly OrganizerDomainObject $organizer,
+ ?RenderedEmailTemplateDTO $renderedTemplate = null,
+ private readonly ?EventOccurrenceDomainObject $occurrence = null,
+ ) {
parent::__construct();
$this->renderedTemplate = $renderedTemplate;
}
@@ -42,7 +47,7 @@ public function __construct(
public function envelope(): Envelope
{
$subject = $this->renderedTemplate?->subject ?? __('🎟️ Your Ticket for :event', [
- 'event' => Str::limit($this->event->getTitle(), 50)
+ 'event' => Str::limit($this->event->getTitle(), 50),
]);
return new Envelope(
@@ -64,7 +69,9 @@ public function content(): Content
);
}
- // If no template is provided, use the default blade template
+ $occurrence = $this->occurrence ?? $this->attendee->getEventOccurrence();
+ $eventLocation = $occurrence?->getEventLocation() ?? $this->event->getEventLocation();
+
return new Content(
markdown: 'emails.orders.attendee-ticket',
with: [
@@ -73,23 +80,84 @@ public function content(): Content
'eventSettings' => $this->eventSettings,
'organizer' => $this->organizer,
'order' => $this->order,
+ 'occurrence' => $occurrence,
+ 'eventLocation' => $eventLocation,
+ 'effectiveVenueName' => $this->venueNameFor($eventLocation),
+ 'effectiveAddressString' => $this->addressStringFor($eventLocation),
'ticketUrl' => sprintf(
Url::getFrontEndUrlFromConfig(Url::ATTENDEE_TICKET),
$this->event->getId(),
$this->attendee->getShortId(),
- )
+ ),
]
);
}
+ private function venueNameFor(?EventLocationDomainObject $eventLocation): ?string
+ {
+ $venue = $this->venueLocation($eventLocation);
+ if ($venue === null) {
+ return null;
+ }
+
+ $name = $venue->getName();
+ if ($name !== null && $name !== '') {
+ return $name;
+ }
+
+ return $venue->getStructuredAddress()['venue_name'] ?? null;
+ }
+
+ private function addressStringFor(?EventLocationDomainObject $eventLocation): ?string
+ {
+ $venue = $this->venueLocation($eventLocation);
+ if ($venue === null) {
+ return null;
+ }
+
+ $address = $venue->getStructuredAddress();
+ if (! is_array($address)) {
+ return null;
+ }
+
+ $formatted = AddressHelper::formatAddress($address);
+
+ return $formatted === '' ? null : $formatted;
+ }
+
+ private function venueLocation(?EventLocationDomainObject $eventLocation): ?LocationDomainObject
+ {
+ if ($eventLocation === null) {
+ return null;
+ }
+
+ if ($eventLocation->getType() !== LocationType::IN_PERSON->name) {
+ return null;
+ }
+
+ return $eventLocation->getLocation();
+ }
+
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())
- ->uniqueIdentifier('event-' . $this->attendee->getId())
+ ->name($eventTitle)
+ ->uniqueIdentifier('event-'.$this->attendee->getId())
->startsAt($startDateTime)
->url($this->event->getEventUrl())
->organizer($this->organizer->getEmail(), $this->organizer->getName());
@@ -98,8 +166,14 @@ public function attachments(): array
$event->description(StringHelper::previewFromHtml($this->event->getDescription()));
}
- if ($this->eventSettings->getLocationDetails()) {
- $event->address($this->eventSettings->getAddressString());
+ $occurrence = $this->occurrence ?? $this->attendee->getEventOccurrence();
+ $eventLocation = $occurrence?->getEventLocation() ?? $this->event->getEventLocation();
+ $address = $this->addressStringFor($eventLocation);
+ if ($address !== null) {
+ $event->address($address);
+ } elseif ($eventLocation?->getType() === LocationType::ONLINE->name
+ && $eventLocation->getOnlineEventConnectionDetails() !== null) {
+ $event->address(__('Online event'));
}
if ($endDateTime) {
@@ -111,8 +185,8 @@ public function attachments(): array
->get();
return [
- Attachment::fromData(static fn() => $calendar, 'event.ics')
- ->withMime('text/calendar')
+ Attachment::fromData(static fn () => $calendar, 'event.ics')
+ ->withMime('text/calendar'),
];
}
}
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..8cd19a3973 100644
--- a/backend/app/Models/Event.php
+++ b/backend/app/Models/Event.php
@@ -13,8 +13,8 @@
class Event extends BaseModel
{
- use SoftDeletes;
use HasImages;
+ use SoftDeletes;
public function account(): BelongsTo
{
@@ -81,6 +81,16 @@ public function affiliates(): HasMany
return $this->hasMany(Affiliate::class);
}
+ public function event_occurrences(): HasMany
+ {
+ return $this->hasMany(EventOccurrence::class);
+ }
+
+ public function event_location(): BelongsTo
+ {
+ return $this->belongsTo(EventLocation::class, 'event_location_id');
+ }
+
public static function boot(): void
{
parent::boot();
@@ -96,10 +106,8 @@ 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/EventLocation.php b/backend/app/Models/EventLocation.php
new file mode 100644
index 0000000000..0bc6d389f9
--- /dev/null
+++ b/backend/app/Models/EventLocation.php
@@ -0,0 +1,30 @@
+belongsTo(Event::class);
+ }
+
+ public function location(): BelongsTo
+ {
+ return $this->belongsTo(Location::class, 'location_id');
+ }
+
+ protected function getCastMap(): array
+ {
+ return [];
+ }
+}
diff --git a/backend/app/Models/EventOccurrence.php b/backend/app/Models/EventOccurrence.php
new file mode 100644
index 0000000000..7107058601
--- /dev/null
+++ b/backend/app/Models/EventOccurrence.php
@@ -0,0 +1,71 @@
+belongsTo(Event::class);
+ }
+
+ public function event_location(): BelongsTo
+ {
+ return $this->belongsTo(EventLocation::class, 'event_location_id');
+ }
+
+ 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/EventSetting.php b/backend/app/Models/EventSetting.php
index 11cf5fbb74..b29d1af5f2 100644
--- a/backend/app/Models/EventSetting.php
+++ b/backend/app/Models/EventSetting.php
@@ -11,7 +11,6 @@ class EventSetting extends BaseModel
protected function getCastMap(): array
{
return [
- 'location_details' => 'array',
'payment_providers' => 'array',
'ticket_design_settings' => 'array',
'homepage_theme_settings' => 'array',
diff --git a/backend/app/Models/Location.php b/backend/app/Models/Location.php
new file mode 100644
index 0000000000..7c9ff41297
--- /dev/null
+++ b/backend/app/Models/Location.php
@@ -0,0 +1,36 @@
+belongsTo(Account::class);
+ }
+
+ public function organizer(): BelongsTo
+ {
+ return $this->belongsTo(Organizer::class);
+ }
+
+ protected function getCastMap(): array
+ {
+ return [
+ LocationDomainObjectAbstract::STRUCTURED_ADDRESS => 'array',
+ LocationDomainObjectAbstract::LATITUDE => 'float',
+ LocationDomainObjectAbstract::LONGITUDE => 'float',
+ LocationDomainObjectAbstract::RAW_PROVIDER_RESPONSE => 'array',
+ ];
+ }
+}
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..e1bcb039b0 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,24 @@ 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');
+ }
+
+ public function location_record(): BelongsTo
+ {
+ return $this->belongsTo(Location::class, 'location_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/OrganizerSetting.php b/backend/app/Models/OrganizerSetting.php
index db72d958a2..90d457f855 100644
--- a/backend/app/Models/OrganizerSetting.php
+++ b/backend/app/Models/OrganizerSetting.php
@@ -13,7 +13,6 @@ public function getCastMap(): array
return [
'social_media_handles' => 'array',
'homepage_theme_settings' => 'array',
- 'location_details' => 'array',
'tracking_pixels' => 'array',
];
}
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/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php
index 8f3b933a63..66118f6933 100644
--- a/backend/app/Providers/AppServiceProvider.php
+++ b/backend/app/Providers/AppServiceProvider.php
@@ -12,6 +12,10 @@
use HiEvents\Services\Infrastructure\CurrencyConversion\CurrencyConversionClientInterface;
use HiEvents\Services\Infrastructure\CurrencyConversion\NoOpCurrencyConversionClient;
use HiEvents\Services\Infrastructure\CurrencyConversion\OpenExchangeRatesCurrencyConversionClient;
+use HiEvents\Services\Infrastructure\Geo\GeoProviderInterface;
+use HiEvents\Services\Infrastructure\Geo\GooglePlacesGeoProvider;
+use HiEvents\Services\Infrastructure\Geo\NoOpGeoProvider;
+use Illuminate\Http\Client\Factory as HttpClient;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\DB;
@@ -29,6 +33,7 @@ public function register(): void
$this->bindDoctrineConnection();
$this->bindStripeServices();
$this->bindCurrencyConversionClient();
+ $this->bindGeoProvider();
}
/**
@@ -143,4 +148,28 @@ function () {
}
);
}
+
+ private function bindGeoProvider(): void
+ {
+ $this->app->bind(
+ GeoProviderInterface::class,
+ function () {
+ $provider = config('services.geo.provider');
+ $googleKey = config('services.geo.google.api_key');
+
+ if ($provider === 'google' && $googleKey) {
+ return new GooglePlacesGeoProvider(
+ apiKey: $googleKey,
+ http: $this->app->make(HttpClient::class),
+ logger: $this->app->make('log'),
+ cache: $this->app->make(\Illuminate\Contracts\Cache\Repository::class),
+ );
+ }
+
+ return new NoOpGeoProvider(
+ logger: $this->app->make('log'),
+ );
+ }
+ );
+ }
}
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..90d474beeb 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,11 +16,16 @@
use HiEvents\Repository\Eloquent\CheckInListRepository;
use HiEvents\Repository\Eloquent\EmailTemplateRepository;
use HiEvents\Repository\Eloquent\EventDailyStatisticRepository;
+use HiEvents\Repository\Eloquent\EventLocationRepository;
+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;
use HiEvents\Repository\Eloquent\ImageRepository;
use HiEvents\Repository\Eloquent\InvoiceRepository;
+use HiEvents\Repository\Eloquent\LocationRepository;
use HiEvents\Repository\Eloquent\MessageRepository;
use HiEvents\Repository\Eloquent\OrderApplicationFeeRepository;
use HiEvents\Repository\Eloquent\OrderAuditLogRepository;
@@ -32,10 +35,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 +63,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,11 +71,16 @@
use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface;
use HiEvents\Repository\Interfaces\EmailTemplateRepositoryInterface;
use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventLocationRepositoryInterface;
+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;
use HiEvents\Repository\Interfaces\ImageRepositoryInterface;
use HiEvents\Repository\Interfaces\InvoiceRepositoryInterface;
+use HiEvents\Repository\Interfaces\LocationRepositoryInterface;
use HiEvents\Repository\Interfaces\MessageRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderApplicationFeeRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderAuditLogRepositoryInterface;
@@ -79,10 +90,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 +164,19 @@ 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,
+ LocationRepositoryInterface::class => LocationRepository::class,
+ EventLocationRepositoryInterface::class => EventLocationRepository::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/EventLocationRepository.php b/backend/app/Repository/Eloquent/EventLocationRepository.php
new file mode 100644
index 0000000000..4f269dbf32
--- /dev/null
+++ b/backend/app/Repository/Eloquent/EventLocationRepository.php
@@ -0,0 +1,45 @@
+
+ */
+class EventLocationRepository extends BaseRepository implements EventLocationRepositoryInterface
+{
+ protected function getModel(): string
+ {
+ return EventLocation::class;
+ }
+
+ public function getDomainObject(): string
+ {
+ return EventLocationDomainObject::class;
+ }
+
+ public function isReferenced(int $eventLocationId): bool
+ {
+ $eventCount = DB::table('events')
+ ->where('event_location_id', $eventLocationId)
+ ->whereNull('deleted_at')
+ ->count();
+
+ if ($eventCount > 0) {
+ return true;
+ }
+
+ $occurrenceCount = DB::table('event_occurrences')
+ ->where('event_location_id', $eventLocationId)
+ ->whereNull('deleted_at')
+ ->count();
+
+ return $occurrenceCount > 0;
+ }
+}
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/LocationRepository.php b/backend/app/Repository/Eloquent/LocationRepository.php
new file mode 100644
index 0000000000..6723afe6ee
--- /dev/null
+++ b/backend/app/Repository/Eloquent/LocationRepository.php
@@ -0,0 +1,81 @@
+
+ */
+class LocationRepository extends BaseRepository implements LocationRepositoryInterface
+{
+ protected function getModel(): string
+ {
+ return Location::class;
+ }
+
+ public function getDomainObject(): string
+ {
+ return LocationDomainObject::class;
+ }
+
+ public function findByOrganizerId(int $organizerId, int $accountId, QueryParamsDTO $params): LengthAwarePaginator
+ {
+ $this->model = $this->model->newQuery()->orderBy(
+ column: $this->validateSortColumn($params->sort_by, LocationDomainObject::class),
+ direction: $this->validateSortDirection($params->sort_direction, LocationDomainObject::class),
+ );
+
+ if (! empty($params->filter_fields)) {
+ $this->applyFilterFields($params, LocationDomainObject::getAllowedFilterFields());
+ }
+
+ if (! empty($params->query)) {
+ $needle = '%'.strtolower($params->query).'%';
+ $this->model = $this->model
+ ->where(function ($query) use ($needle) {
+ $query
+ ->whereRaw('LOWER('.LocationDomainObjectAbstract::NAME.') LIKE ?', [$needle])
+ ->orWhereRaw("LOWER(structured_address->>'venue_name') LIKE ?", [$needle])
+ ->orWhereRaw("LOWER(structured_address->>'address_line_1') LIKE ?", [$needle])
+ ->orWhereRaw("LOWER(structured_address->>'city') LIKE ?", [$needle]);
+ });
+ }
+
+ return $this->paginateWhere(
+ where: [
+ LocationDomainObjectAbstract::ORGANIZER_ID => $organizerId,
+ LocationDomainObjectAbstract::ACCOUNT_ID => $accountId,
+ ],
+ limit: $params->per_page,
+ page: $params->page,
+ );
+ }
+
+ public function isReferenced(int $locationId): bool
+ {
+ $eventLocationCount = DB::table('event_locations')
+ ->where('location_id', $locationId)
+ ->whereNull('deleted_at')
+ ->count();
+
+ if ($eventLocationCount > 0) {
+ return true;
+ }
+
+ $organizerCount = DB::table('organizers')
+ ->where('location_id', $locationId)
+ ->whereNull('deleted_at')
+ ->count();
+
+ return $organizerCount > 0;
+ }
+}
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/OrganizerRepository.php b/backend/app/Repository/Eloquent/OrganizerRepository.php
index 6449239786..93871c7059 100644
--- a/backend/app/Repository/Eloquent/OrganizerRepository.php
+++ b/backend/app/Repository/Eloquent/OrganizerRepository.php
@@ -4,14 +4,17 @@
namespace HiEvents\Repository\Eloquent;
+use Carbon\Carbon;
use HiEvents\DomainObjects\Generated\OrganizerDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\OrganizerSettingDomainObjectAbstract;
use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\DomainObjects\Status\OrganizerStatus;
use HiEvents\Models\Organizer;
+use HiEvents\Repository\DTO\Organizer\OrganizerDailyStatsResponseDTO;
use HiEvents\Repository\DTO\Organizer\OrganizerStatsResponseDTO;
use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface;
use Illuminate\Pagination\LengthAwarePaginator;
+use Illuminate\Support\Collection;
/**
* @extends BaseRepository
@@ -32,15 +35,15 @@ public function getSitemapOrganizers(int $page, int $perPage): LengthAwarePagina
{
return $this->handleResults($this->model
->select([
- 'organizers.' . OrganizerDomainObjectAbstract::ID,
- 'organizers.' . OrganizerDomainObjectAbstract::NAME,
- 'organizers.' . OrganizerDomainObjectAbstract::UPDATED_AT,
+ 'organizers.'.OrganizerDomainObjectAbstract::ID,
+ 'organizers.'.OrganizerDomainObjectAbstract::NAME,
+ 'organizers.'.OrganizerDomainObjectAbstract::UPDATED_AT,
])
->join('organizer_settings', 'organizers.id', '=', 'organizer_settings.organizer_id')
- ->where('organizers.' . OrganizerDomainObjectAbstract::STATUS, OrganizerStatus::LIVE->name)
- ->where('organizer_settings.' . OrganizerSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true)
- ->whereNull('organizers.' . OrganizerDomainObjectAbstract::DELETED_AT)
- ->orderBy('organizers.' . OrganizerDomainObjectAbstract::ID)
+ ->where('organizers.'.OrganizerDomainObjectAbstract::STATUS, OrganizerStatus::LIVE->name)
+ ->where('organizer_settings.'.OrganizerSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true)
+ ->whereNull('organizers.'.OrganizerDomainObjectAbstract::DELETED_AT)
+ ->orderBy('organizers.'.OrganizerDomainObjectAbstract::ID)
->paginate($perPage, ['*'], 'page', $page));
}
@@ -49,39 +52,65 @@ public function getSitemapOrganizerCount(): int
return $this->model
->newQuery()
->join('organizer_settings', 'organizers.id', '=', 'organizer_settings.organizer_id')
- ->where('organizers.' . OrganizerDomainObjectAbstract::STATUS, OrganizerStatus::LIVE->name)
- ->where('organizer_settings.' . OrganizerSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true)
- ->whereNull('organizers.' . OrganizerDomainObjectAbstract::DELETED_AT)
+ ->where('organizers.'.OrganizerDomainObjectAbstract::STATUS, OrganizerStatus::LIVE->name)
+ ->where('organizer_settings.'.OrganizerSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true)
+ ->whereNull('organizers.'.OrganizerDomainObjectAbstract::DELETED_AT)
->count();
}
- public function getOrganizerStats(int $organizerId, int $accountId, string $currencyCode): OrganizerStatsResponseDTO
- {
- $totalsQuery = <<= :startDate::date
+ AND eods.date <= :endDate::date
+ AND eods.deleted_at IS NULL
+ AND e.deleted_at IS NULL;
+SQL;
+
+ $totalsResult = $this->db->selectOne($totalsQuery, [
+ 'organizerId' => $organizerId,
+ 'accountId' => $accountId,
+ 'currencyCode' => $currencyCode,
+ 'startDate' => $startDate,
+ 'endDate' => $endDate,
+ ]);
+
+ $totalViewsQuery = <<<'SQL'
+ SELECT COALESCE(SUM(es.total_views), 0) AS total_views
FROM event_statistics es
JOIN events e ON e.id = es.event_id
WHERE e.organizer_id = :organizerId
AND e.account_id = :accountId
AND e.currency = :currencyCode
- AND es.deleted_at IS NULL;
- SQL;
+ AND es.deleted_at IS NULL
+ AND e.deleted_at IS NULL;
+SQL;
- $totalsResult = $this->db->selectOne($totalsQuery, [
+ $totalViewsResult = $this->db->selectOne($totalViewsQuery, [
'organizerId' => $organizerId,
'accountId' => $accountId,
'currencyCode' => $currencyCode,
]);
- $allOrganizersCurrenciesQuery = << $accountId,
]);
+ $dailyStats = $this->getDailyOrganizerStats(
+ organizerId: $organizerId,
+ accountId: $accountId,
+ currencyCode: $currencyCode,
+ startDate: $startDate,
+ endDate: $endDate,
+ );
+
return new OrganizerStatsResponseDTO(
- total_products_sold: (int)($totalsResult->total_products_sold ?? 0),
- total_attendees_registered: (int)($totalsResult->attendees_registered ?? 0),
- total_orders: (int)($totalsResult->total_orders ?? 0),
- total_gross_sales: (float)($totalsResult->total_gross_sales ?? 0),
- total_fees: (float)($totalsResult->total_fees ?? 0),
- total_tax: (float)($totalsResult->total_tax ?? 0),
- total_views: (int)($totalsResult->total_views ?? 0),
- total_refunded: (float)($totalsResult->total_refunded ?? 0),
+ total_products_sold: (int) ($totalsResult->total_products_sold ?? 0),
+ total_attendees_registered: (int) ($totalsResult->attendees_registered ?? 0),
+ total_orders: (int) ($totalsResult->total_orders ?? 0),
+ total_gross_sales: (float) ($totalsResult->total_gross_sales ?? 0),
+ total_fees: (float) ($totalsResult->total_fees ?? 0),
+ total_tax: (float) ($totalsResult->total_tax ?? 0),
+ total_views: (int) ($totalViewsResult->total_views ?? 0),
+ total_refunded: (float) ($totalsResult->total_refunded ?? 0),
currency_code: $currencyCode,
+ daily_stats: $dailyStats,
+ start_date: $startDate,
+ end_date: $endDate,
all_organizers_currencies: array_map(
- static fn($currency) => $currency->currency,
+ static fn ($currency) => $currency->currency,
$allOrganizersCurrencies
),
);
}
+
+ private function getDailyOrganizerStats(
+ int $organizerId,
+ int $accountId,
+ string $currencyCode,
+ string $startDate,
+ string $endDate,
+ ): Collection {
+ $query = <<<'SQL'
+ WITH date_series AS (
+ SELECT date::date
+ FROM generate_series(
+ :startDate::date,
+ :endDate::date,
+ '1 day'
+ ) AS gs(date)
+ )
+ SELECT
+ ds.date,
+ COALESCE(SUM(eods.attendees_registered), 0) AS attendees_registered,
+ COALESCE(SUM(eods.products_sold), 0) AS products_sold,
+ COALESCE(SUM(eods.sales_total_gross), 0) AS total_sales_gross,
+ COALESCE(SUM(eods.orders_created), 0) AS orders_created,
+ COALESCE(SUM(eods.total_refunded), 0) AS total_refunded
+ FROM date_series ds
+ LEFT JOIN event_occurrence_daily_statistics eods
+ ON ds.date = eods.date
+ AND eods.deleted_at IS NULL
+ AND eods.event_id IN (
+ SELECT e.id FROM events e
+ WHERE e.organizer_id = :organizerId
+ AND e.account_id = :accountId
+ AND e.currency = :currencyCode
+ AND e.deleted_at IS NULL
+ )
+ GROUP BY ds.date
+ ORDER BY ds.date ASC;
+SQL;
+
+ $results = $this->db->select($query, [
+ 'startDate' => $startDate,
+ 'endDate' => $endDate,
+ 'organizerId' => $organizerId,
+ 'accountId' => $accountId,
+ 'currencyCode' => $currencyCode,
+ ]);
+
+ $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;
+
+ return new OrganizerDailyStatsResponseDTO(
+ date: $dateTimeWithCurrentTime,
+ attendees_registered: (int) $result->attendees_registered,
+ products_sold: (int) $result->products_sold,
+ total_sales_gross: (float) $result->total_sales_gross,
+ orders_created: (int) $result->orders_created,
+ total_refunded: (float) $result->total_refunded,
+ );
+ });
+ }
}
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/EventLocationRepositoryInterface.php b/backend/app/Repository/Interfaces/EventLocationRepositoryInterface.php
new file mode 100644
index 0000000000..94cd386491
--- /dev/null
+++ b/backend/app/Repository/Interfaces/EventLocationRepositoryInterface.php
@@ -0,0 +1,15 @@
+
+ */
+interface EventLocationRepositoryInterface extends RepositoryInterface
+{
+ public function isReferenced(int $eventLocationId): bool;
+}
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/LocationRepositoryInterface.php b/backend/app/Repository/Interfaces/LocationRepositoryInterface.php
new file mode 100644
index 0000000000..f7164dd06e
--- /dev/null
+++ b/backend/app/Repository/Interfaces/LocationRepositoryInterface.php
@@ -0,0 +1,19 @@
+
+ */
+interface LocationRepositoryInterface extends RepositoryInterface
+{
+ public function findByOrganizerId(int $organizerId, int $accountId, QueryParamsDTO $params): LengthAwarePaginator;
+
+ public function isReferenced(int $locationId): bool;
+}
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/OrganizerRepositoryInterface.php b/backend/app/Repository/Interfaces/OrganizerRepositoryInterface.php
index 069993a93b..97790b6f5f 100644
--- a/backend/app/Repository/Interfaces/OrganizerRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/OrganizerRepositoryInterface.php
@@ -13,7 +13,13 @@
*/
interface OrganizerRepositoryInterface extends RepositoryInterface
{
- public function getOrganizerStats(int $organizerId, int $accountId, string $currencyCode): OrganizerStatsResponseDTO;
+ public function getOrganizerStats(
+ int $organizerId,
+ int $accountId,
+ string $currencyCode,
+ string $startDate,
+ string $endDate,
+ ): OrganizerStatsResponseDTO;
public function getSitemapOrganizers(int $page, int $perPage): LengthAwarePaginator;
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..c7d3ec5e7e 100644
--- a/backend/app/Resources/Event/EventResource.php
+++ b/backend/app/Resources/Event/EventResource.php
@@ -4,6 +4,8 @@
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\Resources\BaseResource;
+use HiEvents\Resources\EventLocation\EventLocationResource;
+use HiEvents\Resources\EventOccurrence\EventOccurrenceResource;
use HiEvents\Resources\Image\ImageResource;
use HiEvents\Resources\Organizer\OrganizerResource;
use HiEvents\Resources\Product\ProductResource;
@@ -24,33 +26,44 @@ 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(),
'slug' => $this->getSlug(),
+ 'organizer_id' => $this->getOrganizerId(),
'products' => $this->when(
- condition: (bool)$this->getProducts(),
- value: fn() => ProductResource::collection($this->getProducts()),
+ condition: (bool) $this->getProducts(),
+ value: fn () => ProductResource::collection($this->getProducts()),
),
'product_categories' => $this->when(
- condition: (bool)$this->getProductCategories(),
- value: fn() => ProductCategoryResource::collection($this->getProductCategories()),
+ condition: (bool) $this->getProductCategories(),
+ value: fn () => ProductCategoryResource::collection($this->getProductCategories()),
+ ),
+ 'attributes' => $this->when((bool) $this->getAttributes(), fn () => $this->getAttributes()),
+ 'images' => $this->when((bool) $this->getImages(), fn () => ImageResource::collection($this->getImages())),
+ 'event_location' => $this->when(
+ condition: $this->getEventLocation() !== null,
+ value: fn () => new EventLocationResource($this->getEventLocation()),
),
- 'attributes' => $this->when((bool)$this->getAttributes(), fn() => $this->getAttributes()),
- 'images' => $this->when((bool)$this->getImages(), fn() => ImageResource::collection($this->getImages())),
- 'location_details' => $this->when((bool)$this->getLocationDetails(), fn() => $this->getLocationDetails()),
'settings' => $this->when(
- condition: !is_null($this->getEventSettings()),
- value: fn() => new EventSettingsResource($this->getEventSettings())
+ condition: ! is_null($this->getEventSettings()),
+ value: fn () => new EventSettingsResource($this->getEventSettings())
),
'organizer' => $this->when(
- condition: !is_null($this->getOrganizer()),
- value: fn() => new OrganizerResource($this->getOrganizer())
+ condition: ! is_null($this->getOrganizer()),
+ value: fn () => new OrganizerResource($this->getOrganizer())
),
'statistics' => $this->when(
- condition: !is_null($this->getEventStatistics()),
- value: fn() => new EventStatisticsResource($this->getEventStatistics())
+ 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..c3819d5076 100644
--- a/backend/app/Resources/Event/EventResourcePublic.php
+++ b/backend/app/Resources/Event/EventResourcePublic.php
@@ -2,8 +2,12 @@
namespace HiEvents\Resources\Event;
+use HiEvents\DomainObjects\Enums\EventType;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\Resources\BaseResource;
+use HiEvents\Resources\EventLocation\EventLocationResourcePublic;
+use HiEvents\Resources\EventOccurrence\EventOccurrenceResourcePublic;
use HiEvents\Resources\Image\ImageResource;
use HiEvents\Resources\Organizer\OrganizerResourcePublic;
use HiEvents\Resources\ProductCategory\ProductCategoryResourcePublic;
@@ -20,11 +24,9 @@ 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)
+ ) {
+ // Laravel passes a numeric collection key as the second arg during
+ // collection iteration; coerce to false unless the caller passed a bool.
$this->includePostCheckoutData = is_bool($includePostCheckoutData)
? $includePostCheckoutData
: false;
@@ -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,54 @@ 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()),
+ 'event_location' => $this->when(
+ condition: $this->getEventLocation() !== null,
+ value: fn () => new EventLocationResourcePublic($this->getEventLocation(), $this->includePostCheckoutData),
+ ),
'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; do not re-cap here
+ // or shared/checkout links past the cap silently drop out.
+ 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/Event/EventSettingsResource.php b/backend/app/Resources/Event/EventSettingsResource.php
index b61c69bf09..ca1037bdc8 100644
--- a/backend/app/Resources/Event/EventSettingsResource.php
+++ b/backend/app/Resources/Event/EventSettingsResource.php
@@ -34,10 +34,6 @@ public function toArray($request): array
'website_url' => $this->getWebsiteUrl(),
'maps_url' => $this->getMapsUrl(),
- 'location_details' => $this->getLocationDetails(),
- 'is_online_event' => $this->getIsOnlineEvent(),
- 'online_event_connection_details' => $this->getOnlineEventConnectionDetails(),
-
'seo_title' => $this->getSeoTitle(),
'seo_description' => $this->getSeoDescription(),
'seo_keywords' => $this->getSeoKeywords(),
@@ -46,7 +42,6 @@ public function toArray($request): array
'notify_organizer_of_new_orders' => $this->getNotifyOrganizerOfNewOrders(),
'price_display_mode' => $this->getPriceDisplayMode(),
- 'hide_getting_started_page' => $this->getHideGettingStartedPage(),
// Ticket design settings
'ticket_design_settings' => $this->getTicketDesignSettings(),
diff --git a/backend/app/Resources/Event/EventSettingsResourcePublic.php b/backend/app/Resources/Event/EventSettingsResourcePublic.php
index 02ed37b5c2..f184db60ed 100644
--- a/backend/app/Resources/Event/EventSettingsResourcePublic.php
+++ b/backend/app/Resources/Event/EventSettingsResourcePublic.php
@@ -11,10 +11,9 @@
class EventSettingsResourcePublic extends JsonResource
{
public function __construct(
- mixed $resource,
+ mixed $resource,
private readonly bool $includePostCheckoutData = false,
- )
- {
+ ) {
parent::__construct($resource);
}
@@ -28,7 +27,6 @@ public function toArray($request): array
// i.e. order->event->event_settings and not event->event_settings
$this->mergeWhen($this->includePostCheckoutData, [
'post_checkout_message' => $this->getPostCheckoutMessage(),
- 'online_event_connection_details' => $this->getOnlineEventConnectionDetails(),
]),
'product_page_message' => $this->getProductPageMessage(),
@@ -51,9 +49,6 @@ public function toArray($request): array
'website_url' => $this->getWebsiteUrl(),
'maps_url' => $this->getMapsUrl(),
- 'location_details' => $this->getLocationDetails(),
- 'is_online_event' => $this->getIsOnlineEvent(),
-
// Ticket design settings
'ticket_design_settings' => $this->getTicketDesignSettings(),
diff --git a/backend/app/Resources/EventLocation/EventLocationResource.php b/backend/app/Resources/EventLocation/EventLocationResource.php
new file mode 100644
index 0000000000..e99b17e689
--- /dev/null
+++ b/backend/app/Resources/EventLocation/EventLocationResource.php
@@ -0,0 +1,46 @@
+includeOnlineConnectionDetails = is_bool($includeOnlineConnectionDetails)
+ ? $includeOnlineConnectionDetails
+ : true;
+
+ parent::__construct($resource);
+ }
+
+ public function toArray(Request $request): array
+ {
+ return [
+ 'id' => $this->getId(),
+ 'type' => $this->getType(),
+ 'location_id' => $this->getLocationId(),
+ 'online_event_connection_details' => $this->when(
+ condition: $this->includeOnlineConnectionDetails,
+ value: fn () => $this->getOnlineEventConnectionDetails(),
+ ),
+ 'location' => $this->when(
+ condition: $this->getLocation() !== null,
+ value: fn () => new LocationResource($this->getLocation()),
+ ),
+ ];
+ }
+}
diff --git a/backend/app/Resources/EventLocation/EventLocationResourcePublic.php b/backend/app/Resources/EventLocation/EventLocationResourcePublic.php
new file mode 100644
index 0000000000..ab57849eca
--- /dev/null
+++ b/backend/app/Resources/EventLocation/EventLocationResourcePublic.php
@@ -0,0 +1,48 @@
+includeOnlineConnectionDetails = is_bool($includeOnlineConnectionDetails)
+ ? $includeOnlineConnectionDetails
+ : false;
+
+ parent::__construct($resource);
+ }
+
+ public function toArray(Request $request): array
+ {
+ return [
+ 'type' => $this->getType(),
+ 'online_event_connection_details' => $this->when(
+ condition: $this->includeOnlineConnectionDetails,
+ value: fn () => $this->getOnlineEventConnectionDetails(),
+ ),
+ 'location' => $this->when(
+ condition: $this->getLocation() !== null,
+ value: fn () => new LocationPublicResource($this->getLocation()),
+ ),
+ ];
+ }
+}
diff --git a/backend/app/Resources/EventOccurrence/EventOccurrenceResource.php b/backend/app/Resources/EventOccurrence/EventOccurrenceResource.php
new file mode 100644
index 0000000000..f0dfca2849
--- /dev/null
+++ b/backend/app/Resources/EventOccurrence/EventOccurrenceResource.php
@@ -0,0 +1,51 @@
+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(),
+ 'event_location' => $this->when(
+ condition: $this->getEventLocation() !== null,
+ value: fn () => new EventLocationResource($this->getEventLocation()),
+ ),
+ '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..dfc76eddb0
--- /dev/null
+++ b/backend/app/Resources/EventOccurrence/EventOccurrenceResourcePublic.php
@@ -0,0 +1,36 @@
+ $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(),
+ 'event_location' => $this->when(
+ condition: $this->getEventLocation() !== null,
+ value: fn () => new EventLocationResourcePublic($this->getEventLocation(), false),
+ ),
+ ];
+ }
+}
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/Location/LocationPublicResource.php b/backend/app/Resources/Location/LocationPublicResource.php
new file mode 100644
index 0000000000..ca11f56a81
--- /dev/null
+++ b/backend/app/Resources/Location/LocationPublicResource.php
@@ -0,0 +1,29 @@
+ $this->getName(),
+ 'structured_address' => $this->getStructuredAddress(),
+ 'latitude' => $this->getLatitude(),
+ 'longitude' => $this->getLongitude(),
+ ];
+ }
+}
diff --git a/backend/app/Resources/Location/LocationResource.php b/backend/app/Resources/Location/LocationResource.php
new file mode 100644
index 0000000000..8cd7921618
--- /dev/null
+++ b/backend/app/Resources/Location/LocationResource.php
@@ -0,0 +1,31 @@
+ $this->getId(),
+ 'organizer_id' => $this->getOrganizerId(),
+ 'name' => $this->getName(),
+ 'structured_address' => $this->getStructuredAddress(),
+ 'latitude' => $this->getLatitude(),
+ 'longitude' => $this->getLongitude(),
+ 'provider' => $this->getProvider(),
+ 'provider_place_id' => $this->getProviderPlaceId(),
+ '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 @@
$this->getCurrency(),
'slug' => $this->getSlug(),
'status' => $this->getStatus(),
+ 'location_id' => $this->getLocationId(),
+ 'location' => $this->when(
+ condition: $this->getLocationRecord() !== null,
+ value: fn() => new LocationResource($this->getLocationRecord()),
+ ),
'images' => $this->when(
(bool)$this->getImages(),
fn() => ImageResource::collection($this->getImages())
@@ -32,6 +38,19 @@ public function toArray($request): array
condition: !is_null($this->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/Organizer/OrganizerResourcePublic.php b/backend/app/Resources/Organizer/OrganizerResourcePublic.php
index 084d457ebb..7b22f23fee 100644
--- a/backend/app/Resources/Organizer/OrganizerResourcePublic.php
+++ b/backend/app/Resources/Organizer/OrganizerResourcePublic.php
@@ -5,6 +5,7 @@
use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\Resources\Event\EventResourcePublic;
use HiEvents\Resources\Image\ImageResource;
+use HiEvents\Resources\Location\LocationPublicResource;
use Illuminate\Http\Resources\Json\JsonResource;
/**
@@ -21,17 +22,21 @@ public function toArray($request): array
'description' => $this->getDescription(),
'slug' => $this->getSlug(),
'status' => $this->getStatus(),
+ 'location' => $this->when(
+ condition: $this->getLocationRecord() !== null,
+ value: fn () => new LocationPublicResource($this->getLocationRecord()),
+ ),
'images' => $this->when(
- (bool)$this->getImages(),
- fn() => ImageResource::collection($this->getImages())
+ (bool) $this->getImages(),
+ fn () => ImageResource::collection($this->getImages())
),
'events' => $this->when(
- condition: !is_null($this->getEvents()),
- value: fn() => EventResourcePublic::collection($this->getEvents())
+ condition: ! is_null($this->getEvents()),
+ value: fn () => EventResourcePublic::collection($this->getEvents())
),
'settings' => $this->when(
- condition: !is_null($this->getOrganizerSettings()),
- value: fn() => new OrganizerSettingsPublicResource($this->getOrganizerSettings())
+ condition: ! is_null($this->getOrganizerSettings()),
+ value: fn () => new OrganizerSettingsPublicResource($this->getOrganizerSettings())
),
];
}
diff --git a/backend/app/Resources/Organizer/OrganizerSettingsResource.php b/backend/app/Resources/Organizer/OrganizerSettingsResource.php
index 439f9fe318..d297cf31ef 100644
--- a/backend/app/Resources/Organizer/OrganizerSettingsResource.php
+++ b/backend/app/Resources/Organizer/OrganizerSettingsResource.php
@@ -28,7 +28,6 @@ public function toArray($request): array
'seo_title' => $this->getSeoTitle(),
'seo_description' => $this->getSeoDescription(),
'allow_search_engine_indexing' => $this->getAllowSearchEngineIndexing(),
- 'location_details' => $this->getLocationDetails(),
'tracking_pixels' => $this->getTrackingPixels(),
'tracking_consent_acknowledged' => $this->getTrackingConsentAcknowledged(),
];
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..15ceec0aa4 100644
--- a/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php
+++ b/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php
@@ -6,12 +6,15 @@
use HiEvents\DomainObjects\Enums\EventCategory;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\Generated\EventDomainObjectAbstract;
use HiEvents\Exceptions\OrganizerNotFoundException;
+use HiEvents\Jobs\Event\Webhook\DispatchEventWebhookJob;
+use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Services\Application\Handlers\Event\DTO\CreateEventDTO;
use HiEvents\Services\Domain\Event\CreateEventService;
-use HiEvents\Services\Domain\ProductCategory\CreateProductCategoryService;
+use HiEvents\Services\Domain\EventLocation\EventLocationUpserter;
use HiEvents\Services\Domain\Organizer\OrganizerFetchService;
-use HiEvents\Jobs\Event\Webhook\DispatchEventWebhookJob;
+use HiEvents\Services\Domain\ProductCategory\CreateProductCategoryService;
use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType;
use Illuminate\Database\DatabaseManager;
use Throwable;
@@ -19,13 +22,13 @@
class CreateEventHandler
{
public function __construct(
- private readonly CreateEventService $createEventService,
- private readonly OrganizerFetchService $organizerFetchService,
+ private readonly CreateEventService $createEventService,
+ private readonly OrganizerFetchService $organizerFetchService,
private readonly CreateProductCategoryService $createProductCategoryService,
- private readonly DatabaseManager $databaseManager,
- )
- {
- }
+ private readonly EventLocationUpserter $eventLocationUpserter,
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly DatabaseManager $databaseManager,
+ ) {}
/**
* @throws OrganizerNotFoundException
@@ -33,7 +36,7 @@ public function __construct(
*/
public function handle(CreateEventDTO $eventData): EventDomainObject
{
- return $this->databaseManager->transaction(fn() => $this->createEvent($eventData));
+ return $this->databaseManager->transaction(fn () => $this->createEvent($eventData));
}
/**
@@ -47,23 +50,45 @@ private function createEvent(CreateEventDTO $eventData): EventDomainObject
accountId: $eventData->account_id
);
- $event = (new EventDomainObject())
+ $event = (new EventDomainObject)
->setOrganizerId($eventData->organizer_id)
->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)
- ->setEventSettings($eventData->event_settings)
- ->setLocationDetails($eventData->location_details?->toArray());
+ ->setType($eventData->type?->name)
+ ->setEventSettings($eventData->event_settings);
+
+ $newEvent = $this->createEventService->createEvent(
+ eventData: $event,
+ startDate: $eventData->start_date,
+ endDate: $eventData->end_date,
+ );
+
+ if ($eventData->event_location !== null) {
+ $eventLocation = $this->eventLocationUpserter->createForEvent(
+ eventId: $newEvent->getId(),
+ accountId: $eventData->account_id,
+ data: $eventData->event_location,
+ );
+
+ $this->eventRepository->updateWhere(
+ attributes: [
+ EventDomainObjectAbstract::EVENT_LOCATION_ID => $eventLocation->getId(),
+ ],
+ where: [
+ 'id' => $newEvent->getId(),
+ ],
+ );
- $newEvent = $this->createEventService->createEvent($event);
+ $newEvent->setEventLocationId($eventLocation->getId());
+ $newEvent->setEventLocation($eventLocation);
+ }
$this->createProductCategoryService->createDefaultProductCategory($newEvent);
diff --git a/backend/app/Services/Application/Handlers/Event/CreateEventImageHandler.php b/backend/app/Services/Application/Handlers/Event/CreateEventImageHandler.php
index fdba6365f4..32bce28da3 100644
--- a/backend/app/Services/Application/Handlers/Event/CreateEventImageHandler.php
+++ b/backend/app/Services/Application/Handlers/Event/CreateEventImageHandler.php
@@ -11,9 +11,7 @@ class CreateEventImageHandler
{
public function __construct(
private readonly CreateEventImageService $createEventImageService,
- )
- {
- }
+ ) {}
/**
* @throws Throwable
diff --git a/backend/app/Services/Application/Handlers/Event/DTO/CreateEventDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/CreateEventDTO.php
index e25ac39383..079c620ed1 100644
--- a/backend/app/Services/Application/Handlers/Event/DTO/CreateEventDTO.php
+++ b/backend/app/Services/Application/Handlers/Event/DTO/CreateEventDTO.php
@@ -2,36 +2,36 @@
namespace HiEvents\Services\Application\Handlers\Event\DTO;
-use HiEvents\DataTransferObjects\AddressDTO;
use HiEvents\DataTransferObjects\Attributes\CollectionOf;
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 HiEvents\Services\Domain\EventLocation\EventLocationData;
use Illuminate\Support\Collection;
class CreateEventDTO extends BaseDTO
{
public function __construct(
- public readonly string $title,
- public readonly int $organizer_id,
- public readonly int $account_id,
- public readonly int $user_id,
- public readonly ?int $id = null,
- public readonly ?string $start_date = null,
- public readonly ?string $end_date = null,
- public readonly ?string $description = null,
+ public readonly string $title,
+ public readonly int $organizer_id,
+ public readonly int $account_id,
+ public readonly int $user_id,
+ public readonly ?int $id = null,
+ public readonly ?string $start_date = null,
+ public readonly ?string $end_date = null,
+ public readonly ?string $description = null,
#[CollectionOf(AttributesDTO::class)]
- public readonly ?Collection $attributes = null,
- public readonly ?string $timezone = null,
- public readonly ?string $currency = null,
+ public readonly ?Collection $attributes = null,
+ public readonly ?string $timezone = null,
+ public readonly ?string $currency = null,
public readonly ?EventCategory $category = null,
- public readonly ?AddressDTO $location_details = null,
- public readonly ?string $status = EventStatus::DRAFT->name,
+ public readonly ?EventLocationData $event_location = 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/CreateEventImageDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/CreateEventImageDTO.php
index b5e466d69d..da47521e53 100644
--- a/backend/app/Services/Application/Handlers/Event/DTO/CreateEventImageDTO.php
+++ b/backend/app/Services/Application/Handlers/Event/DTO/CreateEventImageDTO.php
@@ -9,11 +9,9 @@
class CreateEventImageDTO extends BaseDTO
{
public function __construct(
- public readonly int $eventId,
- public readonly int $accountId,
+ public readonly int $eventId,
+ public readonly int $accountId,
public readonly UploadedFile $image,
- public readonly ImageType $imageType,
- )
- {
- }
+ public readonly ImageType $imageType,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Event/DTO/DeleteEventDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/DeleteEventDTO.php
index 8ef3d4a60a..9bbbe92b8e 100644
--- a/backend/app/Services/Application/Handlers/Event/DTO/DeleteEventDTO.php
+++ b/backend/app/Services/Application/Handlers/Event/DTO/DeleteEventDTO.php
@@ -9,7 +9,5 @@ class DeleteEventDTO extends BaseDTO
public function __construct(
public int $eventId,
public int $accountId,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Event/DTO/DeleteEventImageDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/DeleteEventImageDTO.php
index 1c464d579b..b488231a2a 100644
--- a/backend/app/Services/Application/Handlers/Event/DTO/DeleteEventImageDTO.php
+++ b/backend/app/Services/Application/Handlers/Event/DTO/DeleteEventImageDTO.php
@@ -9,7 +9,5 @@ class DeleteEventImageDTO extends BaseDTO
public function __construct(
public int $eventId,
public int $imageId,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Event/DTO/EventStatsRequestDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/EventStatsRequestDTO.php
index 9b21de9fa4..26882a9c20 100644
--- a/backend/app/Services/Application/Handlers/Event/DTO/EventStatsRequestDTO.php
+++ b/backend/app/Services/Application/Handlers/Event/DTO/EventStatsRequestDTO.php
@@ -7,11 +7,10 @@
class EventStatsRequestDTO extends BaseDTO
{
public function __construct(
- public int $event_id,
+ public int $event_id,
public ?string $start_date = null,
public ?string $end_date = null,
- public string $date_range_preset = 'month',
- )
- {
- }
+ public string $date_range_preset = 'month',
+ public ?int $occurrence_id = null,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Event/DTO/EventStatsResponseDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/EventStatsResponseDTO.php
index ef5e25dfd7..9ac95cd22a 100644
--- a/backend/app/Services/Application/Handlers/Event/DTO/EventStatsResponseDTO.php
+++ b/backend/app/Services/Application/Handlers/Event/DTO/EventStatsResponseDTO.php
@@ -4,7 +4,6 @@
use HiEvents\DataTransferObjects\Attributes\CollectionOf;
use HiEvents\DataTransferObjects\BaseDTO;
-use HiEvents\Services\Domain\Event\DTO\EventCheckInStatsResponseDTO;
use HiEvents\Services\Domain\Event\DTO\EventDailyStatsResponseDTO;
use Illuminate\Support\Collection;
@@ -12,20 +11,18 @@ class EventStatsResponseDTO extends BaseDTO
{
public function __construct(
#[CollectionOf(EventDailyStatsResponseDTO::class)]
- public readonly Collection $daily_stats,
- public readonly string $start_date,
- public readonly string $end_date,
+ public readonly Collection $daily_stats,
+ public readonly string $start_date,
+ public readonly string $end_date,
- public int $total_products_sold,
- public int $total_attendees_registered,
+ public int $total_products_sold,
+ public int $total_attendees_registered,
- public int $total_orders,
- public float $total_gross_sales,
- public float $total_fees,
- public float $total_tax,
- public float $total_views,
- public float $total_refunded,
- )
- {
- }
+ public int $total_orders,
+ public float $total_gross_sales,
+ public float $total_fees,
+ public float $total_tax,
+ public float $total_views,
+ public float $total_refunded,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Event/DTO/GetEventsDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/GetEventsDTO.php
index 4bf64a61ce..cb19e337de 100644
--- a/backend/app/Services/Application/Handlers/Event/DTO/GetEventsDTO.php
+++ b/backend/app/Services/Application/Handlers/Event/DTO/GetEventsDTO.php
@@ -10,7 +10,5 @@ class GetEventsDTO extends BaseDTO
public function __construct(
public int $accountId,
public QueryParamsDTO $queryParams,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Event/DTO/GetPublicEventDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/GetPublicEventDTO.php
index c3d61ce009..8854845de6 100644
--- a/backend/app/Services/Application/Handlers/Event/DTO/GetPublicEventDTO.php
+++ b/backend/app/Services/Application/Handlers/Event/DTO/GetPublicEventDTO.php
@@ -7,11 +7,10 @@
class GetPublicEventDTO extends BaseDTO
{
public function __construct(
- public int $eventId,
- public bool $isAuthenticated,
+ public int $eventId,
+ public bool $isAuthenticated,
public ?string $ipAddress = null,
public ?string $promoCode = null,
- )
- {
- }
+ public ?int $eventOccurrenceId = null,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Event/DTO/GetPublicOrganizerEventsDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/GetPublicOrganizerEventsDTO.php
index 36a70a4900..0b1c5e30cb 100644
--- a/backend/app/Services/Application/Handlers/Event/DTO/GetPublicOrganizerEventsDTO.php
+++ b/backend/app/Services/Application/Handlers/Event/DTO/GetPublicOrganizerEventsDTO.php
@@ -8,10 +8,8 @@
class GetPublicOrganizerEventsDTO extends BaseDTO
{
public function __construct(
- public int $organizerId,
+ public int $organizerId,
public QueryParamsDTO $queryParams,
- public ?int $authenticatedAccountId = null,
- )
- {
- }
+ public ?int $authenticatedAccountId = null,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Event/DTO/UpdateEventDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/UpdateEventDTO.php
index 0f30609d07..de31e84ebe 100644
--- a/backend/app/Services/Application/Handlers/Event/DTO/UpdateEventDTO.php
+++ b/backend/app/Services/Application/Handlers/Event/DTO/UpdateEventDTO.php
@@ -2,7 +2,6 @@
namespace HiEvents\Services\Application\Handlers\Event\DTO;
-use HiEvents\DataTransferObjects\AddressDTO;
use HiEvents\DataTransferObjects\Attributes\CollectionOf;
use HiEvents\DataTransferObjects\AttributesDTO;
use HiEvents\DataTransferObjects\BaseDTO;
@@ -13,21 +12,17 @@
class UpdateEventDTO extends BaseDTO
{
public function __construct(
- public readonly string $title,
+ public readonly string $title,
public readonly ?EventCategory $category,
- public readonly int $account_id,
- public readonly int $id,
- public readonly ?string $start_date = null,
- public readonly ?string $end_date = null,
- public readonly ?string $description = null,
+ public readonly int $account_id,
+ public readonly int $id,
+ public readonly ?string $start_date = null,
+ public readonly ?string $end_date = null,
+ public readonly ?string $description = null,
#[CollectionOf(AttributesDTO::class)]
- public readonly ?Collection $attributes = null,
- public readonly ?string $timezone = null,
- public readonly ?string $currency = null,
- public readonly ?string $location = null,
- public readonly ?AddressDTO $location_details = null,
- public readonly ?string $status = EventStatus::DRAFT->name,
- )
- {
- }
+ public readonly ?Collection $attributes = null,
+ public readonly ?string $timezone = null,
+ public readonly ?string $currency = null,
+ public readonly ?string $status = EventStatus::DRAFT->name,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Event/DTO/UpdateEventLocationDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/UpdateEventLocationDTO.php
new file mode 100644
index 0000000000..b5926d774f
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Event/DTO/UpdateEventLocationDTO.php
@@ -0,0 +1,18 @@
+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/GetEventStatsHandler.php b/backend/app/Services/Application/Handlers/Event/GetEventStatsHandler.php
index cd523bc1d9..d0d3be4fd8 100644
--- a/backend/app/Services/Application/Handlers/Event/GetEventStatsHandler.php
+++ b/backend/app/Services/Application/Handlers/Event/GetEventStatsHandler.php
@@ -8,9 +8,7 @@
readonly class GetEventStatsHandler
{
- public function __construct(private EventStatsFetchService $eventStatsFetchService)
- {
- }
+ public function __construct(private EventStatsFetchService $eventStatsFetchService) {}
public function handle(EventStatsRequestDTO $statsRequestDTO): EventStatsResponseDTO
{
diff --git a/backend/app/Services/Application/Handlers/Event/GetEventsHandler.php b/backend/app/Services/Application/Handlers/Event/GetEventsHandler.php
index d58beb6c43..70779b3f1b 100644
--- a/backend/app/Services/Application/Handlers/Event/GetEventsHandler.php
+++ b/backend/app/Services/Application/Handlers/Event/GetEventsHandler.php
@@ -2,9 +2,12 @@
namespace HiEvents\Services\Application\Handlers\Event;
+use HiEvents\DomainObjects\EventLocationDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\EventStatisticDomainObject;
use HiEvents\DomainObjects\ImageDomainObject;
+use HiEvents\DomainObjects\LocationDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
@@ -17,13 +20,19 @@ class GetEventsHandler
{
public function __construct(
private readonly EventRepositoryInterface $eventRepository,
- )
- {
- }
+ ) {}
public function handle(GetEventsDTO $dto): LengthAwarePaginator
{
return $this->eventRepository
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]))
+ ->loadRelation(new Relationship(domainObject: EventOccurrenceDomainObject::class, nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]),
+ ]))
->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..eb5acb0da6 100644
--- a/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php
+++ b/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php
@@ -2,18 +2,25 @@
namespace HiEvents\Services\Application\Handlers\Event;
+use HiEvents\DomainObjects\Enums\EventType;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventLocationDomainObject;
+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\LocationDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\DomainObjects\OrganizerSettingDomainObject;
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 +29,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
{
@@ -48,29 +56,107 @@ public function handle(GetPublicEventDTO $data): EventDomainObject
])
)
->loadRelation(new Relationship(EventSettingDomainObject::class))
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]))
->loadRelation(new Relationship(ImageDomainObject::class))
->loadRelation(new Relationship(OrganizerDomainObject::class, nested: [
new Relationship(ImageDomainObject::class),
new Relationship(OrganizerSettingDomainObject::class),
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location_record'),
], 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
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]))
+ ->findWhere(
+ where: $occurrenceWhere,
+ orderAndDirections: [
+ new OrderAndDirection(EventOccurrenceDomainObjectAbstract::START_DATE, 'asc'),
+ ],
+ limit: self::MAX_PUBLIC_OCCURRENCES + 1,
+ );
+
+ // 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
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]))
+ ->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..445c97964b 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;
@@ -19,17 +20,16 @@
class GetPublicEventsHandler
{
public function __construct(
- private readonly EventRepositoryInterface $eventRepository,
+ private readonly EventRepositoryInterface $eventRepository,
private readonly OrganizerRepositoryInterface $organizerRepository,
- )
- {
- }
+ ) {}
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..715d2be3ea 100644
--- a/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php
+++ b/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php
@@ -1,19 +1,23 @@
eventRepository->findFirstWhere([
'id' => $eventData->id,
@@ -51,41 +57,62 @@ private function fetchExistingEvent(UpdateEventDTO $eventData)
if ($existingEvent === null) {
throw new ResourceNotFoundException(
- __('Event :id not found', ['id' => $eventData->id])
+ __('Event :id not found', ['id' => $eventData->id]),
);
}
- return $existingEvent;
+ if ($eventData->currency !== null && $eventData->currency !== $existingEvent->getCurrency()) {
+ $this->checkForCompletedOrders($eventData);
+ }
+
+ $attributes = [
+ 'title' => $eventData->title,
+ 'category' => $eventData->category?->value ?? $existingEvent->getCategory(),
+ 'description' => $this->purifier->purify($eventData->description),
+ 'timezone' => $eventData->timezone ?? $existingEvent->getTimezone(),
+ 'currency' => $eventData->currency ?? $existingEvent->getCurrency(),
+ ];
+
+ $this->eventRepository->updateWhere(
+ attributes: $attributes,
+ where: [
+ 'id' => $eventData->id,
+ 'account_id' => $eventData->account_id,
+ ],
+ );
+
+ $this->updateSingleOccurrenceDates($eventData, $existingEvent);
}
- /**
- * @throws CannotChangeCurrencyException
- */
- private function updateEventAttributes(UpdateEventDTO $eventData): void
+ private function updateSingleOccurrenceDates(UpdateEventDTO $eventData, EventDomainObject $existingEvent): void
{
- $existingEvent = $this->fetchExistingEvent($eventData);
+ if ($existingEvent->getType() !== EventType::SINGLE->name) {
+ return;
+ }
- if ($eventData->currency !== null && $eventData->currency !== $existingEvent->getCurrency()) {
- $this->checkForCompletedOrders($eventData);
+ if ($eventData->start_date === null) {
+ return;
}
- $this->eventRepository->updateWhere(
+ $timezone = $eventData->timezone ?? $existingEvent->getTimezone();
+
+ $occurrence = $this->occurrenceRepository->findFirstWhere([
+ 'event_id' => $eventData->id,
+ ]);
+
+ if ($occurrence === null) {
+ return;
+ }
+
+ $this->occurrenceRepository->updateWhere(
attributes: [
- 'title' => $eventData->title,
- 'category' => $eventData->category?->value ?? $existingEvent->getCategory(),
- 'start_date' => DateHelper::convertToUTC($eventData->start_date, $eventData->timezone),
+ 'start_date' => DateHelper::convertToUTC($eventData->start_date, $timezone),
'end_date' => $eventData->end_date
- ? DateHelper::convertToUTC($eventData->end_date, $eventData->timezone)
+ ? DateHelper::convertToUTC($eventData->end_date, $timezone)
: null,
- 'description' => $this->purifier->purify($eventData->description),
- 'timezone' => $eventData->timezone ?? $existingEvent->getTimezone(),
- 'currency' => $eventData->currency ?? $existingEvent->getCurrency(),
- 'location' => $eventData->location,
- 'location_details' => $eventData->location_details?->toArray(),
],
where: [
- 'id' => $eventData->id,
- 'account_id' => $eventData->account_id,
+ 'id' => $occurrence->getId(),
],
);
}
@@ -117,11 +144,10 @@ private function checkForCompletedOrders(UpdateEventDTO $eventData): void
'status' => OrderStatus::COMPLETED->name,
]);
- if (!$orders->isNotEmpty()) {
+ if ($orders->isNotEmpty()) {
throw new CannotChangeCurrencyException(
__('You cannot change the currency of an event that has completed orders'),
);
}
}
}
-
diff --git a/backend/app/Services/Application/Handlers/Event/UpdateEventLocationHandler.php b/backend/app/Services/Application/Handlers/Event/UpdateEventLocationHandler.php
new file mode 100644
index 0000000000..e3144e908f
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Event/UpdateEventLocationHandler.php
@@ -0,0 +1,76 @@
+databaseManager->transaction(function () use ($dto) {
+ $event = $this->eventRepository->findFirstWhere([
+ 'id' => $dto->event_id,
+ 'account_id' => $dto->account_id,
+ ]);
+
+ if ($event === null) {
+ throw new ResourceNotFoundException(__('Event :id not found', ['id' => $dto->event_id]));
+ }
+
+ $previousEventLocationId = $event->getEventLocationId();
+
+ if ($dto->event_location !== null) {
+ if ($previousEventLocationId === null) {
+ $created = $this->eventLocationUpserter->createForEvent(
+ eventId: $dto->event_id,
+ accountId: $dto->account_id,
+ data: $dto->event_location,
+ );
+ $this->eventRepository->updateWhere(
+ attributes: [EventDomainObjectAbstract::EVENT_LOCATION_ID => $created->getId()],
+ where: ['id' => $dto->event_id, 'account_id' => $dto->account_id],
+ );
+ } else {
+ $this->eventLocationUpserter->updateInPlace(
+ eventLocationId: $previousEventLocationId,
+ eventId: $dto->event_id,
+ accountId: $dto->account_id,
+ data: $dto->event_location,
+ );
+ }
+ } elseif ($dto->clear_event_location && $previousEventLocationId !== null) {
+ $this->eventRepository->updateWhere(
+ attributes: [EventDomainObjectAbstract::EVENT_LOCATION_ID => null],
+ where: ['id' => $dto->event_id, 'account_id' => $dto->account_id],
+ );
+ $this->eventLocationCleaner->deleteIfOrphaned($previousEventLocationId);
+ }
+
+ return $this->eventRepository->findFirstWhere([
+ 'id' => $dto->event_id,
+ 'account_id' => $dto->account_id,
+ ]);
+ });
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/Event/UpdateEventStatusHandler.php b/backend/app/Services/Application/Handlers/Event/UpdateEventStatusHandler.php
index 4d43879404..bbf29db4e5 100644
--- a/backend/app/Services/Application/Handlers/Event/UpdateEventStatusHandler.php
+++ b/backend/app/Services/Application/Handlers/Event/UpdateEventStatusHandler.php
@@ -3,12 +3,12 @@
namespace HiEvents\Services\Application\Handlers\Event;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\Status\EventStatus;
use HiEvents\Exceptions\AccountNotVerifiedException;
+use HiEvents\Jobs\Event\Webhook\DispatchEventWebhookJob;
use HiEvents\Repository\Interfaces\AccountRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Services\Application\Handlers\Event\DTO\UpdateEventStatusDTO;
-use HiEvents\DomainObjects\Status\EventStatus;
-use HiEvents\Jobs\Event\Webhook\DispatchEventWebhookJob;
use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType;
use Illuminate\Database\DatabaseManager;
use Psr\Log\LoggerInterface;
@@ -17,13 +17,11 @@
readonly class UpdateEventStatusHandler
{
public function __construct(
- private EventRepositoryInterface $eventRepository,
+ private EventRepositoryInterface $eventRepository,
private AccountRepositoryInterface $accountRepository,
- private LoggerInterface $logger,
- private DatabaseManager $databaseManager,
- )
- {
- }
+ private LoggerInterface $logger,
+ private DatabaseManager $databaseManager,
+ ) {}
/**
* @throws AccountNotVerifiedException|Throwable
@@ -60,7 +58,7 @@ private function updateEventStatus(UpdateEventStatusDTO $updateEventStatusDTO):
$this->logger->info('Event status updated', [
'eventId' => $updateEventStatusDTO->eventId,
- 'status' => $updateEventStatusDTO->status
+ 'status' => $updateEventStatusDTO->status,
]);
$event = $this->eventRepository->findFirstWhere([
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..fb4133a217
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandler.php
@@ -0,0 +1,425 @@
+databaseManager->transaction(function () use ($dto) {
+ $event = $this->eventRepository->findById($dto->event_id);
+ if ($event === null) {
+ throw new ResourceNotFoundException(__('Event :id not found', ['id' => $dto->event_id]));
+ }
+
+ $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, $event->getAccountId()),
+ };
+ });
+ }
+
+ 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: []);
+ }
+
+ // Attendees can exist without order items (imports/legacy), 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 = [];
+ $deletableEventLocationIds = [];
+
+ foreach ($eligible as $occurrence) {
+ $id = $occurrence->getId();
+
+ if (! isset($idsWithOrders[$id]) && ! isset($idsWithAttendees[$id])) {
+ $deletableIds[] = $id;
+ $deletableStartDates[] = $occurrence->getStartDate();
+ if ($occurrence->getEventLocationId() !== null) {
+ $deletableEventLocationIds[] = $occurrence->getEventLocationId();
+ }
+ }
+ }
+
+ 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);
+
+ foreach (array_unique($deletableEventLocationIds) as $eventLocationId) {
+ $this->eventLocationCleaner->deleteIfOrphaned($eventLocationId);
+ }
+ }
+
+ return new BulkUpdateOccurrencesResultDTO(
+ updated_count: count($deletableIds),
+ updated_ids: $deletableIds,
+ );
+ }
+
+ private function handleUpdate(BulkUpdateOccurrencesDTO $dto, Collection $eligible, int $accountId): BulkUpdateOccurrencesResultDTO
+ {
+ $perRowEventLocation = $dto->event_location !== null || $dto->clear_event_location;
+
+ $requiresPerRow = $dto->start_time_shift !== null
+ || $dto->end_time_shift !== null
+ || $dto->duration_minutes !== null
+ || $perRowEventLocation;
+
+ if ($requiresPerRow) {
+ return $this->applyPerRowUpdate($dto, $eligible, $accountId);
+ }
+
+ 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);
+
+ 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],
+ ],
+ );
+
+ 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
+ {
+ $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,
+ );
+
+ 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, int $accountId): BulkUpdateOccurrencesResultDTO
+ {
+ $updatedIds = [];
+ $orphanCandidateIds = [];
+
+ foreach ($eligible as $occurrence) {
+ $attributes = $this->buildPerRowAttributes($dto, $occurrence);
+
+ $previousEventLocationId = $occurrence->getEventLocationId();
+
+ if ($dto->event_location !== null) {
+ $eventLocation = $this->eventLocationUpserter->createForEvent(
+ eventId: $dto->event_id,
+ accountId: $accountId,
+ data: $dto->event_location,
+ );
+ $attributes[EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID] = $eventLocation->getId();
+ $attributes[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] = true;
+
+ if ($previousEventLocationId !== null) {
+ $orphanCandidateIds[] = $previousEventLocationId;
+ }
+ } elseif ($dto->clear_event_location && $previousEventLocationId !== null) {
+ $attributes[EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID] = null;
+ $attributes[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] = true;
+ $orphanCandidateIds[] = $previousEventLocationId;
+ }
+
+ if (! empty($attributes)) {
+ 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();
+ }
+ }
+
+ foreach (array_unique($orphanCandidateIds) as $eventLocationId) {
+ $this->eventLocationCleaner->deleteIfOrphaned($eventLocationId);
+ }
+
+ return new BulkUpdateOccurrencesResultDTO(
+ updated_count: count($updatedIds),
+ updated_ids: $updatedIds,
+ );
+ }
+
+ 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;
+ }
+
+ 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..c703aa2e83
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandler.php
@@ -0,0 +1,64 @@
+databaseManager->transaction(function () use ($dto) {
+ $eventLocationId = null;
+
+ if ($dto->event_location !== null) {
+ $event = $this->eventRepository->findById($dto->event_id);
+ if ($event === null) {
+ throw new ResourceNotFoundException(__('Event :id not found', ['id' => $dto->event_id]));
+ }
+
+ $eventLocation = $this->eventLocationUpserter->createForEvent(
+ eventId: $dto->event_id,
+ accountId: $event->getAccountId(),
+ data: $dto->event_location,
+ );
+ $eventLocationId = $eventLocation->getId();
+ }
+
+ 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,
+ EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID => $eventLocationId,
+ ]);
+ });
+ }
+}
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..0756dfbd01
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/DTO/BulkUpdateOccurrencesDTO.php
@@ -0,0 +1,30 @@
+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..25481a918d
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandler.php
@@ -0,0 +1,59 @@
+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..76d7d23477
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandler.php
@@ -0,0 +1,45 @@
+occurrenceRepository
+ ->loadRelation(EventOccurrenceStatisticDomainObject::class)
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]))
+ ->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..da4cb19f43
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandler.php
@@ -0,0 +1,34 @@
+occurrenceRepository
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]));
+
+ 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..ea60247046
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandler.php
@@ -0,0 +1,38 @@
+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..05dc936a7e
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/DTO/UpsertPriceOverrideDTO.php
@@ -0,0 +1,15 @@
+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..ff9967eb6a
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandler.php
@@ -0,0 +1,38 @@
+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..9640e791e6
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandler.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])
+ );
+ }
+
+ $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..0b8c290341
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandler.php
@@ -0,0 +1,159 @@
+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,
+ ])
+ );
+ }
+
+ $previousEventLocationId = $occurrence->getEventLocationId();
+ $newEventLocationId = $previousEventLocationId;
+ $eventLocationChanged = false;
+
+ if ($dto->event_location !== null) {
+ $event = $this->eventRepository->findById($dto->event_id);
+ if ($event === null) {
+ throw new ResourceNotFoundException(__('Event :id not found', ['id' => $dto->event_id]));
+ }
+
+ if ($previousEventLocationId === null) {
+ $eventLocation = $this->eventLocationUpserter->createForEvent(
+ eventId: $dto->event_id,
+ accountId: $event->getAccountId(),
+ data: $dto->event_location,
+ );
+ $newEventLocationId = $eventLocation->getId();
+ $eventLocationChanged = true;
+ } else {
+ $this->eventLocationUpserter->updateInPlace(
+ eventLocationId: $previousEventLocationId,
+ eventId: $dto->event_id,
+ accountId: $event->getAccountId(),
+ data: $dto->event_location,
+ );
+ }
+ } elseif ($dto->clear_event_location && $previousEventLocationId !== null) {
+ $newEventLocationId = null;
+ $eventLocationChanged = true;
+ }
+
+ // `DateHelper::convertToUTC` normalizes to a different string than the
+ // DB-hydrated value, so string compare alone always reports a change.
+ $isOverride = $occurrence->getIsOverridden()
+ || $this->datesDiffer($dto->start_date, $occurrence->getStartDate())
+ || $this->datesDiffer($dto->end_date, $occurrence->getEndDate())
+ || $dto->capacity !== $occurrence->getCapacity()
+ || $eventLocationChanged;
+
+ $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,
+ EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID => $newEventLocationId,
+ ];
+
+ $reconciledStatus = $this->reconcileCapacityStatus(
+ currentStatus: $occurrence->getStatus(),
+ newCapacity: $dto->capacity,
+ usedCapacity: $occurrence->getUsedCapacity() ?? 0,
+ );
+ if ($reconciledStatus !== null) {
+ $attributes[EventOccurrenceDomainObjectAbstract::STATUS] = $reconciledStatus;
+ }
+
+ $updated = $this->occurrenceRepository->updateFromArray(
+ id: $occurrence->getId(),
+ attributes: $attributes,
+ );
+
+ if ($dto->clear_event_location && $previousEventLocationId !== null) {
+ $this->eventLocationCleaner->deleteIfOrphaned($previousEventLocationId);
+ }
+
+ return $updated;
+ });
+ }
+
+ 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 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..965aebe735
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandler.php
@@ -0,0 +1,83 @@
+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/DTO/GetPlatformFeePreviewDTO.php b/backend/app/Services/Application/Handlers/EventSettings/DTO/GetPlatformFeePreviewDTO.php
index b9006e9ed1..6b803a4eb7 100644
--- a/backend/app/Services/Application/Handlers/EventSettings/DTO/GetPlatformFeePreviewDTO.php
+++ b/backend/app/Services/Application/Handlers/EventSettings/DTO/GetPlatformFeePreviewDTO.php
@@ -7,8 +7,7 @@
class GetPlatformFeePreviewDTO extends BaseDataObject
{
public function __construct(
- public readonly int $eventId,
+ public readonly int $eventId,
public readonly float $price,
- ) {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/EventSettings/DTO/PartialUpdateEventSettingsDTO.php b/backend/app/Services/Application/Handlers/EventSettings/DTO/PartialUpdateEventSettingsDTO.php
index 315a23b7ae..ecd7f9134f 100644
--- a/backend/app/Services/Application/Handlers/EventSettings/DTO/PartialUpdateEventSettingsDTO.php
+++ b/backend/app/Services/Application/Handlers/EventSettings/DTO/PartialUpdateEventSettingsDTO.php
@@ -7,10 +7,8 @@
class PartialUpdateEventSettingsDTO extends BaseDTO
{
public function __construct(
- public readonly int $account_id,
- public readonly int $event_id,
+ public readonly int $account_id,
+ public readonly int $event_id,
public readonly array $settings,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/EventSettings/DTO/PlatformFeePreviewResponseDTO.php b/backend/app/Services/Application/Handlers/EventSettings/DTO/PlatformFeePreviewResponseDTO.php
index 5734673da0..01de620d6e 100644
--- a/backend/app/Services/Application/Handlers/EventSettings/DTO/PlatformFeePreviewResponseDTO.php
+++ b/backend/app/Services/Application/Handlers/EventSettings/DTO/PlatformFeePreviewResponseDTO.php
@@ -7,14 +7,13 @@
class PlatformFeePreviewResponseDTO extends BaseDataObject
{
public function __construct(
- public readonly string $eventCurrency,
+ public readonly string $eventCurrency,
public readonly ?string $feeCurrency,
- public readonly float $fixedFeeOriginal,
- public readonly float $fixedFeeConverted,
- public readonly float $percentageFee,
- public readonly float $samplePrice,
- public readonly float $platformFee,
- public readonly float $total,
- ) {
- }
+ public readonly float $fixedFeeOriginal,
+ public readonly float $fixedFeeConverted,
+ public readonly float $percentageFee,
+ public readonly float $samplePrice,
+ public readonly float $platformFee,
+ public readonly float $total,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php
index 6d3c3864f1..3ed2ef57fb 100644
--- a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php
+++ b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php
@@ -2,7 +2,6 @@
namespace HiEvents\Services\Application\Handlers\EventSettings\DTO;
-use HiEvents\DataTransferObjects\AddressDTO;
use HiEvents\DataTransferObjects\BaseDTO;
use HiEvents\DomainObjects\Enums\AttendeeDetailsCollectionMethod;
use HiEvents\DomainObjects\Enums\HomepageBackgroundType;
@@ -13,90 +12,81 @@
class UpdateEventSettingsDTO extends BaseDTO
{
public function __construct(
- public readonly int $account_id,
+ public readonly int $account_id,
// event settings
- public readonly int $event_id,
- public readonly ?string $post_checkout_message,
- public readonly ?string $pre_checkout_message,
- public readonly ?string $email_footer_message,
- public readonly ?string $continue_button_text,
- public readonly ?string $support_email,
-
- public readonly ?string $homepage_background_color,
- public readonly ?string $homepage_primary_color,
- public readonly ?string $homepage_primary_text_color,
- public readonly ?string $homepage_secondary_color,
- public readonly ?string $homepage_secondary_text_color,
- public readonly ?string $homepage_body_background_color,
+ public readonly int $event_id,
+ public readonly ?string $post_checkout_message,
+ public readonly ?string $pre_checkout_message,
+ public readonly ?string $email_footer_message,
+ public readonly ?string $continue_button_text,
+ public readonly ?string $support_email,
+
+ public readonly ?string $homepage_background_color,
+ public readonly ?string $homepage_primary_color,
+ public readonly ?string $homepage_primary_text_color,
+ public readonly ?string $homepage_secondary_color,
+ public readonly ?string $homepage_secondary_text_color,
+ public readonly ?string $homepage_body_background_color,
public readonly ?HomepageBackgroundType $homepage_background_type,
- public readonly bool $require_attendee_details,
+ public readonly bool $require_attendee_details,
public readonly AttendeeDetailsCollectionMethod $attendee_details_collection_method,
- public readonly int $order_timeout_in_minutes,
- public readonly ?string $website_url,
- public readonly ?string $maps_url,
- public readonly ?string $seo_title,
- public readonly ?string $seo_description,
- public readonly ?string $seo_keywords,
+ public readonly int $order_timeout_in_minutes,
+ public readonly ?string $website_url,
+ public readonly ?string $maps_url,
+ public readonly ?string $seo_title,
+ public readonly ?string $seo_description,
+ public readonly ?string $seo_keywords,
- public readonly ?AddressDTO $location_details = null,
- public readonly bool $is_online_event = false,
- public readonly ?string $online_event_connection_details = null,
+ public readonly ?bool $allow_search_engine_indexing = true,
- public readonly ?bool $allow_search_engine_indexing = true,
+ public readonly ?bool $notify_organizer_of_new_orders = null,
- public readonly ?bool $notify_organizer_of_new_orders = null,
-
- public readonly ?PriceDisplayMode $price_display_mode = PriceDisplayMode::INCLUSIVE,
-
- public readonly ?bool $hide_getting_started_page = false,
+ public readonly ?PriceDisplayMode $price_display_mode = PriceDisplayMode::INCLUSIVE,
// Payment settings
- public readonly array $payment_providers = [],
- public readonly ?string $offline_payment_instructions = null,
- public readonly bool $allow_orders_awaiting_offline_payment_to_check_in = false,
+ public readonly array $payment_providers = [],
+ public readonly ?string $offline_payment_instructions = null,
+ public readonly bool $allow_orders_awaiting_offline_payment_to_check_in = false,
// Invoice settings
- public readonly bool $enable_invoicing = false,
- public readonly ?string $invoice_label = null,
- public readonly ?string $invoice_prefix = null,
- public readonly ?int $invoice_start_number = null,
- public readonly bool $require_billing_address = true,
- public readonly ?string $organization_name = null,
- public readonly ?string $organization_address = null,
- public readonly ?string $invoice_tax_details = null,
- public readonly ?string $invoice_notes = null,
- public readonly ?int $invoice_payment_terms_days = null,
+ public readonly bool $enable_invoicing = false,
+ public readonly ?string $invoice_label = null,
+ public readonly ?string $invoice_prefix = null,
+ public readonly ?int $invoice_start_number = null,
+ public readonly bool $require_billing_address = true,
+ public readonly ?string $organization_name = null,
+ public readonly ?string $organization_address = null,
+ public readonly ?string $invoice_tax_details = null,
+ public readonly ?string $invoice_notes = null,
+ public readonly ?int $invoice_payment_terms_days = null,
// Ticket design settings
- public readonly ?array $ticket_design_settings = null,
+ public readonly ?array $ticket_design_settings = null,
// Marketing settings
- public readonly bool $show_marketing_opt_in = true,
+ public readonly bool $show_marketing_opt_in = true,
// Platform fee settings
- public readonly bool $pass_platform_fee_to_buyer = false,
+ public readonly bool $pass_platform_fee_to_buyer = false,
// Homepage theme settings
- public readonly ?array $homepage_theme_settings = null,
+ public readonly ?array $homepage_theme_settings = null,
// Self-service settings
- public readonly bool $allow_attendee_self_edit = false,
+ public readonly bool $allow_attendee_self_edit = false,
// Waitlist settings
- public readonly ?bool $waitlist_auto_process = null,
- public readonly ?int $waitlist_offer_timeout_minutes = null,
- )
- {
- }
+ public readonly ?bool $waitlist_auto_process = null,
+ public readonly ?int $waitlist_offer_timeout_minutes = null,
+ ) {}
public static function createWithDefaults(
- int $account_id,
- int $event_id,
+ int $account_id,
+ int $event_id,
OrganizerDomainObject $organizer,
- ): self
- {
+ ): self {
return new self(
account_id: $account_id,
event_id: $event_id,
@@ -120,13 +110,9 @@ public static function createWithDefaults(
seo_title: null,
seo_description: null,
seo_keywords: null,
- location_details: null,
- is_online_event: false,
- online_event_connection_details: null,
allow_search_engine_indexing: true,
notify_organizer_of_new_orders: null,
price_display_mode: PriceDisplayMode::INCLUSIVE,
- hide_getting_started_page: false,
// Payment defaults
payment_providers: [PaymentProviders::STRIPE->value],
@@ -172,4 +158,3 @@ public static function createWithDefaults(
);
}
}
-
diff --git a/backend/app/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandler.php b/backend/app/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandler.php
index d741e3e764..7286a9d2e9 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,26 +15,28 @@
class GetPlatformFeePreviewHandler
{
public function __construct(
- private readonly AccountRepositoryInterface $accountRepository,
- private readonly EventRepositoryInterface $eventRepository,
+ private readonly EventRepositoryInterface $eventRepository,
private readonly CurrencyConversionClientInterface $currencyConversionClient,
- )
- {
- }
+ ) {}
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/EventSettings/PartialUpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php
index cbad542a81..65b3d6dfad 100644
--- a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php
+++ b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php
@@ -2,7 +2,6 @@
namespace HiEvents\Services\Application\Handlers\EventSettings;
-use HiEvents\DataTransferObjects\AddressDTO;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\Exceptions\RefundNotPossibleException;
use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface;
@@ -13,11 +12,9 @@
class PartialUpdateEventSettingsHandler
{
public function __construct(
- private readonly UpdateEventSettingsHandler $eventSettingsHandler,
+ private readonly UpdateEventSettingsHandler $eventSettingsHandler,
private readonly EventSettingsRepositoryInterface $eventSettingsRepository,
- )
- {
- }
+ ) {}
/**
* @throws Throwable
@@ -28,17 +25,10 @@ public function handle(PartialUpdateEventSettingsDTO $eventSettingsDTO): EventSe
'event_id' => $eventSettingsDTO->event_id,
]);
- if (!$existingSettings) {
+ if (! $existingSettings) {
throw new RefundNotPossibleException('Event settings not found');
}
- $locationDetails = AddressDTO::from($eventSettingsDTO->settings['location_details'] ?? $existingSettings->getLocationDetails());
- $isOnlineEvent = $eventSettingsDTO->settings['is_online_event'] ?? $existingSettings->getIsOnlineEvent();
-
- if ($isOnlineEvent) {
- $locationDetails = null;
- }
-
return $this->eventSettingsHandler->handle(
UpdateEventSettingsDTO::fromArray([
'event_id' => $eventSettingsDTO->event_id,
@@ -70,11 +60,6 @@ public function handle(PartialUpdateEventSettingsDTO $eventSettingsDTO): EventSe
'maps_url' => array_key_exists('maps_url', $eventSettingsDTO->settings)
? $eventSettingsDTO->settings['maps_url']
: $existingSettings->getMapsUrl(),
- 'location_details' => $locationDetails,
- 'is_online_event' => $eventSettingsDTO->settings['is_online_event'] ?? $existingSettings->getIsOnlineEvent(),
- 'online_event_connection_details' => array_key_exists('online_event_connection_details', $eventSettingsDTO->settings)
- ? $eventSettingsDTO->settings['online_event_connection_details']
- : $existingSettings->getOnlineEventConnectionDetails(),
'seo_title' => $eventSettingsDTO->settings['seo_title'] ?? $existingSettings->getSeoTitle(),
'seo_description' => $eventSettingsDTO->settings['seo_description'] ?? $existingSettings->getSeoDescription(),
@@ -83,7 +68,6 @@ public function handle(PartialUpdateEventSettingsDTO $eventSettingsDTO): EventSe
'notify_organizer_of_new_orders' => $eventSettingsDTO->settings['notify_organizer_of_new_orders'] ?? $existingSettings->getNotifyOrganizerOfNewOrders(),
'price_display_mode' => $eventSettingsDTO->settings['price_display_mode'] ?? $existingSettings->getPriceDisplayMode(),
- 'hide_getting_started_page' => $eventSettingsDTO->settings['hide_getting_started_page'] ?? $existingSettings->getHideGettingStartedPage(),
// Payment settings
'payment_providers' => $eventSettingsDTO->settings['payment_providers'] ?? $existingSettings->getPaymentProviders(),
diff --git a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php
index de98ee5862..a382126d27 100644
--- a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php
+++ b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php
@@ -15,11 +15,9 @@ class UpdateEventSettingsHandler
{
public function __construct(
private readonly EventSettingsRepositoryInterface $eventSettingsRepository,
- private readonly HtmlPurifierService $purifier,
- private readonly DatabaseManager $databaseManager,
- )
- {
- }
+ private readonly HtmlPurifierService $purifier,
+ private readonly DatabaseManager $databaseManager,
+ ) {}
/**
* @throws Throwable
@@ -54,9 +52,6 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje
'order_timeout_in_minutes' => $settings->order_timeout_in_minutes,
'website_url' => trim($settings->website_url),
'maps_url' => trim($settings->maps_url),
- 'location_details' => $settings->location_details?->toArray(),
- 'is_online_event' => $settings->is_online_event,
- 'online_event_connection_details' => $this->purifier->purify($settings->online_event_connection_details),
'seo_title' => $settings->seo_title,
'seo_description' => $settings->seo_description,
@@ -64,7 +59,6 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje
'allow_search_engine_indexing' => $settings->allow_search_engine_indexing,
'notify_organizer_of_new_orders' => $settings->notify_organizer_of_new_orders,
'price_display_mode' => $settings->price_display_mode->name,
- 'hide_getting_started_page' => $settings->hide_getting_started_page,
// Payment settings
'payment_providers' => $settings->payment_providers,
@@ -113,7 +107,7 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje
]);
});
- if ($settings->waitlist_auto_process && !$wasAutoProcessEnabled) {
+ if ($settings->waitlist_auto_process && ! $wasAutoProcessEnabled) {
event(new CapacityChangedEvent(
eventId: $settings->event_id,
direction: CapacityChangeDirection::INCREASED,
diff --git a/backend/app/Services/Application/Handlers/Location/CreateLocationHandler.php b/backend/app/Services/Application/Handlers/Location/CreateLocationHandler.php
new file mode 100644
index 0000000000..e0b4b02c8d
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Location/CreateLocationHandler.php
@@ -0,0 +1,61 @@
+databaseManager->transaction(function () use ($dto) {
+ if ($dto->provider !== null && $dto->provider_place_id !== null) {
+ $existing = $this->locationRepository->findFirstWhere([
+ LocationDomainObjectAbstract::ORGANIZER_ID => $dto->organizer_id,
+ LocationDomainObjectAbstract::ACCOUNT_ID => $dto->account_id,
+ LocationDomainObjectAbstract::PROVIDER => $dto->provider,
+ LocationDomainObjectAbstract::PROVIDER_PLACE_ID => $dto->provider_place_id,
+ ]);
+
+ // Reuse the saved row as-is. Mutating it here would silently
+ // rename or move locations already linked from other events or
+ // an organizer's saved address. Edits must go through
+ // UpdateLocationHandler with an explicit location ID.
+ if ($existing !== null) {
+ return $existing;
+ }
+ }
+
+ return $this->locationRepository->create([
+ LocationDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::LOCATION_PREFIX),
+ LocationDomainObjectAbstract::ACCOUNT_ID => $dto->account_id,
+ LocationDomainObjectAbstract::ORGANIZER_ID => $dto->organizer_id,
+ LocationDomainObjectAbstract::NAME => $this->sanitizer->sanitizeText($dto->name),
+ LocationDomainObjectAbstract::STRUCTURED_ADDRESS => $this->sanitizer->sanitizeAddress($dto->structured_address->toArray()),
+ LocationDomainObjectAbstract::LATITUDE => $dto->latitude,
+ LocationDomainObjectAbstract::LONGITUDE => $dto->longitude,
+ LocationDomainObjectAbstract::PROVIDER => $dto->provider,
+ LocationDomainObjectAbstract::PROVIDER_PLACE_ID => $dto->provider_place_id,
+ LocationDomainObjectAbstract::RAW_PROVIDER_RESPONSE => $this->sanitizer->fetchRawProviderResponse($dto->provider, $dto->provider_place_id),
+ ]);
+ });
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/Location/DTO/UpsertLocationDTO.php b/backend/app/Services/Application/Handlers/Location/DTO/UpsertLocationDTO.php
new file mode 100644
index 0000000000..a80322c6d8
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Location/DTO/UpsertLocationDTO.php
@@ -0,0 +1,22 @@
+databaseManager->transaction(function () use ($organizerId, $accountId, $locationId) {
+ $location = $this->locationRepository->findFirstWhere([
+ LocationDomainObjectAbstract::ID => $locationId,
+ LocationDomainObjectAbstract::ORGANIZER_ID => $organizerId,
+ LocationDomainObjectAbstract::ACCOUNT_ID => $accountId,
+ ]);
+
+ if ($location === null) {
+ throw new ResourceNotFoundException(__('Location not found'));
+ }
+
+ if ($this->locationRepository->isReferenced($locationId)) {
+ throw new ResourceConflictException(
+ __('This location is referenced by one or more events or occurrences and cannot be deleted')
+ );
+ }
+
+ $this->locationRepository->deleteWhere([
+ LocationDomainObjectAbstract::ID => $locationId,
+ ]);
+ });
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/Location/GeoAutocompleteHandler.php b/backend/app/Services/Application/Handlers/Location/GeoAutocompleteHandler.php
new file mode 100644
index 0000000000..2cea1a683b
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Location/GeoAutocompleteHandler.php
@@ -0,0 +1,23 @@
+
+ */
+ public function handle(string $query, ?string $locale = null, ?string $country = null): array
+ {
+ return $this->geoProvider->autocomplete($query, $locale, $country);
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/Location/GeoPlaceDetailsHandler.php b/backend/app/Services/Application/Handlers/Location/GeoPlaceDetailsHandler.php
new file mode 100644
index 0000000000..2d63ccf474
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Location/GeoPlaceDetailsHandler.php
@@ -0,0 +1,20 @@
+geoProvider->getPlaceDetails($providerPlaceId, $locale);
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/Location/GetLocationsHandler.php b/backend/app/Services/Application/Handlers/Location/GetLocationsHandler.php
new file mode 100644
index 0000000000..e4e3f4db21
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Location/GetLocationsHandler.php
@@ -0,0 +1,21 @@
+locationRepository->findByOrganizerId($organizerId, $accountId, $params);
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/Location/UpdateLocationHandler.php b/backend/app/Services/Application/Handlers/Location/UpdateLocationHandler.php
new file mode 100644
index 0000000000..fae77e12ce
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Location/UpdateLocationHandler.php
@@ -0,0 +1,51 @@
+databaseManager->transaction(function () use ($locationId, $dto) {
+ $location = $this->locationRepository->findFirstWhere([
+ LocationDomainObjectAbstract::ID => $locationId,
+ LocationDomainObjectAbstract::ORGANIZER_ID => $dto->organizer_id,
+ LocationDomainObjectAbstract::ACCOUNT_ID => $dto->account_id,
+ ]);
+
+ if ($location === null) {
+ throw new ResourceNotFoundException(__('Location not found'));
+ }
+
+ return $this->locationRepository->updateFromArray($location->getId(), [
+ LocationDomainObjectAbstract::NAME => $this->sanitizer->sanitizeText($dto->name),
+ LocationDomainObjectAbstract::STRUCTURED_ADDRESS => $this->sanitizer->sanitizeAddress($dto->structured_address->toArray()),
+ LocationDomainObjectAbstract::LATITUDE => $dto->latitude,
+ LocationDomainObjectAbstract::LONGITUDE => $dto->longitude,
+ LocationDomainObjectAbstract::PROVIDER => $dto->provider,
+ LocationDomainObjectAbstract::PROVIDER_PLACE_ID => $dto->provider_place_id,
+ LocationDomainObjectAbstract::RAW_PROVIDER_RESPONSE => $this->sanitizer->fetchRawProviderResponse($dto->provider, $dto->provider_place_id),
+ ]);
+ });
+ }
+}
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/DTO/GetOrganizerStatsRequestDTO.php b/backend/app/Services/Application/Handlers/Organizer/DTO/GetOrganizerStatsRequestDTO.php
index 301ea04c99..ff712f27cd 100644
--- a/backend/app/Services/Application/Handlers/Organizer/DTO/GetOrganizerStatsRequestDTO.php
+++ b/backend/app/Services/Application/Handlers/Organizer/DTO/GetOrganizerStatsRequestDTO.php
@@ -7,8 +7,9 @@ class GetOrganizerStatsRequestDTO
public function __construct(
public readonly int $organizerId,
public readonly int $accountId,
- public ?string $currencyCode = null,
- )
- {
- }
+ public ?string $currencyCode = null,
+ public ?string $startDate = null,
+ public ?string $endDate = null,
+ public string $dateRangePreset = 'month',
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php b/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php
index 9bc1c94447..c28bbf80e6 100644
--- a/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php
+++ b/backend/app/Services/Application/Handlers/Organizer/DTO/PartialUpdateOrganizerSettingsDTO.php
@@ -2,7 +2,6 @@
namespace HiEvents\Services\Application\Handlers\Organizer\DTO;
-use HiEvents\DataTransferObjects\AddressDTO;
use HiEvents\DataTransferObjects\BaseDataObject;
use HiEvents\DomainObjects\Enums\AttendeeDetailsCollectionMethod;
use HiEvents\DomainObjects\Enums\OrganizerHomepageVisibility;
@@ -52,9 +51,6 @@ public function __construct(
// Website
public readonly string|Optional|null $websiteUrl,
- // Location details
- public readonly AddressDTO|Optional|null $locationDetails,
-
// Homepage settings
public readonly OrganizerHomepageVisibility|Optional|null $homepageVisibility,
diff --git a/backend/app/Services/Application/Handlers/Organizer/DTO/UpdateOrganizerLocationDTO.php b/backend/app/Services/Application/Handlers/Organizer/DTO/UpdateOrganizerLocationDTO.php
new file mode 100644
index 0000000000..7a9035e0a3
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Organizer/DTO/UpdateOrganizerLocationDTO.php
@@ -0,0 +1,16 @@
+eventRepository
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]))
+ ->loadRelation(new Relationship(domainObject: EventOccurrenceDomainObject::class, nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]),
+ ]))
->loadRelation(new Relationship(ImageDomainObject::class))
->loadRelation(new Relationship(EventSettingDomainObject::class))
->loadRelation(new Relationship(
diff --git a/backend/app/Services/Application/Handlers/Organizer/GetOrganizerStatsHandler.php b/backend/app/Services/Application/Handlers/Organizer/GetOrganizerStatsHandler.php
index 7f29ac06a0..f60c892ccd 100644
--- a/backend/app/Services/Application/Handlers/Organizer/GetOrganizerStatsHandler.php
+++ b/backend/app/Services/Application/Handlers/Organizer/GetOrganizerStatsHandler.php
@@ -2,6 +2,7 @@
namespace HiEvents\Services\Application\Handlers\Organizer;
+use Carbon\Carbon;
use HiEvents\Repository\DTO\Organizer\OrganizerStatsResponseDTO;
use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface;
use HiEvents\Services\Application\Handlers\Organizer\DTO\GetOrganizerStatsRequestDTO;
@@ -9,9 +10,7 @@
class GetOrganizerStatsHandler
{
- public function __construct(private readonly OrganizerRepositoryInterface $repository)
- {
- }
+ public function __construct(private readonly OrganizerRepositoryInterface $repository) {}
public function handle(GetOrganizerStatsRequestDTO $statsRequestDTO): OrganizerStatsResponseDTO
{
@@ -24,10 +23,42 @@ public function handle(GetOrganizerStatsRequestDTO $statsRequestDTO): OrganizerS
throw new ResourceNotFoundException('Organizer not found');
}
+ [$startDate, $endDate] = $this->resolveDateRange(
+ $statsRequestDTO->startDate,
+ $statsRequestDTO->endDate,
+ $statsRequestDTO->dateRangePreset,
+ );
+
return $this->repository->getOrganizerStats(
organizerId: $statsRequestDTO->organizerId,
accountId: $statsRequestDTO->accountId,
currencyCode: $statsRequestDTO->currencyCode ?? $organizer->getCurrency(),
+ startDate: $startDate,
+ endDate: $endDate,
);
}
+
+ /**
+ * @return array{0: string, 1: string}
+ */
+ private function resolveDateRange(?string $startDate, ?string $endDate, string $preset): array
+ {
+ if ($startDate !== null && $endDate !== null) {
+ return [$startDate, $endDate];
+ }
+
+ $end = Carbon::now();
+
+ $start = match ($preset) {
+ 'week' => (clone $end)->subDays(7),
+ 'quarter' => (clone $end)->subDays(90),
+ 'year' => (clone $end)->subDays(365),
+ default => (clone $end)->subDays(30),
+ };
+
+ return [
+ $start->format('Y-m-d H:i:s'),
+ $end->format('Y-m-d H:i:s'),
+ ];
+ }
}
diff --git a/backend/app/Services/Application/Handlers/Organizer/GetPublicOrganizerHandler.php b/backend/app/Services/Application/Handlers/Organizer/GetPublicOrganizerHandler.php
index 577796b904..0b07c4c145 100644
--- a/backend/app/Services/Application/Handlers/Organizer/GetPublicOrganizerHandler.php
+++ b/backend/app/Services/Application/Handlers/Organizer/GetPublicOrganizerHandler.php
@@ -3,7 +3,9 @@
namespace HiEvents\Services\Application\Handlers\Organizer;
use HiEvents\DomainObjects\ImageDomainObject;
+use HiEvents\DomainObjects\LocationDomainObject;
use HiEvents\DomainObjects\OrganizerSettingDomainObject;
+use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface;
class GetPublicOrganizerHandler
@@ -19,6 +21,7 @@ public function handle(int $organizerId)
return $this->organizerRepository
->loadRelation(ImageDomainObject::class)
->loadRelation(OrganizerSettingDomainObject::class)
+ ->loadRelation(new Relationship(LocationDomainObject::class, name: 'location_record'))
->findById($organizerId);
}
}
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/Settings/PartialUpdateOrganizerSettingsHandler.php b/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php
index cc97506a71..d38d1cce52 100644
--- a/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php
+++ b/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php
@@ -7,7 +7,6 @@
use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface;
use HiEvents\Repository\Interfaces\OrganizerSettingsRepositoryInterface;
use HiEvents\Services\Application\Handlers\Organizer\DTO\PartialUpdateOrganizerSettingsDTO;
-use Spatie\LaravelData\Data;
class PartialUpdateOrganizerSettingsHandler
{
@@ -31,16 +30,6 @@ public function handle(PartialUpdateOrganizerSettingsDTO $dto): OrganizerSetting
'organizer_id' => $organizer->getId(),
]);
- $locationDetails = $dto->getProvided('locationDetails', $organizerSettings->getLocationDetails());
-
- if ($locationDetails instanceof Data) {
- $locationDetails = $locationDetails->toArray();
- } elseif (is_array($locationDetails)) {
- $locationDetails = array_filter($locationDetails);
- } else {
- $locationDetails = [];
- }
-
$this->organizerSettingsRepository->updateWhere([
'default_attendee_details_collection_method' => $dto->getProvided(
'defaultAttendeeDetailsCollectionMethod',
@@ -83,8 +72,6 @@ public function handle(PartialUpdateOrganizerSettingsDTO $dto): OrganizerSetting
'website_url' => $dto->getProvided('websiteUrl', $organizerSettings->getWebsiteUrl()),
- 'location_details' => $locationDetails,
-
'homepage_visibility' => $dto->getProvided('homepageVisibility', $organizerSettings->getHomepageVisibility()),
'homepage_theme_settings' => $dto->getProvided('homepageThemeSettings', $organizerSettings->getHomepageThemeSettings()),
diff --git a/backend/app/Services/Application/Handlers/Organizer/UpdateOrganizerLocationHandler.php b/backend/app/Services/Application/Handlers/Organizer/UpdateOrganizerLocationHandler.php
new file mode 100644
index 0000000000..1436c6056b
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Organizer/UpdateOrganizerLocationHandler.php
@@ -0,0 +1,52 @@
+organizerRepository->findFirstWhere([
+ 'id' => $dto->organizer_id,
+ 'account_id' => $dto->account_id,
+ ]);
+
+ if ($existing === null) {
+ throw new ResourceNotFoundException(
+ __('Organizer :id not found', ['id' => $dto->organizer_id]),
+ );
+ }
+
+ $this->locationOwnershipValidator->assertOwnedBy(
+ $dto->location_id,
+ $dto->organizer_id,
+ $dto->account_id,
+ );
+
+ $this->organizerRepository->updateWhere(
+ attributes: ['location_id' => $dto->location_id],
+ where: [
+ 'id' => $dto->organizer_id,
+ 'account_id' => $dto->account_id,
+ ],
+ );
+
+ return $this->organizerRepository->findFirstWhere([
+ 'id' => $dto->organizer_id,
+ 'account_id' => $dto->account_id,
+ ]);
+ }
+}
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..e561f50bbd 100644
--- a/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php
+++ b/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php
@@ -4,8 +4,11 @@
use Carbon\Carbon;
use HiEvents\DomainObjects\AttendeeDomainObject;
+use HiEvents\DomainObjects\Enums\LocationType;
use HiEvents\DomainObjects\Enums\PaymentProviders;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventLocationDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
@@ -19,31 +22,43 @@
class EmailTokenContextBuilder
{
+ /**
+ * Callers must hydrate `$event->event_location` (with nested `location`) and
+ * `$occurrence->event_location` before invoking these builders. Reads happen
+ * directly off the domain objects with no lazy-load fallback — invoking from
+ * a tight loop without preloads will N+1.
+ */
public function buildOrderConfirmationContext(
- OrderDomainObject $order,
- EventDomainObject $event,
- OrganizerDomainObject $organizer,
- EventSettingDomainObject $eventSettings
- ): array
- {
- $eventStartDate = new Carbon(DateHelper::convertFromUTC($event->getStartDate(), $event->getTimezone()));
- $eventEndDate = $event->getEndDate() ? new Carbon(DateHelper::convertFromUTC($event->getEndDate(), $event->getTimezone())) : null;
+ OrderDomainObject $order,
+ EventDomainObject $event,
+ OrganizerDomainObject $organizer,
+ EventSettingDomainObject $eventSettings,
+ ?EventOccurrenceDomainObject $occurrence = null,
+ ): array {
+ $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;
+
+ $eventLocation = $occurrence?->getEventLocation() ?? $event->getEventLocation();
+ $structuredAddress = $this->extractStructuredAddress($eventLocation);
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()) : '',
- 'location_details' => $eventSettings->getLocationDetails(),
+ 'full_address' => $structuredAddress ? AddressHelper::formatAddress($structuredAddress) : '',
+ 'location_details' => $structuredAddress,
'description' => $event->getDescription() ?? '',
'timezone' => $event->getTimezone(),
],
- // Order object
+ 'event_location' => $this->buildLocationContext($eventLocation, $structuredAddress),
+
'order' => [
'url' => sprintf(
Url::getFrontEndUrlFromConfig(Url::ORDER_SUMMARY),
@@ -53,8 +68,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,40 +77,45 @@ 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() ?? '',
+ ],
];
}
public function buildAttendeeTicketContext(
- AttendeeDomainObject $attendee,
- OrderDomainObject $order,
- EventDomainObject $event,
- OrganizerDomainObject $organizer,
- EventSettingDomainObject $eventSettings
- ): array
- {
- $baseContext = $this->buildOrderConfirmationContext($order, $event, $organizer, $eventSettings);
+ AttendeeDomainObject $attendee,
+ OrderDomainObject $order,
+ EventDomainObject $event,
+ OrganizerDomainObject $organizer,
+ EventSettingDomainObject $eventSettings,
+ ?EventOccurrenceDomainObject $occurrence = null,
+ ): array {
+ $baseContext = $this->buildOrderConfirmationContext($order, $event, $organizer, $eventSettings, $occurrence);
/** @var OrderItemDomainObject $orderItem */
- $orderItem = $order->getOrderItems()->first(fn(OrderItemDomainObject $item) => $item->getProductPriceId() === $attendee->getProductPriceId());
+ $orderItem = $order->getOrderItems()->first(fn (OrderItemDomainObject $item) => $item->getProductPriceId() === $attendee->getProductPriceId());
$ticketPrice = Currency::format($orderItem?->getPrice() ?? 0, $event->getCurrency());
$ticketName = $orderItem?->getItemName();
- // Add attendee and ticket objects
$baseContext['attendee'] = [
- 'name' => $attendee->getFirstName() . ' ' . $attendee->getLastName(),
+ 'name' => $attendee->getFirstName().' '.$attendee->getLastName(),
'email' => $attendee->getEmail() ?? '',
];
@@ -112,6 +132,65 @@ 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;
+
+ $eventLocation = $occurrence->getEventLocation() ?? $event->getEventLocation();
+ $structuredAddress = $this->extractStructuredAddress($eventLocation);
+
+ 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' => $structuredAddress ? AddressHelper::formatAddress($structuredAddress) : '',
+ 'location_details' => $structuredAddress,
+ 'description' => $event->getDescription() ?? '',
+ 'timezone' => $event->getTimezone(),
+ 'url' => sprintf(
+ Url::getFrontEndUrlFromConfig(Url::EVENT_HOMEPAGE),
+ $event->getId(),
+ $event->getSlug(),
+ ),
+ ],
+
+ 'event_location' => $this->buildLocationContext($eventLocation, $structuredAddress),
+
+ '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 = [
@@ -132,7 +211,7 @@ public function buildPreviewContext(string $templateType): array
'state_or_region' => 'Dublin 1',
'zip_or_postal_code' => 'D01 T0X4',
'country' => 'IE',
- ]
+ ],
],
'order' => [
'url' => 'https://example.com/order/ABC123',
@@ -145,7 +224,7 @@ public function buildPreviewContext(string $templateType): array
'is_awaiting_offline_payment' => false,
'is_offline_payment' => false,
'locale' => Locale::EN->value,
- 'currency' => 'USD'
+ 'currency' => 'USD',
],
'organizer' => [
'name' => 'ACME Events Inc.',
@@ -158,6 +237,26 @@ 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',
+ ];
+
+ $baseContext['event_location'] = [
+ 'type' => LocationType::IN_PERSON->name,
+ 'is_online' => false,
+ 'online_connection_details' => null,
+ 'name' => '3 Arena',
+ 'label' => null,
+ 'formatted_address' => __('3 Arena, North Wall Quay, Dublin 1, Ireland'),
+ 'latitude' => 53.3478,
+ 'longitude' => -6.2289,
+ 'structured_address' => $baseContext['event']['location_details'],
+ ];
+
if ($templateType === 'attendee_ticket') {
$baseContext['attendee'] = [
'name' => 'John Smith',
@@ -170,6 +269,50 @@ 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;
}
+
+ private function extractStructuredAddress(?EventLocationDomainObject $eventLocation): ?array
+ {
+ if ($eventLocation === null) {
+ return null;
+ }
+
+ if ($eventLocation->getType() !== LocationType::IN_PERSON->name) {
+ return null;
+ }
+
+ $location = $eventLocation->getLocation();
+ if ($location === null) {
+ return null;
+ }
+
+ $address = $location->getStructuredAddress();
+
+ return is_array($address) ? $address : null;
+ }
+
+ private function buildLocationContext(?EventLocationDomainObject $eventLocation, ?array $structuredAddress): array
+ {
+ $type = $eventLocation?->getType();
+ $location = $eventLocation?->getLocation();
+
+ return [
+ 'type' => $type,
+ 'is_online' => $type === LocationType::ONLINE->name,
+ 'online_connection_details' => $eventLocation?->getOnlineEventConnectionDetails(),
+ 'name' => $location?->getName(),
+ 'formatted_address' => $structuredAddress ? AddressHelper::formatAddress($structuredAddress) : '',
+ 'latitude' => $location?->getLatitude(),
+ 'longitude' => $location?->getLongitude(),
+ 'structured_address' => $structuredAddress,
+ ];
+ }
}
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..187e1b5366 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,26 +115,37 @@ 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(),
'category' => $eventData->getCategory(),
- 'location_details' => $eventData->getLocationDetails(),
'account_id' => $eventData->getAccountId(),
'user_id' => $eventData->getUserId(),
'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 +163,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 +193,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 +251,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..42aa522031 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,70 @@ 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(eods.products_sold), 0) AS total_products_sold,
+ COALESCE(SUM(eods.orders_created), 0) AS total_orders,
+ COALESCE(SUM(eods.sales_total_gross), 0) AS total_gross_sales,
+ COALESCE(SUM(eods.total_tax), 0) AS total_tax,
+ COALESCE(SUM(eods.total_fee), 0) AS total_fees,
+ 0 AS total_views,
+ COALESCE(SUM(eods.total_refunded), 0) AS total_refunded,
+ COALESCE(SUM(eods.attendees_registered), 0) AS attendees_registered
+ FROM event_occurrence_daily_statistics eods
+ WHERE eods.event_occurrence_id = :occurrenceId
+ AND eods.event_id = :eventId
+ AND eods.deleted_at IS NULL
+ AND eods.date >= :startDate::date
+ AND eods.date <= :endDate::date;
+ SQL;
+ $totalsResult = $this->db->selectOne($totalsQuery, [
+ 'occurrenceId' => $occurrenceId,
+ 'eventId' => $eventId,
+ 'startDate' => $requestData->start_date,
+ 'endDate' => $requestData->end_date,
+ ]);
+ } else {
+ $totalsQuery = <<<'SQL'
+ SELECT
+ COALESCE(SUM(eods.products_sold), 0) AS total_products_sold,
+ COALESCE(SUM(eods.orders_created), 0) AS total_orders,
+ COALESCE(SUM(eods.sales_total_gross), 0) AS total_gross_sales,
+ COALESCE(SUM(eods.total_tax), 0) AS total_tax,
+ COALESCE(SUM(eods.total_fee), 0) AS total_fees,
+ COALESCE((
+ SELECT SUM(eds.total_views)
+ FROM event_daily_statistics eds
+ WHERE eds.event_id = :eventIdViews
+ AND eds.deleted_at IS NULL
+ AND eds.date >= :startDateViews::date
+ AND eds.date <= :endDateViews::date
+ ), 0) AS total_views,
+ COALESCE(SUM(eods.total_refunded), 0) AS total_refunded,
+ COALESCE(SUM(eods.attendees_registered), 0) AS attendees_registered
+ FROM event_occurrence_daily_statistics eods
+ WHERE eods.event_id = :eventId
+ AND eods.deleted_at IS NULL
+ AND eods.date >= :startDate::date
+ AND eods.date <= :endDate::date;
+ SQL;
+ $totalsResult = $this->db->selectOne($totalsQuery, [
+ 'eventId' => $eventId,
+ 'eventIdViews' => $eventId,
+ 'startDate' => $requestData->start_date,
+ 'endDate' => $requestData->end_date,
+ 'startDateViews' => $requestData->start_date,
+ 'endDateViews' => $requestData->end_date,
+ ]);
+ }
- // 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 +112,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 +202,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 +217,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/EventLocation/EventLocationCleaner.php b/backend/app/Services/Domain/EventLocation/EventLocationCleaner.php
new file mode 100644
index 0000000000..69f46c2346
--- /dev/null
+++ b/backend/app/Services/Domain/EventLocation/EventLocationCleaner.php
@@ -0,0 +1,27 @@
+eventLocationRepository->isReferenced($eventLocationId)) {
+ return;
+ }
+
+ $this->eventLocationRepository->deleteById($eventLocationId);
+ }
+}
diff --git a/backend/app/Services/Domain/EventLocation/EventLocationData.php b/backend/app/Services/Domain/EventLocation/EventLocationData.php
new file mode 100644
index 0000000000..252349eb55
--- /dev/null
+++ b/backend/app/Services/Domain/EventLocation/EventLocationData.php
@@ -0,0 +1,26 @@
+assertOwnership($eventId, $accountId, $data->location_id);
+
+ return $this->eventLocationRepository->create([
+ EventLocationDomainObjectAbstract::EVENT_ID => $eventId,
+ EventLocationDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::EVENT_LOCATION_PREFIX),
+ EventLocationDomainObjectAbstract::TYPE => $data->type->name,
+ EventLocationDomainObjectAbstract::LOCATION_ID => $data->type === LocationType::IN_PERSON ? $data->location_id : null,
+ EventLocationDomainObjectAbstract::ONLINE_EVENT_CONNECTION_DETAILS => $this->resolveConnectionDetails($data),
+ ]);
+ }
+
+ /**
+ * @throws ResourceNotFoundException
+ */
+ public function updateInPlace(int $eventLocationId, int $eventId, int $accountId, EventLocationData $data): EventLocationDomainObject
+ {
+ $this->assertOwnership($eventId, $accountId, $data->location_id);
+
+ $existing = $this->eventLocationRepository->findFirstWhere([
+ EventLocationDomainObjectAbstract::ID => $eventLocationId,
+ EventLocationDomainObjectAbstract::EVENT_ID => $eventId,
+ ]);
+
+ if ($existing === null) {
+ throw new ResourceNotFoundException(
+ __('Event location :id not found for event :event', ['id' => $eventLocationId, 'event' => $eventId]),
+ );
+ }
+
+ return $this->eventLocationRepository->updateFromArray($eventLocationId, [
+ EventLocationDomainObjectAbstract::TYPE => $data->type->name,
+ EventLocationDomainObjectAbstract::LOCATION_ID => $data->type === LocationType::IN_PERSON ? $data->location_id : null,
+ EventLocationDomainObjectAbstract::ONLINE_EVENT_CONNECTION_DETAILS => $this->resolveConnectionDetails($data),
+ ]);
+ }
+
+ /**
+ * @throws ResourceNotFoundException
+ */
+ private function assertOwnership(int $eventId, int $accountId, ?int $locationId): void
+ {
+ $event = $this->eventRepository->findFirstWhere([
+ 'id' => $eventId,
+ 'account_id' => $accountId,
+ ]);
+
+ if ($event === null) {
+ throw new ResourceNotFoundException(__('Event :id not found', ['id' => $eventId]));
+ }
+
+ $this->locationOwnershipValidator->assertOwnedBy($locationId, $event->getOrganizerId(), $accountId);
+ }
+
+ private function resolveConnectionDetails(EventLocationData $data): ?string
+ {
+ if ($data->type !== LocationType::ONLINE) {
+ return null;
+ }
+
+ if ($data->online_event_connection_details === null) {
+ return null;
+ }
+
+ return $this->purifier->purify($data->online_event_connection_details);
+ }
+}
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/Location/LocationDataSanitizer.php b/backend/app/Services/Domain/Location/LocationDataSanitizer.php
new file mode 100644
index 0000000000..1b224c6522
--- /dev/null
+++ b/backend/app/Services/Domain/Location/LocationDataSanitizer.php
@@ -0,0 +1,69 @@
+purifier->purify($value));
+ }
+
+ public function sanitizeAddress(array $address): array
+ {
+ foreach ($address as $key => $value) {
+ if (is_string($value)) {
+ $address[$key] = $this->sanitizeText($value);
+ }
+ }
+
+ return $address;
+ }
+
+ /**
+ * Returns the provider's raw place response so we have a record of
+ * exactly what we saved this location from. Failures degrade to null
+ * — saving must not block on a flaky third party.
+ */
+ public function fetchRawProviderResponse(?string $provider, ?string $providerPlaceId): ?array
+ {
+ if ($provider === null || $providerPlaceId === null) {
+ return null;
+ }
+
+ try {
+ return $this->geoProvider->getPlaceDetails($providerPlaceId)?->raw_response;
+ } catch (GeoProviderException $e) {
+ $this->logger->warning('Geo provider lookup failed during location save; storing without raw response', [
+ 'provider' => $provider,
+ 'provider_place_id' => $providerPlaceId,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return null;
+ }
+ }
+}
diff --git a/backend/app/Services/Domain/Location/LocationOwnershipValidator.php b/backend/app/Services/Domain/Location/LocationOwnershipValidator.php
new file mode 100644
index 0000000000..5ee3a29b0c
--- /dev/null
+++ b/backend/app/Services/Domain/Location/LocationOwnershipValidator.php
@@ -0,0 +1,37 @@
+locationRepository->findFirstWhere([
+ 'id' => $locationId,
+ 'account_id' => $accountId,
+ 'organizer_id' => $organizerId,
+ ]);
+
+ if ($location === null) {
+ throw new ResourceNotFoundException(
+ __('Location :id not found', ['id' => $locationId]),
+ );
+ }
+ }
+}
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..0879c98836 100644
--- a/backend/app/Services/Domain/Mail/SendOrderDetailsService.php
+++ b/backend/app/Services/Domain/Mail/SendOrderDetailsService.php
@@ -4,13 +4,16 @@
use HiEvents\DomainObjects\AttendeeDomainObject;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventLocationDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\InvoiceDomainObject;
+use HiEvents\DomainObjects\LocationDomainObject;
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 +37,52 @@ 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,
+ nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]),
+ ],
+ name: 'event_occurrence',
+ ),
+ ],
+ ))
+ ->loadRelation(new Relationship(
+ domainObject: AttendeeDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]),
+ ],
+ 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(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]))
+ ->loadRelation(new Relationship(EventOccurrenceDomainObject::class, nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]),
+ ]))
->findById($order->getEventId());
if ($order->isOrderCompleted() || $order->isOrderAwaitingOfflinePayment()) {
@@ -63,11 +104,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 +117,8 @@ public function sendCustomerOrderSummary(
$event,
$eventSettings,
$organizer,
- $invoice
+ $invoice,
+ $occurrence ?? $this->resolvePrimaryOccurrence($order),
);
$this->mailer
@@ -84,6 +127,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..9590f8e982 100644
--- a/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php
+++ b/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php
@@ -3,16 +3,18 @@
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\EventLocationDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract;
use HiEvents\DomainObjects\InvoiceDomainObject;
+use HiEvents\DomainObjects\LocationDomainObject;
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 +26,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 +40,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 +59,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 +74,45 @@ public function markOrderAsPaid(
$event = $this->eventRepository
->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer'))
->loadRelation(new Relationship(EventSettingDomainObject::class))
+ ->loadRelation(new Relationship(domainObject: EventOccurrenceDomainObject::class, nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]),
+ ]))
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]))
->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,
+ nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]),
+ ],
+ name: 'event_occurrence',
+ ),
+ ],
+ ))
->findById($orderId);
// Update affiliate sales if this order has an affiliate
@@ -123,6 +151,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 +219,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..344c77ab4d 100644
--- a/backend/app/Services/Domain/SelfService/SelfServiceEditOrderService.php
+++ b/backend/app/Services/Domain/SelfService/SelfServiceEditOrderService.php
@@ -4,8 +4,11 @@
use HiEvents\DomainObjects\AttendeeDomainObject;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventLocationDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\InvoiceDomainObject;
+use HiEvents\DomainObjects\LocationDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
@@ -62,7 +65,7 @@ public function editOrder(
$emailChanged = true;
}
- if (!empty($updateData)) {
+ if (! empty($updateData)) {
$oldEmail = $order->getEmail();
if ($emailChanged) {
@@ -114,14 +117,48 @@ private function loadEventWithRelations(int $eventId): EventDomainObject
return $this->eventRepository
->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer'))
->loadRelation(new Relationship(EventSettingDomainObject::class))
+ ->loadRelation(new Relationship(domainObject: EventOccurrenceDomainObject::class, nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]),
+ ]))
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]))
->findById($eventId);
}
private function sendConfirmationToNewEmail(int $orderId, EventDomainObject $event): void
{
$order = $this->orderRepository
- ->loadRelation(OrderItemDomainObject::class)
- ->loadRelation(AttendeeDomainObject::class)
+ ->loadRelation(new Relationship(
+ domainObject: OrderItemDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]),
+ ],
+ name: 'event_occurrence',
+ ),
+ ],
+ ))
+ ->loadRelation(new Relationship(
+ domainObject: AttendeeDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]),
+ ],
+ name: 'event_occurrence',
+ ),
+ ],
+ ))
->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..a6b456260f 100644
--- a/backend/app/Services/Domain/SelfService/SelfServiceResendEmailService.php
+++ b/backend/app/Services/Domain/SelfService/SelfServiceResendEmailService.php
@@ -4,12 +4,15 @@
use HiEvents\DomainObjects\AttendeeDomainObject;
use HiEvents\DomainObjects\Enums\OrderAuditAction;
-use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventLocationDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\InvoiceDomainObject;
+use HiEvents\DomainObjects\LocationDomainObject;
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 +42,19 @@ public function resendAttendeeTicket(
->loadRelation(new Relationship(OrderDomainObject::class, nested: [
new Relationship(OrderItemDomainObject::class),
], name: 'order'))
+ ->loadRelation(new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]),
+ ],
+ name: 'event_occurrence',
+ ))
+ ->loadRelation(new Relationship(
+ domainObject: ProductDomainObject::class,
+ name: 'product',
+ ))
->findFirstWhere([
'id' => $attendeeId,
'order_id' => $orderId,
@@ -48,6 +64,9 @@ public function resendAttendeeTicket(
$event = $this->eventRepository
->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer'))
->loadRelation(EventSettingDomainObject::class)
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]))
->findById($eventId);
$this->sendAttendeeTicketService->send(
@@ -75,8 +94,34 @@ public function resendOrderConfirmation(
?string $userAgent
): void {
$order = $this->orderRepository
- ->loadRelation(OrderItemDomainObject::class)
- ->loadRelation(AttendeeDomainObject::class)
+ ->loadRelation(new Relationship(
+ domainObject: OrderItemDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]),
+ ],
+ name: 'event_occurrence',
+ ),
+ ],
+ ))
+ ->loadRelation(new Relationship(
+ domainObject: AttendeeDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]),
+ ],
+ name: 'event_occurrence',
+ ),
+ ],
+ ))
->loadRelation(InvoiceDomainObject::class)
->findFirstWhere([
'id' => $orderId,
@@ -86,6 +131,14 @@ public function resendOrderConfirmation(
$event = $this->eventRepository
->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer'))
->loadRelation(new Relationship(EventSettingDomainObject::class))
+ ->loadRelation(new Relationship(domainObject: EventOccurrenceDomainObject::class, nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]),
+ ]))
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]))
->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/Infrastructure/Geo/DTO/GeoPlaceDTO.php b/backend/app/Services/Infrastructure/Geo/DTO/GeoPlaceDTO.php
new file mode 100644
index 0000000000..6f4c0cae28
--- /dev/null
+++ b/backend/app/Services/Infrastructure/Geo/DTO/GeoPlaceDTO.php
@@ -0,0 +1,21 @@
+
+ */
+ public function autocomplete(string $query, ?string $locale = null, ?string $country = null): array;
+
+ public function getPlaceDetails(string $providerPlaceId, ?string $locale = null): ?GeoPlaceDTO;
+}
diff --git a/backend/app/Services/Infrastructure/Geo/GooglePlacesGeoProvider.php b/backend/app/Services/Infrastructure/Geo/GooglePlacesGeoProvider.php
new file mode 100644
index 0000000000..e419fbbc57
--- /dev/null
+++ b/backend/app/Services/Infrastructure/Geo/GooglePlacesGeoProvider.php
@@ -0,0 +1,259 @@
+ $query];
+ if ($locale !== null && $locale !== '') {
+ $body['languageCode'] = $locale;
+ }
+ if ($country !== null && $country !== '') {
+ $body['regionCode'] = strtoupper($country);
+ $body['includedRegionCodes'] = [strtoupper($country)];
+ }
+
+ try {
+ $response = $this->http
+ ->withHeaders([
+ 'X-Goog-Api-Key' => $this->apiKey,
+ 'X-Goog-FieldMask' => self::AUTOCOMPLETE_FIELD_MASK,
+ ])
+ ->timeout(self::REQUEST_TIMEOUT_SECONDS)
+ ->post(self::AUTOCOMPLETE_URL, $body);
+ } catch (Throwable $e) {
+ $this->logger->error('Google Places autocomplete failed', ['error' => $e->getMessage()]);
+
+ throw new GeoProviderException('Geo provider unavailable', previous: $e);
+ }
+
+ if (! $response->successful()) {
+ $this->logger->error('Google Places autocomplete non-2xx', [
+ 'status' => $response->status(),
+ 'body' => $response->body(),
+ ]);
+
+ if ($this->isQuotaResponse($response->status(), $response->json())) {
+ throw new GeoProviderQuotaExceededException(
+ sprintf('Geo provider quota exceeded (HTTP %d)', $response->status()),
+ );
+ }
+
+ throw new GeoProviderException(sprintf('Geo provider returned HTTP %d', $response->status()));
+ }
+
+ $suggestions = [];
+ foreach ($response->json('suggestions') ?? [] as $suggestion) {
+ $prediction = $suggestion['placePrediction'] ?? null;
+ if ($prediction === null) {
+ continue;
+ }
+
+ $suggestions[] = new GeoSuggestionDTO(
+ provider_place_id: (string) ($prediction['placeId'] ?? ''),
+ primary_text: (string) ($prediction['structuredFormat']['mainText']['text'] ?? $prediction['text']['text'] ?? ''),
+ secondary_text: $prediction['structuredFormat']['secondaryText']['text'] ?? null,
+ );
+ }
+
+ return $suggestions;
+ }
+
+ public function getPlaceDetails(string $providerPlaceId, ?string $locale = null): ?GeoPlaceDTO
+ {
+ if ($providerPlaceId === '') {
+ return null;
+ }
+
+ // Place IDs are stable upstream — Google explicitly recommends caching
+ // place-details responses to cut API costs. Cache the parsed payload
+ // (not the DTO, since some fields are derived).
+ $cacheKey = $this->placeDetailsCacheKey($providerPlaceId, $locale);
+ $payload = $this->cache->get($cacheKey);
+
+ if ($payload === null) {
+ $payload = $this->fetchPlaceDetailsPayload($providerPlaceId, $locale);
+ if ($payload === null) {
+ return null;
+ }
+ $this->cache->put($cacheKey, $payload, self::PLACE_DETAILS_CACHE_TTL_SECONDS);
+ }
+
+ return $this->mapToGeoPlaceDTO($payload, $providerPlaceId);
+ }
+
+ private function fetchPlaceDetailsPayload(string $providerPlaceId, ?string $locale): ?array
+ {
+ $url = self::PLACE_DETAILS_URL.rawurlencode($providerPlaceId);
+
+ try {
+ $query = [];
+ if ($locale !== null && $locale !== '') {
+ $query['languageCode'] = $locale;
+ }
+
+ $response = $this->http
+ ->withHeaders([
+ 'X-Goog-Api-Key' => $this->apiKey,
+ 'X-Goog-FieldMask' => self::PLACE_DETAILS_FIELD_MASK,
+ ])
+ ->timeout(self::REQUEST_TIMEOUT_SECONDS)
+ ->get($url, $query);
+ } catch (Throwable $e) {
+ $this->logger->error('Google Places details failed', ['error' => $e->getMessage(), 'place_id' => $providerPlaceId]);
+
+ throw new GeoProviderException('Geo provider unavailable', previous: $e);
+ }
+
+ if ($response->status() === 404) {
+ return null;
+ }
+
+ if (! $response->successful()) {
+ $this->logger->error('Google Places details non-2xx', [
+ 'status' => $response->status(),
+ 'place_id' => $providerPlaceId,
+ 'body' => $response->body(),
+ ]);
+
+ if ($this->isQuotaResponse($response->status(), $response->json())) {
+ throw new GeoProviderQuotaExceededException(
+ sprintf('Geo provider quota exceeded (HTTP %d)', $response->status()),
+ );
+ }
+
+ throw new GeoProviderException(sprintf('Geo provider returned HTTP %d', $response->status()));
+ }
+
+ $payload = $response->json();
+ if (! is_array($payload)) {
+ return null;
+ }
+
+ return $payload;
+ }
+
+ private function placeDetailsCacheKey(string $providerPlaceId, ?string $locale): string
+ {
+ return 'geo:place_details:'.self::PROVIDER_NAME.':'.$providerPlaceId.':'.($locale ?? '');
+ }
+
+ private function isQuotaResponse(int $status, mixed $body): bool
+ {
+ if ($status === 429) {
+ return true;
+ }
+
+ if (is_array($body)) {
+ $errorStatus = $body['error']['status'] ?? null;
+
+ return $errorStatus === 'RESOURCE_EXHAUSTED';
+ }
+
+ return false;
+ }
+
+ private function mapToGeoPlaceDTO(array $payload, string $fallbackPlaceId): GeoPlaceDTO
+ {
+ $components = $this->indexAddressComponents($payload['addressComponents'] ?? []);
+
+ $streetNumber = $components['street_number']['short'] ?? null;
+ $route = $components['route']['short'] ?? null;
+ $addressLine1 = trim(implode(' ', array_filter([$streetNumber, $route])));
+
+ $city = $components['locality']['short']
+ ?? $components['postal_town']['short']
+ ?? $components['sublocality_level_1']['short']
+ ?? null;
+
+ $stateOrRegion = $components['administrative_area_level_1']['long']
+ ?? $components['administrative_area_level_1']['short']
+ ?? null;
+ $postalCode = $components['postal_code']['short'] ?? null;
+ $country = $components['country']['short'] ?? null;
+ $addressLine2 = $components['subpremise']['short'] ?? null;
+
+ $types = $payload['types'] ?? [];
+ $isEstablishment = in_array('establishment', $types, true) || in_array('point_of_interest', $types, true);
+ $displayName = $payload['displayName']['text'] ?? null;
+ $venueName = ($isEstablishment && $displayName) ? $displayName : null;
+
+ $address = new AddressDTO(
+ venue_name: $venueName,
+ address_line_1: $addressLine1 !== '' ? $addressLine1 : null,
+ address_line_2: $addressLine2,
+ city: $city,
+ state_or_region: $stateOrRegion,
+ zip_or_postal_code: $postalCode,
+ country: $country !== null ? strtoupper($country) : null,
+ );
+
+ $latitude = isset($payload['location']['latitude']) ? (float) $payload['location']['latitude'] : null;
+ $longitude = isset($payload['location']['longitude']) ? (float) $payload['location']['longitude'] : null;
+
+ return new GeoPlaceDTO(
+ provider: self::PROVIDER_NAME,
+ provider_place_id: (string) ($payload['id'] ?? $fallbackPlaceId),
+ address: $address,
+ latitude: $latitude,
+ longitude: $longitude,
+ display_name: $displayName,
+ raw_response: $payload,
+ );
+ }
+
+ private function indexAddressComponents(array $components): array
+ {
+ $index = [];
+ foreach ($components as $component) {
+ foreach ($component['types'] ?? [] as $type) {
+ $index[$type] = [
+ 'short' => $component['shortText'] ?? $component['longText'] ?? null,
+ 'long' => $component['longText'] ?? $component['shortText'] ?? null,
+ ];
+ }
+ }
+
+ return $index;
+ }
+}
diff --git a/backend/app/Services/Infrastructure/Geo/NoOpGeoProvider.php b/backend/app/Services/Infrastructure/Geo/NoOpGeoProvider.php
new file mode 100644
index 0000000000..96b14382ea
--- /dev/null
+++ b/backend/app/Services/Infrastructure/Geo/NoOpGeoProvider.php
@@ -0,0 +1,27 @@
+logger->warning('NoOpGeoProvider in use — autocomplete returned no results. Configure a real provider in services.geo.');
+
+ return [];
+ }
+
+ public function getPlaceDetails(string $providerPlaceId, ?string $locale = null): ?GeoPlaceDTO
+ {
+ $this->logger->warning('NoOpGeoProvider in use — getPlaceDetails returned null. Configure a real provider in services.geo.');
+
+ return null;
+ }
+}
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..7c1849d8d3 100644
--- a/backend/app/Validators/EventRules.php
+++ b/backend/app/Validators/EventRules.php
@@ -3,15 +3,18 @@
namespace HiEvents\Validators;
use HiEvents\DomainObjects\Enums\EventCategory;
+use HiEvents\DomainObjects\Enums\EventType;
+use HiEvents\DomainObjects\Enums\LocationType;
use Illuminate\Validation\Rule;
trait EventRules
{
public function eventRules(): array
{
- $currencies = include __DIR__ . '/../../data/currencies.php';
+ $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))],
@@ -20,26 +23,30 @@ public function eventRules(): array
'attributes.*.name' => ['string', 'min:1', 'max:50', 'required'],
'attributes.*.value' => ['min:1', 'max:1000', 'required'],
'attributes.*.is_public' => ['boolean', 'required'],
- 'location_details' => ['array'],
- 'location_details.venue_name' => ['string', 'max:100'],
- 'location_details.address_line_1' => ['required_with:location_details', 'string', 'max:255'],
- 'location_details.address_line_2' => ['string', 'max:255', 'nullable'],
- 'location_details.city' => ['required_with:location_details', 'string', 'max:85'],
- 'location_details.state_or_region' => ['string', 'max:85'],
- 'location_details.zip_or_postal_code' => ['required_with:location_details', 'string', 'max:85'],
- 'location_details.country' => ['required_with:location_details', 'string', 'max:2'],
+ 'event_location' => ['nullable', 'array'],
+ 'event_location.type' => ['required_with:event_location', Rule::in(LocationType::valuesArray())],
+ 'event_location.location_id' => [
+ 'nullable', 'integer',
+ 'required_if:event_location.type,'.LocationType::IN_PERSON->name,
+ ],
+ 'event_location.online_event_connection_details' => [
+ 'nullable', 'string', 'max:10000',
+ 'required_if:event_location.type,'.LocationType::ONLINE->name,
+ ],
]);
}
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',
- Rule::when($this->input('end_date') !== null, ['before_or_equal:end_date'])
+ $isRecurring ? 'nullable' : 'required',
+ Rule::when($this->input('end_date') !== null, ['before_or_equal:end_date']),
],
'end_date' => ['date', 'nullable'],
];
@@ -52,11 +59,7 @@ public function eventMessages(): array
'attributes.*.name.required' => __('The attribute name is required'),
'attributes.*.value.required' => __('The attribute value is required'),
'attributes.*.is_public.required' => __('The attribute is_public fields is required'),
- 'location_details.address_line_1.required' => __('The address line 1 field is required'),
- 'location_details.city.required' => __('The city field is required'),
- 'location_details.zip_or_postal_code.required' => __('The zip or postal code field is required'),
- 'location_details.country.required' => __('The country field is required'),
- 'location_details.country.max' => __('The country field should be a 2 character ISO 3166 code'),
+ 'event_location.location_id.required_if' => __('A saved location must be selected for in-person events'),
];
}
}
diff --git a/backend/composer.json b/backend/composer.json
index e9768d531c..6a45976976 100644
--- a/backend/composer.json
+++ b/backend/composer.json
@@ -6,7 +6,7 @@
"license": "AGPL-3.0",
"version": "1.8.0-beta",
"require": {
- "php": "^8.2",
+ "php": "^8.3",
"ext-intl": "*",
"ext-xmlwriter": "*",
"barryvdh/laravel-dompdf": "^3.0",
@@ -15,13 +15,13 @@
"ezyang/htmlpurifier": "^4.17",
"guzzlehttp/guzzle": "^7.2",
"lab404/laravel-impersonate": "^1.7",
- "laravel/framework": "^12.0",
+ "laravel/framework": "^13.0",
"laravel/sanctum": "^4.0",
- "laravel/tinker": "^2.8",
+ "laravel/tinker": "^3.0",
"laravel/vapor-core": "^2.37",
"league/flysystem-aws-s3-v3": "^3.0",
"liquid/liquid": "^1.4",
- "maatwebsite/excel": "^3.1",
+ "maatwebsite/excel": "4.x-dev",
"nette/php-generator": "^4.0",
"php-open-source-saver/jwt-auth": "^2.1",
"sentry/sentry-laravel": "^4.13",
@@ -36,14 +36,14 @@
"ext-imagick": "Required for image dimension extraction and LQIP generation"
},
"require-dev": {
- "druc/laravel-langscanner": "dev-l12-compatibility",
+ "druc/laravel-langscanner": "dev-l13-compatibility",
"fakerphp/faker": "^1.9.1",
"gettext/gettext": "^5.7",
"laravel/pint": "^1.0",
"laravel/sail": "^1.22",
"mockery/mockery": "^1.4.4",
"nunomaduro/collision": "^8.1",
- "phpunit/phpunit": "^11.0",
+ "phpunit/phpunit": "^12.0",
"spatie/laravel-ignition": "^2.0"
},
"autoload": {
@@ -82,12 +82,15 @@
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
+ "platform": {
+ "php": "8.3.0"
+ },
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
- "minimum-stability": "stable",
+ "minimum-stability": "dev",
"prefer-stable": true,
"repositories": [
{
diff --git a/backend/composer.lock b/backend/composer.lock
index 7e0b6a083d..fc3882e364 100644
--- a/backend/composer.lock
+++ b/backend/composer.lock
@@ -4,816 +4,8 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "7649da1e3e0f8fad888953eb259e42b7",
+ "content-hash": "d629c0ab94ca3eb682267d76a76cd4a0",
"packages": [
- {
- "name": "amphp/amp",
- "version": "v3.1.1",
- "source": {
- "type": "git",
- "url": "https://github.com/amphp/amp.git",
- "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/amphp/amp/zipball/fa0ab33a6f47a82929c38d03ca47ebb71086a93f",
- "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f",
- "shasum": ""
- },
- "require": {
- "php": ">=8.1",
- "revolt/event-loop": "^1 || ^0.2"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "^2",
- "phpunit/phpunit": "^9",
- "psalm/phar": "5.23.1"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/functions.php",
- "src/Future/functions.php",
- "src/Internal/functions.php"
- ],
- "psr-4": {
- "Amp\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
- },
- {
- "name": "Bob Weinand",
- "email": "bobwei9@hotmail.com"
- },
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- },
- {
- "name": "Daniel Lowrey",
- "email": "rdlowrey@php.net"
- }
- ],
- "description": "A non-blocking concurrency framework for PHP applications.",
- "homepage": "https://amphp.org/amp",
- "keywords": [
- "async",
- "asynchronous",
- "awaitable",
- "concurrency",
- "event",
- "event-loop",
- "future",
- "non-blocking",
- "promise"
- ],
- "support": {
- "issues": "https://github.com/amphp/amp/issues",
- "source": "https://github.com/amphp/amp/tree/v3.1.1"
- },
- "funding": [
- {
- "url": "https://github.com/amphp",
- "type": "github"
- }
- ],
- "time": "2025-08-27T21:42:00+00:00"
- },
- {
- "name": "amphp/byte-stream",
- "version": "v2.1.2",
- "source": {
- "type": "git",
- "url": "https://github.com/amphp/byte-stream.git",
- "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/amphp/byte-stream/zipball/55a6bd071aec26fa2a3e002618c20c35e3df1b46",
- "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46",
- "shasum": ""
- },
- "require": {
- "amphp/amp": "^3",
- "amphp/parser": "^1.1",
- "amphp/pipeline": "^1",
- "amphp/serialization": "^1",
- "amphp/sync": "^2",
- "php": ">=8.1",
- "revolt/event-loop": "^1 || ^0.2.3"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "^2",
- "amphp/phpunit-util": "^3",
- "phpunit/phpunit": "^9",
- "psalm/phar": "5.22.1"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/functions.php",
- "src/Internal/functions.php"
- ],
- "psr-4": {
- "Amp\\ByteStream\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
- },
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- }
- ],
- "description": "A stream abstraction to make working with non-blocking I/O simple.",
- "homepage": "https://amphp.org/byte-stream",
- "keywords": [
- "amp",
- "amphp",
- "async",
- "io",
- "non-blocking",
- "stream"
- ],
- "support": {
- "issues": "https://github.com/amphp/byte-stream/issues",
- "source": "https://github.com/amphp/byte-stream/tree/v2.1.2"
- },
- "funding": [
- {
- "url": "https://github.com/amphp",
- "type": "github"
- }
- ],
- "time": "2025-03-16T17:10:27+00:00"
- },
- {
- "name": "amphp/cache",
- "version": "v2.0.1",
- "source": {
- "type": "git",
- "url": "https://github.com/amphp/cache.git",
- "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c",
- "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c",
- "shasum": ""
- },
- "require": {
- "amphp/amp": "^3",
- "amphp/serialization": "^1",
- "amphp/sync": "^2",
- "php": ">=8.1",
- "revolt/event-loop": "^1 || ^0.2"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "^2",
- "amphp/phpunit-util": "^3",
- "phpunit/phpunit": "^9",
- "psalm/phar": "^5.4"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Amp\\Cache\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- },
- {
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
- },
- {
- "name": "Daniel Lowrey",
- "email": "rdlowrey@php.net"
- }
- ],
- "description": "A fiber-aware cache API based on Amp and Revolt.",
- "homepage": "https://amphp.org/cache",
- "support": {
- "issues": "https://github.com/amphp/cache/issues",
- "source": "https://github.com/amphp/cache/tree/v2.0.1"
- },
- "funding": [
- {
- "url": "https://github.com/amphp",
- "type": "github"
- }
- ],
- "time": "2024-04-19T03:38:06+00:00"
- },
- {
- "name": "amphp/dns",
- "version": "v2.4.0",
- "source": {
- "type": "git",
- "url": "https://github.com/amphp/dns.git",
- "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71",
- "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71",
- "shasum": ""
- },
- "require": {
- "amphp/amp": "^3",
- "amphp/byte-stream": "^2",
- "amphp/cache": "^2",
- "amphp/parser": "^1",
- "amphp/process": "^2",
- "daverandom/libdns": "^2.0.2",
- "ext-filter": "*",
- "ext-json": "*",
- "php": ">=8.1",
- "revolt/event-loop": "^1 || ^0.2"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "^2",
- "amphp/phpunit-util": "^3",
- "phpunit/phpunit": "^9",
- "psalm/phar": "5.20"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/functions.php"
- ],
- "psr-4": {
- "Amp\\Dns\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Chris Wright",
- "email": "addr@daverandom.com"
- },
- {
- "name": "Daniel Lowrey",
- "email": "rdlowrey@php.net"
- },
- {
- "name": "Bob Weinand",
- "email": "bobwei9@hotmail.com"
- },
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- },
- {
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
- }
- ],
- "description": "Async DNS resolution for Amp.",
- "homepage": "https://github.com/amphp/dns",
- "keywords": [
- "amp",
- "amphp",
- "async",
- "client",
- "dns",
- "resolve"
- ],
- "support": {
- "issues": "https://github.com/amphp/dns/issues",
- "source": "https://github.com/amphp/dns/tree/v2.4.0"
- },
- "funding": [
- {
- "url": "https://github.com/amphp",
- "type": "github"
- }
- ],
- "time": "2025-01-19T15:43:40+00:00"
- },
- {
- "name": "amphp/parallel",
- "version": "v2.3.1",
- "source": {
- "type": "git",
- "url": "https://github.com/amphp/parallel.git",
- "reference": "5113111de02796a782f5d90767455e7391cca190"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/amphp/parallel/zipball/5113111de02796a782f5d90767455e7391cca190",
- "reference": "5113111de02796a782f5d90767455e7391cca190",
- "shasum": ""
- },
- "require": {
- "amphp/amp": "^3",
- "amphp/byte-stream": "^2",
- "amphp/cache": "^2",
- "amphp/parser": "^1",
- "amphp/pipeline": "^1",
- "amphp/process": "^2",
- "amphp/serialization": "^1",
- "amphp/socket": "^2",
- "amphp/sync": "^2",
- "php": ">=8.1",
- "revolt/event-loop": "^1"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "^2",
- "amphp/phpunit-util": "^3",
- "phpunit/phpunit": "^9",
- "psalm/phar": "^5.18"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/Context/functions.php",
- "src/Context/Internal/functions.php",
- "src/Ipc/functions.php",
- "src/Worker/functions.php"
- ],
- "psr-4": {
- "Amp\\Parallel\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
- },
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- },
- {
- "name": "Stephen Coakley",
- "email": "me@stephencoakley.com"
- }
- ],
- "description": "Parallel processing component for Amp.",
- "homepage": "https://github.com/amphp/parallel",
- "keywords": [
- "async",
- "asynchronous",
- "concurrent",
- "multi-processing",
- "multi-threading"
- ],
- "support": {
- "issues": "https://github.com/amphp/parallel/issues",
- "source": "https://github.com/amphp/parallel/tree/v2.3.1"
- },
- "funding": [
- {
- "url": "https://github.com/amphp",
- "type": "github"
- }
- ],
- "time": "2024-12-21T01:56:09+00:00"
- },
- {
- "name": "amphp/parser",
- "version": "v1.1.1",
- "source": {
- "type": "git",
- "url": "https://github.com/amphp/parser.git",
- "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7",
- "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7",
- "shasum": ""
- },
- "require": {
- "php": ">=7.4"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "^2",
- "phpunit/phpunit": "^9",
- "psalm/phar": "^5.4"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Amp\\Parser\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
- },
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- }
- ],
- "description": "A generator parser to make streaming parsers simple.",
- "homepage": "https://github.com/amphp/parser",
- "keywords": [
- "async",
- "non-blocking",
- "parser",
- "stream"
- ],
- "support": {
- "issues": "https://github.com/amphp/parser/issues",
- "source": "https://github.com/amphp/parser/tree/v1.1.1"
- },
- "funding": [
- {
- "url": "https://github.com/amphp",
- "type": "github"
- }
- ],
- "time": "2024-03-21T19:16:53+00:00"
- },
- {
- "name": "amphp/pipeline",
- "version": "v1.2.3",
- "source": {
- "type": "git",
- "url": "https://github.com/amphp/pipeline.git",
- "reference": "7b52598c2e9105ebcddf247fc523161581930367"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/amphp/pipeline/zipball/7b52598c2e9105ebcddf247fc523161581930367",
- "reference": "7b52598c2e9105ebcddf247fc523161581930367",
- "shasum": ""
- },
- "require": {
- "amphp/amp": "^3",
- "php": ">=8.1",
- "revolt/event-loop": "^1"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "^2",
- "amphp/phpunit-util": "^3",
- "phpunit/phpunit": "^9",
- "psalm/phar": "^5.18"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Amp\\Pipeline\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
- },
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- }
- ],
- "description": "Asynchronous iterators and operators.",
- "homepage": "https://amphp.org/pipeline",
- "keywords": [
- "amp",
- "amphp",
- "async",
- "io",
- "iterator",
- "non-blocking"
- ],
- "support": {
- "issues": "https://github.com/amphp/pipeline/issues",
- "source": "https://github.com/amphp/pipeline/tree/v1.2.3"
- },
- "funding": [
- {
- "url": "https://github.com/amphp",
- "type": "github"
- }
- ],
- "time": "2025-03-16T16:33:53+00:00"
- },
- {
- "name": "amphp/process",
- "version": "v2.0.3",
- "source": {
- "type": "git",
- "url": "https://github.com/amphp/process.git",
- "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/amphp/process/zipball/52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d",
- "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d",
- "shasum": ""
- },
- "require": {
- "amphp/amp": "^3",
- "amphp/byte-stream": "^2",
- "amphp/sync": "^2",
- "php": ">=8.1",
- "revolt/event-loop": "^1 || ^0.2"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "^2",
- "amphp/phpunit-util": "^3",
- "phpunit/phpunit": "^9",
- "psalm/phar": "^5.4"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/functions.php"
- ],
- "psr-4": {
- "Amp\\Process\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Bob Weinand",
- "email": "bobwei9@hotmail.com"
- },
- {
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
- },
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- }
- ],
- "description": "A fiber-aware process manager based on Amp and Revolt.",
- "homepage": "https://amphp.org/process",
- "support": {
- "issues": "https://github.com/amphp/process/issues",
- "source": "https://github.com/amphp/process/tree/v2.0.3"
- },
- "funding": [
- {
- "url": "https://github.com/amphp",
- "type": "github"
- }
- ],
- "time": "2024-04-19T03:13:44+00:00"
- },
- {
- "name": "amphp/serialization",
- "version": "v1.0.0",
- "source": {
- "type": "git",
- "url": "https://github.com/amphp/serialization.git",
- "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/amphp/serialization/zipball/693e77b2fb0b266c3c7d622317f881de44ae94a1",
- "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1",
- "shasum": ""
- },
- "require": {
- "php": ">=7.1"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "dev-master",
- "phpunit/phpunit": "^9 || ^8 || ^7"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/functions.php"
- ],
- "psr-4": {
- "Amp\\Serialization\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
- },
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- }
- ],
- "description": "Serialization tools for IPC and data storage in PHP.",
- "homepage": "https://github.com/amphp/serialization",
- "keywords": [
- "async",
- "asynchronous",
- "serialization",
- "serialize"
- ],
- "support": {
- "issues": "https://github.com/amphp/serialization/issues",
- "source": "https://github.com/amphp/serialization/tree/master"
- },
- "time": "2020-03-25T21:39:07+00:00"
- },
- {
- "name": "amphp/socket",
- "version": "v2.3.1",
- "source": {
- "type": "git",
- "url": "https://github.com/amphp/socket.git",
- "reference": "58e0422221825b79681b72c50c47a930be7bf1e1"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/amphp/socket/zipball/58e0422221825b79681b72c50c47a930be7bf1e1",
- "reference": "58e0422221825b79681b72c50c47a930be7bf1e1",
- "shasum": ""
- },
- "require": {
- "amphp/amp": "^3",
- "amphp/byte-stream": "^2",
- "amphp/dns": "^2",
- "ext-openssl": "*",
- "kelunik/certificate": "^1.1",
- "league/uri": "^6.5 | ^7",
- "league/uri-interfaces": "^2.3 | ^7",
- "php": ">=8.1",
- "revolt/event-loop": "^1 || ^0.2"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "^2",
- "amphp/phpunit-util": "^3",
- "amphp/process": "^2",
- "phpunit/phpunit": "^9",
- "psalm/phar": "5.20"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/functions.php",
- "src/Internal/functions.php",
- "src/SocketAddress/functions.php"
- ],
- "psr-4": {
- "Amp\\Socket\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Daniel Lowrey",
- "email": "rdlowrey@gmail.com"
- },
- {
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
- },
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- }
- ],
- "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.",
- "homepage": "https://github.com/amphp/socket",
- "keywords": [
- "amp",
- "async",
- "encryption",
- "non-blocking",
- "sockets",
- "tcp",
- "tls"
- ],
- "support": {
- "issues": "https://github.com/amphp/socket/issues",
- "source": "https://github.com/amphp/socket/tree/v2.3.1"
- },
- "funding": [
- {
- "url": "https://github.com/amphp",
- "type": "github"
- }
- ],
- "time": "2024-04-21T14:33:03+00:00"
- },
- {
- "name": "amphp/sync",
- "version": "v2.3.0",
- "source": {
- "type": "git",
- "url": "https://github.com/amphp/sync.git",
- "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1",
- "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1",
- "shasum": ""
- },
- "require": {
- "amphp/amp": "^3",
- "amphp/pipeline": "^1",
- "amphp/serialization": "^1",
- "php": ">=8.1",
- "revolt/event-loop": "^1 || ^0.2"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "^2",
- "amphp/phpunit-util": "^3",
- "phpunit/phpunit": "^9",
- "psalm/phar": "5.23"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/functions.php"
- ],
- "psr-4": {
- "Amp\\Sync\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
- },
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- },
- {
- "name": "Stephen Coakley",
- "email": "me@stephencoakley.com"
- }
- ],
- "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.",
- "homepage": "https://github.com/amphp/sync",
- "keywords": [
- "async",
- "asynchronous",
- "mutex",
- "semaphore",
- "synchronization"
- ],
- "support": {
- "issues": "https://github.com/amphp/sync/issues",
- "source": "https://github.com/amphp/sync/tree/v2.3.0"
- },
- "funding": [
- {
- "url": "https://github.com/amphp",
- "type": "github"
- }
- ],
- "time": "2024-08-03T19:31:26+00:00"
- },
{
"name": "aws/aws-crt-php",
"version": "v1.2.7",
@@ -870,16 +62,16 @@
},
{
"name": "aws/aws-sdk-php",
- "version": "3.356.40",
+ "version": "3.382.2",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
- "reference": "3c1d71932ed962810316930b327b3b71a7517cff"
+ "reference": "6844cc6421c47d6b96633ab8039045012acbeb27"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/3c1d71932ed962810316930b327b3b71a7517cff",
- "reference": "3c1d71932ed962810316930b327b3b71a7517cff",
+ "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/6844cc6421c47d6b96633ab8039045012acbeb27",
+ "reference": "6844cc6421c47d6b96633ab8039045012acbeb27",
"shasum": ""
},
"require": {
@@ -892,24 +84,23 @@
"guzzlehttp/psr7": "^2.4.5",
"mtdowling/jmespath.php": "^2.8.0",
"php": ">=8.1",
- "psr/http-message": "^1.0 || ^2.0"
+ "psr/http-message": "^1.0 || ^2.0",
+ "symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0"
},
"require-dev": {
"andrewsville/php-token-reflection": "^1.4",
"aws/aws-php-sns-message-validator": "~1.0",
"behat/behat": "~3.0",
"composer/composer": "^2.7.8",
- "dms/phpunit-arraysubset-asserts": "^0.4.0",
+ "dms/phpunit-arraysubset-asserts": "^v0.5.0",
"doctrine/cache": "~1.4",
"ext-dom": "*",
"ext-openssl": "*",
- "ext-pcntl": "*",
"ext-sockets": "*",
- "phpunit/phpunit": "^5.6.3 || ^8.5 || ^9.5",
+ "phpunit/phpunit": "^10.0",
"psr/cache": "^2.0 || ^3.0",
"psr/simple-cache": "^2.0 || ^3.0",
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
- "symfony/filesystem": "^v6.4.0 || ^v7.1.0",
"yoast/phpunit-polyfills": "^2.0"
},
"suggest": {
@@ -917,6 +108,7 @@
"doctrine/cache": "To use the DoctrineCacheAdapter",
"ext-curl": "To send requests using cURL",
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
+ "ext-pcntl": "To use client-side monitoring",
"ext-sockets": "To use client-side monitoring"
},
"type": "library",
@@ -943,11 +135,11 @@
"authors": [
{
"name": "Amazon Web Services",
- "homepage": "http://aws.amazon.com"
+ "homepage": "https://aws.amazon.com"
}
],
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
- "homepage": "http://aws.amazon.com/sdkforphp",
+ "homepage": "https://aws.amazon.com/sdk-for-php",
"keywords": [
"amazon",
"aws",
@@ -961,32 +153,32 @@
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
- "source": "https://github.com/aws/aws-sdk-php/tree/3.356.40"
+ "source": "https://github.com/aws/aws-sdk-php/tree/3.382.2"
},
- "time": "2025-10-15T18:13:33+00:00"
+ "time": "2026-05-27T18:11:41+00:00"
},
{
"name": "barryvdh/laravel-dompdf",
- "version": "v3.1.1",
+ "version": "v3.1.2",
"source": {
"type": "git",
"url": "https://github.com/barryvdh/laravel-dompdf.git",
- "reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d"
+ "reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d",
- "reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d",
+ "url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/ee3b72b19ccdf57d0243116ecb2b90261344dedc",
+ "reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc",
"shasum": ""
},
"require": {
"dompdf/dompdf": "^3.0",
- "illuminate/support": "^9|^10|^11|^12",
+ "illuminate/support": "^9|^10|^11|^12|^13.0",
"php": "^8.1"
},
"require-dev": {
"larastan/larastan": "^2.7|^3.0",
- "orchestra/testbench": "^7|^8|^9|^10",
+ "orchestra/testbench": "^7|^8|^9.16|^10|^11.0",
"phpro/grumphp": "^2.5",
"squizlabs/php_codesniffer": "^3.5"
},
@@ -1028,7 +220,7 @@
],
"support": {
"issues": "https://github.com/barryvdh/laravel-dompdf/issues",
- "source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.1"
+ "source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.2"
},
"funding": [
{
@@ -1040,29 +232,29 @@
"type": "github"
}
],
- "time": "2025-02-13T15:07:54+00:00"
+ "time": "2026-02-21T08:51:10+00:00"
},
{
"name": "brick/math",
- "version": "0.13.1",
+ "version": "0.14.8",
"source": {
"type": "git",
"url": "https://github.com/brick/math.git",
- "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04"
+ "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/brick/math/zipball/fc7ed316430118cc7836bf45faff18d5dfc8de04",
- "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04",
+ "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629",
+ "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629",
"shasum": ""
},
"require": {
- "php": "^8.1"
+ "php": "^8.2"
},
"require-dev": {
"php-coveralls/php-coveralls": "^2.2",
- "phpunit/phpunit": "^10.1",
- "vimeo/psalm": "6.8.8"
+ "phpstan/phpstan": "2.1.22",
+ "phpunit/phpunit": "^11.5"
},
"type": "library",
"autoload": {
@@ -1092,7 +284,7 @@
],
"support": {
"issues": "https://github.com/brick/math/issues",
- "source": "https://github.com/brick/math/tree/0.13.1"
+ "source": "https://github.com/brick/math/tree/0.14.8"
},
"funding": [
{
@@ -1100,24 +292,24 @@
"type": "github"
}
],
- "time": "2025-03-29T13:50:30+00:00"
+ "time": "2026-02-10T14:33:43+00:00"
},
{
"name": "brick/money",
- "version": "0.10.2",
+ "version": "0.10.3",
"source": {
"type": "git",
"url": "https://github.com/brick/money.git",
- "reference": "4ee860c0371aabef5faaddcc28b27f208bb67b76"
+ "reference": "b1b0bb6035d26a58f29b1c06b1265c01a0b5c9c3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/brick/money/zipball/4ee860c0371aabef5faaddcc28b27f208bb67b76",
- "reference": "4ee860c0371aabef5faaddcc28b27f208bb67b76",
+ "url": "https://api.github.com/repos/brick/money/zipball/b1b0bb6035d26a58f29b1c06b1265c01a0b5c9c3",
+ "reference": "b1b0bb6035d26a58f29b1c06b1265c01a0b5c9c3",
"shasum": ""
},
"require": {
- "brick/math": "~0.12.0|~0.13.0",
+ "brick/math": "~0.12.0|~0.13.0|~0.14.0",
"php": "^8.1"
},
"require-dev": {
@@ -1149,7 +341,7 @@
],
"support": {
"issues": "https://github.com/brick/money/issues",
- "source": "https://github.com/brick/money/tree/0.10.2"
+ "source": "https://github.com/brick/money/tree/0.10.3"
},
"funding": [
{
@@ -1157,7 +349,7 @@
"type": "github"
}
],
- "time": "2025-08-05T13:08:53+00:00"
+ "time": "2025-09-03T09:55:48+00:00"
},
{
"name": "carbonphp/carbon-doctrine-types",
@@ -1384,50 +576,6 @@
],
"time": "2025-08-20T19:15:30+00:00"
},
- {
- "name": "daverandom/libdns",
- "version": "v2.1.0",
- "source": {
- "type": "git",
- "url": "https://github.com/DaveRandom/LibDNS.git",
- "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a",
- "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a",
- "shasum": ""
- },
- "require": {
- "ext-ctype": "*",
- "php": ">=7.1"
- },
- "suggest": {
- "ext-intl": "Required for IDN support"
- },
- "type": "library",
- "autoload": {
- "files": [
- "src/functions.php"
- ],
- "psr-4": {
- "LibDNS\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "description": "DNS protocol implementation written in pure PHP",
- "keywords": [
- "dns"
- ],
- "support": {
- "issues": "https://github.com/DaveRandom/LibDNS/issues",
- "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0"
- },
- "time": "2024-04-12T12:12:48+00:00"
- },
{
"name": "dflydev/dot-access-data",
"version": "v3.0.3",
@@ -1503,133 +651,43 @@
},
"time": "2024-07-08T12:26:09+00:00"
},
- {
- "name": "doctrine/cache",
- "version": "2.2.0",
- "source": {
- "type": "git",
- "url": "https://github.com/doctrine/cache.git",
- "reference": "1ca8f21980e770095a31456042471a57bc4c68fb"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/doctrine/cache/zipball/1ca8f21980e770095a31456042471a57bc4c68fb",
- "reference": "1ca8f21980e770095a31456042471a57bc4c68fb",
- "shasum": ""
- },
- "require": {
- "php": "~7.1 || ^8.0"
- },
- "conflict": {
- "doctrine/common": ">2.2,<2.4"
- },
- "require-dev": {
- "cache/integration-tests": "dev-master",
- "doctrine/coding-standard": "^9",
- "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5",
- "psr/cache": "^1.0 || ^2.0 || ^3.0",
- "symfony/cache": "^4.4 || ^5.4 || ^6",
- "symfony/var-exporter": "^4.4 || ^5.4 || ^6"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "Doctrine\\Common\\Cache\\": "lib/Doctrine/Common/Cache"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Guilherme Blanco",
- "email": "guilhermeblanco@gmail.com"
- },
- {
- "name": "Roman Borschel",
- "email": "roman@code-factory.org"
- },
- {
- "name": "Benjamin Eberlei",
- "email": "kontakt@beberlei.de"
- },
- {
- "name": "Jonathan Wage",
- "email": "jonwage@gmail.com"
- },
- {
- "name": "Johannes Schmitt",
- "email": "schmittjoh@gmail.com"
- }
- ],
- "description": "PHP Doctrine Cache library is a popular cache implementation that supports many different drivers such as redis, memcache, apc, mongodb and others.",
- "homepage": "https://www.doctrine-project.org/projects/cache.html",
- "keywords": [
- "abstraction",
- "apcu",
- "cache",
- "caching",
- "couchdb",
- "memcached",
- "php",
- "redis",
- "xcache"
- ],
- "support": {
- "issues": "https://github.com/doctrine/cache/issues",
- "source": "https://github.com/doctrine/cache/tree/2.2.0"
- },
- "funding": [
- {
- "url": "https://www.doctrine-project.org/sponsorship.html",
- "type": "custom"
- },
- {
- "url": "https://www.patreon.com/phpdoctrine",
- "type": "patreon"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcache",
- "type": "tidelift"
- }
- ],
- "time": "2022-05-20T20:07:39+00:00"
- },
{
"name": "doctrine/dbal",
- "version": "3.9.4",
+ "version": "3.10.5",
"source": {
"type": "git",
"url": "https://github.com/doctrine/dbal.git",
- "reference": "ec16c82f20be1a7224e65ac67144a29199f87959"
+ "reference": "95d84866bf3c04b2ddca1df7c049714660959aef"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/dbal/zipball/ec16c82f20be1a7224e65ac67144a29199f87959",
- "reference": "ec16c82f20be1a7224e65ac67144a29199f87959",
+ "url": "https://api.github.com/repos/doctrine/dbal/zipball/95d84866bf3c04b2ddca1df7c049714660959aef",
+ "reference": "95d84866bf3c04b2ddca1df7c049714660959aef",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2",
- "doctrine/cache": "^1.11|^2.0",
"doctrine/deprecations": "^0.5.3|^1",
"doctrine/event-manager": "^1|^2",
"php": "^7.4 || ^8.0",
"psr/cache": "^1|^2|^3",
"psr/log": "^1|^2|^3"
},
+ "conflict": {
+ "doctrine/cache": "< 1.11"
+ },
"require-dev": {
- "doctrine/coding-standard": "12.0.0",
+ "doctrine/cache": "^1.11|^2.0",
+ "doctrine/coding-standard": "14.0.0",
"fig/log-test": "^1",
"jetbrains/phpstorm-stubs": "2023.1",
- "phpstan/phpstan": "2.1.1",
+ "phpstan/phpstan": "2.1.30",
"phpstan/phpstan-strict-rules": "^2",
- "phpunit/phpunit": "9.6.22",
- "slevomat/coding-standard": "8.13.1",
- "squizlabs/php_codesniffer": "3.10.2",
- "symfony/cache": "^5.4|^6.0|^7.0",
- "symfony/console": "^4.4|^5.4|^6.0|^7.0"
+ "phpunit/phpunit": "9.6.34",
+ "slevomat/coding-standard": "8.27.1",
+ "squizlabs/php_codesniffer": "4.0.1",
+ "symfony/cache": "^5.4|^6.0|^7.0|^8.0",
+ "symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0"
},
"suggest": {
"symfony/console": "For helpful console commands such as SQL execution and import of files."
@@ -1689,7 +747,7 @@
],
"support": {
"issues": "https://github.com/doctrine/dbal/issues",
- "source": "https://github.com/doctrine/dbal/tree/3.9.4"
+ "source": "https://github.com/doctrine/dbal/tree/3.10.5"
},
"funding": [
{
@@ -1705,33 +763,33 @@
"type": "tidelift"
}
],
- "time": "2025-01-16T08:28:55+00:00"
+ "time": "2026-02-24T08:03:57+00:00"
},
{
"name": "doctrine/deprecations",
- "version": "1.1.5",
+ "version": "1.1.6",
"source": {
"type": "git",
"url": "https://github.com/doctrine/deprecations.git",
- "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38"
+ "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
- "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
+ "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
+ "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"conflict": {
- "phpunit/phpunit": "<=7.5 || >=13"
+ "phpunit/phpunit": "<=7.5 || >=14"
},
"require-dev": {
- "doctrine/coding-standard": "^9 || ^12 || ^13",
- "phpstan/phpstan": "1.4.10 || 2.1.11",
+ "doctrine/coding-standard": "^9 || ^12 || ^14",
+ "phpstan/phpstan": "1.4.10 || 2.1.30",
"phpstan/phpstan-phpunit": "^1.0 || ^2",
- "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12",
+ "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0",
"psr/log": "^1 || ^2 || ^3"
},
"suggest": {
@@ -1751,22 +809,22 @@
"homepage": "https://www.doctrine-project.org/",
"support": {
"issues": "https://github.com/doctrine/deprecations/issues",
- "source": "https://github.com/doctrine/deprecations/tree/1.1.5"
+ "source": "https://github.com/doctrine/deprecations/tree/1.1.6"
},
- "time": "2025-04-07T20:06:18+00:00"
+ "time": "2026-02-07T07:09:04+00:00"
},
{
"name": "doctrine/event-manager",
- "version": "2.0.1",
+ "version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/doctrine/event-manager.git",
- "reference": "b680156fa328f1dfd874fd48c7026c41570b9c6e"
+ "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/event-manager/zipball/b680156fa328f1dfd874fd48c7026c41570b9c6e",
- "reference": "b680156fa328f1dfd874fd48c7026c41570b9c6e",
+ "url": "https://api.github.com/repos/doctrine/event-manager/zipball/dda33921b198841ca8dbad2eaa5d4d34769d18cf",
+ "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf",
"shasum": ""
},
"require": {
@@ -1776,10 +834,10 @@
"doctrine/common": "<2.9"
},
"require-dev": {
- "doctrine/coding-standard": "^12",
- "phpstan/phpstan": "^1.8.8",
- "phpunit/phpunit": "^10.5",
- "vimeo/psalm": "^5.24"
+ "doctrine/coding-standard": "^14",
+ "phpdocumentor/guides-cli": "^1.4",
+ "phpstan/phpstan": "^2.1.32",
+ "phpunit/phpunit": "^10.5.58"
},
"type": "library",
"autoload": {
@@ -1828,7 +886,7 @@
],
"support": {
"issues": "https://github.com/doctrine/event-manager/issues",
- "source": "https://github.com/doctrine/event-manager/tree/2.0.1"
+ "source": "https://github.com/doctrine/event-manager/tree/2.1.1"
},
"funding": [
{
@@ -1844,7 +902,7 @@
"type": "tidelift"
}
],
- "time": "2024-05-22T20:47:39+00:00"
+ "time": "2026-01-29T07:11:08+00:00"
},
{
"name": "doctrine/inflector",
@@ -2015,16 +1073,16 @@
},
{
"name": "dompdf/dompdf",
- "version": "v3.1.0",
+ "version": "v3.1.5",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
- "reference": "a51bd7a063a65499446919286fb18b518177155a"
+ "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/dompdf/dompdf/zipball/a51bd7a063a65499446919286fb18b518177155a",
- "reference": "a51bd7a063a65499446919286fb18b518177155a",
+ "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
+ "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496",
"shasum": ""
},
"require": {
@@ -2073,22 +1131,22 @@
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
- "source": "https://github.com/dompdf/dompdf/tree/v3.1.0"
+ "source": "https://github.com/dompdf/dompdf/tree/v3.1.5"
},
- "time": "2025-01-15T14:09:04+00:00"
+ "time": "2026-03-03T13:54:37+00:00"
},
{
"name": "dompdf/php-font-lib",
- "version": "1.0.1",
+ "version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-font-lib.git",
- "reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d"
+ "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d",
- "reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d",
+ "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
+ "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"shasum": ""
},
"require": {
@@ -2096,7 +1154,7 @@
"php": "^7.1 || ^8.0"
},
"require-dev": {
- "symfony/phpunit-bridge": "^3 || ^4 || ^5 || ^6"
+ "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
},
"type": "library",
"autoload": {
@@ -2118,31 +1176,31 @@
"homepage": "https://github.com/dompdf/php-font-lib",
"support": {
"issues": "https://github.com/dompdf/php-font-lib/issues",
- "source": "https://github.com/dompdf/php-font-lib/tree/1.0.1"
+ "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
},
- "time": "2024-12-02T14:37:59+00:00"
+ "time": "2026-01-20T14:10:26+00:00"
},
{
"name": "dompdf/php-svg-lib",
- "version": "1.0.0",
+ "version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-svg-lib.git",
- "reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af"
+ "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/eb045e518185298eb6ff8d80d0d0c6b17aecd9af",
- "reference": "eb045e518185298eb6ff8d80d0d0c6b17aecd9af",
+ "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
+ "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
- "sabberworm/php-css-parser": "^8.4"
+ "sabberworm/php-css-parser": "^8.4 || ^9.0"
},
"require-dev": {
- "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5"
+ "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
},
"type": "library",
"autoload": {
@@ -2164,35 +1222,34 @@
"homepage": "https://github.com/dompdf/php-svg-lib",
"support": {
"issues": "https://github.com/dompdf/php-svg-lib/issues",
- "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.0"
+ "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
},
- "time": "2024-04-29T13:26:35+00:00"
+ "time": "2026-01-02T16:01:13+00:00"
},
{
"name": "dragonmantank/cron-expression",
- "version": "v3.4.0",
+ "version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/dragonmantank/cron-expression.git",
- "reference": "8c784d071debd117328803d86b2097615b457500"
+ "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500",
- "reference": "8c784d071debd117328803d86b2097615b457500",
+ "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013",
+ "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013",
"shasum": ""
},
"require": {
- "php": "^7.2|^8.0",
- "webmozart/assert": "^1.0"
+ "php": "^8.2|^8.3|^8.4|^8.5"
},
"replace": {
"mtdowling/cron-expression": "^1.0"
},
"require-dev": {
- "phpstan/extension-installer": "^1.0",
- "phpstan/phpstan": "^1.0",
- "phpunit/phpunit": "^7.0|^8.0|^9.0"
+ "phpstan/extension-installer": "^1.4.3",
+ "phpstan/phpstan": "^1.12.32|^2.1.31",
+ "phpunit/phpunit": "^8.5.48|^9.0"
},
"type": "library",
"extra": {
@@ -2223,7 +1280,7 @@
],
"support": {
"issues": "https://github.com/dragonmantank/cron-expression/issues",
- "source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0"
+ "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0"
},
"funding": [
{
@@ -2231,7 +1288,7 @@
"type": "github"
}
],
- "time": "2024-10-09T13:47:03+00:00"
+ "time": "2025-10-31T18:51:33+00:00"
},
{
"name": "egulias/email-validator",
@@ -2302,20 +1359,20 @@
},
{
"name": "ezyang/htmlpurifier",
- "version": "v4.18.0",
+ "version": "v4.19.0",
"source": {
"type": "git",
"url": "https://github.com/ezyang/htmlpurifier.git",
- "reference": "cb56001e54359df7ae76dc522d08845dc741621b"
+ "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/cb56001e54359df7ae76dc522d08845dc741621b",
- "reference": "cb56001e54359df7ae76dc522d08845dc741621b",
+ "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf",
+ "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf",
"shasum": ""
},
"require": {
- "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0"
+ "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
},
"require-dev": {
"cerdic/css-tidy": "^1.7 || ^2.0",
@@ -2357,37 +1414,37 @@
],
"support": {
"issues": "https://github.com/ezyang/htmlpurifier/issues",
- "source": "https://github.com/ezyang/htmlpurifier/tree/v4.18.0"
+ "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0"
},
- "time": "2024-11-01T03:51:45+00:00"
+ "time": "2025-10-17T16:34:55+00:00"
},
{
"name": "fruitcake/php-cors",
- "version": "v1.3.0",
+ "version": "v1.4.0",
"source": {
"type": "git",
"url": "https://github.com/fruitcake/php-cors.git",
- "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b"
+ "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/3d158f36e7875e2f040f37bc0573956240a5a38b",
- "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b",
+ "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379",
+ "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379",
"shasum": ""
},
"require": {
- "php": "^7.4|^8.0",
- "symfony/http-foundation": "^4.4|^5.4|^6|^7"
+ "php": "^8.1",
+ "symfony/http-foundation": "^5.4|^6.4|^7.3|^8"
},
"require-dev": {
- "phpstan/phpstan": "^1.4",
+ "phpstan/phpstan": "^2",
"phpunit/phpunit": "^9",
- "squizlabs/php_codesniffer": "^3.5"
+ "squizlabs/php_codesniffer": "^4"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "1.2-dev"
+ "dev-master": "1.3-dev"
}
},
"autoload": {
@@ -2418,7 +1475,7 @@
],
"support": {
"issues": "https://github.com/fruitcake/php-cors/issues",
- "source": "https://github.com/fruitcake/php-cors/tree/v1.3.0"
+ "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0"
},
"funding": [
{
@@ -2430,28 +1487,28 @@
"type": "github"
}
],
- "time": "2023-10-12T05:21:21+00:00"
+ "time": "2025-12-03T09:33:47+00:00"
},
{
"name": "graham-campbell/result-type",
- "version": "v1.1.3",
+ "version": "v1.1.4",
"source": {
"type": "git",
"url": "https://github.com/GrahamCampbell/Result-Type.git",
- "reference": "3ba905c11371512af9d9bdd27d99b782216b6945"
+ "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945",
- "reference": "3ba905c11371512af9d9bdd27d99b782216b6945",
+ "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b",
+ "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
- "phpoption/phpoption": "^1.9.3"
+ "phpoption/phpoption": "^1.9.5"
},
"require-dev": {
- "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28"
+ "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7"
},
"type": "library",
"autoload": {
@@ -2480,7 +1537,7 @@
],
"support": {
"issues": "https://github.com/GrahamCampbell/Result-Type/issues",
- "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3"
+ "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4"
},
"funding": [
{
@@ -2492,20 +1549,20 @@
"type": "tidelift"
}
],
- "time": "2024-07-20T21:45:45+00:00"
+ "time": "2025-12-27T19:43:20+00:00"
},
{
"name": "guzzlehttp/guzzle",
- "version": "7.10.0",
+ "version": "7.10.5",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
- "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4"
+ "reference": "7c8d84b39e680315f687e8662a9d6fb0865c5148"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
- "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
+ "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7c8d84b39e680315f687e8662a9d6fb0865c5148",
+ "reference": "7c8d84b39e680315f687e8662a9d6fb0865c5148",
"shasum": ""
},
"require": {
@@ -2523,8 +1580,9 @@
"bamarni/composer-bin-plugin": "^1.8.2",
"ext-curl": "*",
"guzzle/client-integration-tests": "3.0.2",
+ "guzzlehttp/test-server": "^0.4",
"php-http/message-factory": "^1.1",
- "phpunit/phpunit": "^8.5.39 || ^9.6.20",
+ "phpunit/phpunit": "^8.5.52 || ^9.6.34",
"psr/log": "^1.1 || ^2.0 || ^3.0"
},
"suggest": {
@@ -2602,7 +1660,7 @@
],
"support": {
"issues": "https://github.com/guzzle/guzzle/issues",
- "source": "https://github.com/guzzle/guzzle/tree/7.10.0"
+ "source": "https://github.com/guzzle/guzzle/tree/7.10.5"
},
"funding": [
{
@@ -2618,20 +1676,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-23T22:36:01+00:00"
+ "time": "2026-05-27T11:53:46+00:00"
},
{
"name": "guzzlehttp/promises",
- "version": "2.3.0",
+ "version": "2.4.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/promises.git",
- "reference": "481557b130ef3790cf82b713667b43030dc9c957"
+ "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957",
- "reference": "481557b130ef3790cf82b713667b43030dc9c957",
+ "url": "https://api.github.com/repos/guzzle/promises/zipball/09e8a212562fb1fb6a512c4156ed71525969d6c2",
+ "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2",
"shasum": ""
},
"require": {
@@ -2639,7 +1697,7 @@
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
- "phpunit/phpunit": "^8.5.44 || ^9.6.25"
+ "phpunit/phpunit": "^8.5.52 || ^9.6.34"
},
"type": "library",
"extra": {
@@ -2685,7 +1743,7 @@
],
"support": {
"issues": "https://github.com/guzzle/promises/issues",
- "source": "https://github.com/guzzle/promises/tree/2.3.0"
+ "source": "https://github.com/guzzle/promises/tree/2.4.1"
},
"funding": [
{
@@ -2701,20 +1759,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-22T14:34:08+00:00"
+ "time": "2026-05-20T22:57:30+00:00"
},
{
"name": "guzzlehttp/psr7",
- "version": "2.8.0",
+ "version": "2.10.3",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
- "reference": "21dc724a0583619cd1652f673303492272778051"
+ "reference": "7c1472269227dc6f18930bd903d7a88fe6c52130"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051",
- "reference": "21dc724a0583619cd1652f673303492272778051",
+ "url": "https://api.github.com/repos/guzzle/psr7/zipball/7c1472269227dc6f18930bd903d7a88fe6c52130",
+ "reference": "7c1472269227dc6f18930bd903d7a88fe6c52130",
"shasum": ""
},
"require": {
@@ -2729,8 +1787,9 @@
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
- "http-interop/http-factory-tests": "0.9.0",
- "phpunit/phpunit": "^8.5.44 || ^9.6.25"
+ "http-interop/http-factory-tests": "1.1.0",
+ "jshttp/mime-db": "1.54.0.1",
+ "phpunit/phpunit": "^8.5.52 || ^9.6.34"
},
"suggest": {
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
@@ -2801,7 +1860,7 @@
],
"support": {
"issues": "https://github.com/guzzle/psr7/issues",
- "source": "https://github.com/guzzle/psr7/tree/2.8.0"
+ "source": "https://github.com/guzzle/psr7/tree/2.10.3"
},
"funding": [
{
@@ -2817,20 +1876,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-23T21:21:41+00:00"
+ "time": "2026-05-27T11:48:20+00:00"
},
{
"name": "guzzlehttp/uri-template",
- "version": "v1.0.5",
+ "version": "v1.0.6",
"source": {
"type": "git",
"url": "https://github.com/guzzle/uri-template.git",
- "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1"
+ "reference": "eef7f87bab6f204eba3c39224d8075c70c637946"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/uri-template/zipball/4f4bbd4e7172148801e76e3decc1e559bdee34e1",
- "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1",
+ "url": "https://api.github.com/repos/guzzle/uri-template/zipball/eef7f87bab6f204eba3c39224d8075c70c637946",
+ "reference": "eef7f87bab6f204eba3c39224d8075c70c637946",
"shasum": ""
},
"require": {
@@ -2839,7 +1898,7 @@
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
- "phpunit/phpunit": "^8.5.44 || ^9.6.25",
+ "phpunit/phpunit": "^8.5.52 || ^9.6.34",
"uri-template/tests": "1.0.0"
},
"type": "library",
@@ -2887,7 +1946,7 @@
],
"support": {
"issues": "https://github.com/guzzle/uri-template/issues",
- "source": "https://github.com/guzzle/uri-template/tree/v1.0.5"
+ "source": "https://github.com/guzzle/uri-template/tree/v1.0.6"
},
"funding": [
{
@@ -2903,7 +1962,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-22T14:27:06+00:00"
+ "time": "2026-05-23T22:00:21+00:00"
},
{
"name": "hollodotme/fast-cgi-client",
@@ -2978,64 +2037,8 @@
"jean85/composer-provided-replaced-stub-package": "^1.0",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^7.5|^8.5|^9.6",
- "rector/rector": "^2.0",
- "vimeo/psalm": "^4.3 || ^5.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "1.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Jean85\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Alessandro Lai",
- "email": "alessandro.lai85@gmail.com"
- }
- ],
- "description": "A library to get pretty versions strings of installed dependencies",
- "keywords": [
- "composer",
- "package",
- "release",
- "versions"
- ],
- "support": {
- "issues": "https://github.com/Jean85/pretty-package-versions/issues",
- "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
- },
- "time": "2025-03-19T14:43:43+00:00"
- },
- {
- "name": "kelunik/certificate",
- "version": "v1.1.3",
- "source": {
- "type": "git",
- "url": "https://github.com/kelunik/certificate.git",
- "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e",
- "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e",
- "shasum": ""
- },
- "require": {
- "ext-openssl": "*",
- "php": ">=7.0"
- },
- "require-dev": {
- "amphp/php-cs-fixer-config": "^2",
- "phpunit/phpunit": "^6 | 7 | ^8 | ^9"
+ "rector/rector": "^2.0",
+ "vimeo/psalm": "^4.3 || ^5.0"
},
"type": "library",
"extra": {
@@ -3045,7 +2048,7 @@
},
"autoload": {
"psr-4": {
- "Kelunik\\Certificate\\": "src"
+ "Jean85\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -3054,46 +2057,44 @@
],
"authors": [
{
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
+ "name": "Alessandro Lai",
+ "email": "alessandro.lai85@gmail.com"
}
],
- "description": "Access certificate details and transform between different formats.",
+ "description": "A library to get pretty versions strings of installed dependencies",
"keywords": [
- "DER",
- "certificate",
- "certificates",
- "openssl",
- "pem",
- "x509"
+ "composer",
+ "package",
+ "release",
+ "versions"
],
"support": {
- "issues": "https://github.com/kelunik/certificate/issues",
- "source": "https://github.com/kelunik/certificate/tree/v1.1.3"
+ "issues": "https://github.com/Jean85/pretty-package-versions/issues",
+ "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
},
- "time": "2023-02-03T21:26:53+00:00"
+ "time": "2025-03-19T14:43:43+00:00"
},
{
"name": "lab404/laravel-impersonate",
- "version": "1.7.7",
+ "version": "1.7.8",
"source": {
"type": "git",
"url": "https://github.com/404labfr/laravel-impersonate.git",
- "reference": "5033f3433a55ca8bb2cc3e4a018a39dd8a327a9f"
+ "reference": "0008a39da8914cc946b6a5ed211230708ee736b3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/404labfr/laravel-impersonate/zipball/5033f3433a55ca8bb2cc3e4a018a39dd8a327a9f",
- "reference": "5033f3433a55ca8bb2cc3e4a018a39dd8a327a9f",
+ "url": "https://api.github.com/repos/404labfr/laravel-impersonate/zipball/0008a39da8914cc946b6a5ed211230708ee736b3",
+ "reference": "0008a39da8914cc946b6a5ed211230708ee736b3",
"shasum": ""
},
"require": {
- "laravel/framework": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0",
+ "laravel/framework": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0",
"php": "^7.2 | ^8.0"
},
"require-dev": {
"mockery/mockery": "^1.3.3",
- "orchestra/testbench": "^4.0 | ^5.0 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0",
+ "orchestra/testbench": "^4.0 | ^5.0 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0",
"phpunit/phpunit": "^7.5 | ^8.0 | ^9.0 | ^10.0 | ^11.0"
},
"type": "library",
@@ -3136,30 +2137,30 @@
],
"support": {
"issues": "https://github.com/404labfr/laravel-impersonate/issues",
- "source": "https://github.com/404labfr/laravel-impersonate/tree/1.7.7"
+ "source": "https://github.com/404labfr/laravel-impersonate/tree/1.7.8"
},
- "time": "2025-02-24T16:18:38+00:00"
+ "time": "2026-03-17T15:24:14+00:00"
},
{
"name": "laravel/framework",
- "version": "v12.34.0",
+ "version": "v13.12.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
- "reference": "f9ec5a5d88bc8c468f17b59f88e05c8ac3c8d687"
+ "reference": "6ac27a7fcfa728250c9f77921cb8fb955546b591"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/framework/zipball/f9ec5a5d88bc8c468f17b59f88e05c8ac3c8d687",
- "reference": "f9ec5a5d88bc8c468f17b59f88e05c8ac3c8d687",
+ "url": "https://api.github.com/repos/laravel/framework/zipball/6ac27a7fcfa728250c9f77921cb8fb955546b591",
+ "reference": "6ac27a7fcfa728250c9f77921cb8fb955546b591",
"shasum": ""
},
"require": {
- "brick/math": "^0.11|^0.12|^0.13|^0.14",
+ "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17",
"composer-runtime-api": "^2.2",
"doctrine/inflector": "^2.0.5",
"dragonmantank/cron-expression": "^3.4",
- "egulias/email-validator": "^3.2.1|^4.0",
+ "egulias/email-validator": "^4.0",
"ext-ctype": "*",
"ext-filter": "*",
"ext-hash": "*",
@@ -3169,35 +2170,36 @@
"ext-tokenizer": "*",
"fruitcake/php-cors": "^1.3",
"guzzlehttp/guzzle": "^7.8.2",
+ "guzzlehttp/promises": "^2.0.3",
"guzzlehttp/uri-template": "^1.0",
"laravel/prompts": "^0.3.0",
- "laravel/serializable-closure": "^1.3|^2.0",
- "league/commonmark": "^2.7",
+ "laravel/serializable-closure": "^2.0.10",
+ "league/commonmark": "^2.8.1",
"league/flysystem": "^3.25.1",
"league/flysystem-local": "^3.25.1",
"league/uri": "^7.5.1",
"monolog/monolog": "^3.0",
"nesbot/carbon": "^3.8.4",
"nunomaduro/termwind": "^2.0",
- "php": "^8.2",
- "psr/container": "^1.1.1|^2.0.1",
- "psr/log": "^1.0|^2.0|^3.0",
- "psr/simple-cache": "^1.0|^2.0|^3.0",
+ "php": "^8.3",
+ "psr/container": "^1.1.1 || ^2.0.1",
+ "psr/log": "^1.0 || ^2.0 || ^3.0",
+ "psr/simple-cache": "^1.0 || ^2.0 || ^3.0",
"ramsey/uuid": "^4.7",
- "symfony/console": "^7.2.0",
- "symfony/error-handler": "^7.2.0",
- "symfony/finder": "^7.2.0",
- "symfony/http-foundation": "^7.2.0",
- "symfony/http-kernel": "^7.2.0",
- "symfony/mailer": "^7.2.0",
- "symfony/mime": "^7.2.0",
- "symfony/polyfill-php83": "^1.33",
- "symfony/polyfill-php84": "^1.33",
- "symfony/polyfill-php85": "^1.33",
- "symfony/process": "^7.2.0",
- "symfony/routing": "^7.2.0",
- "symfony/uid": "^7.2.0",
- "symfony/var-dumper": "^7.2.0",
+ "symfony/console": "^7.4.0 || ^8.0.0",
+ "symfony/error-handler": "^7.4.0 || ^8.0.0",
+ "symfony/finder": "^7.4.0 || ^8.0.0",
+ "symfony/http-foundation": "^7.4.0 || ^8.0.0",
+ "symfony/http-kernel": "^7.4.0 || ^8.0.0",
+ "symfony/mailer": "^7.4.0 || ^8.0.0",
+ "symfony/mime": "^7.4.0 || ^8.0.0",
+ "symfony/polyfill-php84": "^1.36",
+ "symfony/polyfill-php85": "^1.36",
+ "symfony/polyfill-php86": "^1.36",
+ "symfony/process": "^7.4.5 || ^8.0.5",
+ "symfony/routing": "^7.4.0 || ^8.0.0",
+ "symfony/uid": "^7.4.0 || ^8.0.0",
+ "symfony/var-dumper": "^7.4.0 || ^8.0.0",
"tijsverkoyen/css-to-inline-styles": "^2.2.5",
"vlucas/phpdotenv": "^5.6.1",
"voku/portable-ascii": "^2.0.2"
@@ -3206,9 +2208,9 @@
"tightenco/collect": "<5.5.33"
},
"provide": {
- "psr/container-implementation": "1.1|2.0",
- "psr/log-implementation": "1.0|2.0|3.0",
- "psr/simple-cache-implementation": "1.0|2.0|3.0"
+ "psr/container-implementation": "1.1 || 2.0",
+ "psr/log-implementation": "1.0 || 2.0 || 3.0",
+ "psr/simple-cache-implementation": "1.0 || 2.0 || 3.0"
},
"replace": {
"illuminate/auth": "self.version",
@@ -3239,6 +2241,7 @@
"illuminate/process": "self.version",
"illuminate/queue": "self.version",
"illuminate/redis": "self.version",
+ "illuminate/reflection": "self.version",
"illuminate/routing": "self.version",
"illuminate/session": "self.version",
"illuminate/support": "self.version",
@@ -3253,8 +2256,7 @@
"aws/aws-sdk-php": "^3.322.9",
"ext-gmp": "*",
"fakerphp/faker": "^1.24",
- "guzzlehttp/promises": "^2.0.3",
- "guzzlehttp/psr7": "^2.4",
+ "guzzlehttp/psr7": "^2.9",
"laravel/pint": "^1.18",
"league/flysystem-aws-s3-v3": "^3.25.1",
"league/flysystem-ftp": "^3.25.1",
@@ -3263,22 +2265,23 @@
"league/flysystem-sftp-v3": "^3.25.1",
"mockery/mockery": "^1.6.10",
"opis/json-schema": "^2.4.1",
- "orchestra/testbench-core": "^10.7.0",
- "pda/pheanstalk": "^5.0.6|^7.0.0",
+ "orchestra/testbench-core": "^11.0.0",
+ "pda/pheanstalk": "^7.0.0 || ^8.0.0",
"php-http/discovery": "^1.15",
"phpstan/phpstan": "^2.0",
- "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1",
- "predis/predis": "^2.3|^3.0",
- "resend/resend-php": "^0.10.0",
- "symfony/cache": "^7.2.0",
- "symfony/http-client": "^7.2.0",
- "symfony/psr-http-message-bridge": "^7.2.0",
- "symfony/translation": "^7.2.0"
+ "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3",
+ "predis/predis": "^2.3 || ^3.0",
+ "rector/rector": "^2.3",
+ "resend/resend-php": "^1.0",
+ "symfony/cache": "^7.4.0 || ^8.0.0",
+ "symfony/http-client": "^7.4.0 || ^8.0.0",
+ "symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0",
+ "symfony/translation": "^7.4.0 || ^8.0.0"
},
"suggest": {
"ably/ably-php": "Required to use the Ably broadcast driver (^1.0).",
"aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).",
- "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).",
+ "brianium/paratest": "Required to run tests in parallel (^7.0 || ^8.0).",
"ext-apcu": "Required to use the APC cache driver.",
"ext-fileinfo": "Required to use the Filesystem class.",
"ext-ftp": "Required to use the Flysystem FTP driver.",
@@ -3287,7 +2290,7 @@
"ext-pcntl": "Required to use all features of the queue worker and console signal trapping.",
"ext-pdo": "Required to use all database features.",
"ext-posix": "Required to use all features of the queue worker.",
- "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).",
+ "ext-redis": "Required to use the Redis cache and queue drivers (^4.0 || ^5.0 || ^6.0).",
"fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).",
"filp/whoops": "Required for friendly error pages in development (^2.14.3).",
"laravel/tinker": "Required to use the tinker console command (^2.0).",
@@ -3297,24 +2300,25 @@
"league/flysystem-read-only": "Required to use read-only disks (^3.25.1)",
"league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).",
"mockery/mockery": "Required to use mocking (^1.6).",
- "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).",
+ "pda/pheanstalk": "Required to use the beanstalk queue driver (^7.0 || ^8.0).",
"php-http/discovery": "Required to use PSR-7 bridging features (^1.15).",
- "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).",
- "predis/predis": "Required to use the predis connector (^2.3|^3.0).",
+ "phpunit/phpunit": "Required to use assertions and run tests (^11.5.50 || ^12.5.8 || ^13.0.3).",
+ "predis/predis": "Required to use the predis connector (^2.3 || ^3.0).",
"psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).",
- "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).",
- "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0).",
- "symfony/cache": "Required to PSR-6 cache bridge (^7.2).",
- "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).",
- "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).",
- "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).",
- "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).",
- "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)."
+ "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0 || ^7.0).",
+ "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0 || ^1.0).",
+ "spatie/fork": "Required to use the 'fork' concurrency driver (^1.2).",
+ "symfony/cache": "Required to PSR-6 cache bridge (^7.4 || ^8.0).",
+ "symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).",
+ "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).",
+ "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).",
+ "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).",
+ "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)."
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "12.x-dev"
+ "dev-master": "13.0.x-dev"
}
},
"autoload": {
@@ -3325,6 +2329,7 @@
"src/Illuminate/Filesystem/functions.php",
"src/Illuminate/Foundation/helpers.php",
"src/Illuminate/Log/functions.php",
+ "src/Illuminate/Reflection/helpers.php",
"src/Illuminate/Support/functions.php",
"src/Illuminate/Support/helpers.php"
],
@@ -3333,7 +2338,8 @@
"Illuminate\\Support\\": [
"src/Illuminate/Macroable/",
"src/Illuminate/Collections/",
- "src/Illuminate/Conditionable/"
+ "src/Illuminate/Conditionable/",
+ "src/Illuminate/Reflection/"
]
}
},
@@ -3357,36 +2363,36 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
- "time": "2025-10-14T13:58:31+00:00"
+ "time": "2026-05-26T23:39:26+00:00"
},
{
"name": "laravel/prompts",
- "version": "v0.3.7",
+ "version": "v0.3.18",
"source": {
"type": "git",
"url": "https://github.com/laravel/prompts.git",
- "reference": "a1891d362714bc40c8d23b0b1d7090f022ea27cc"
+ "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/prompts/zipball/a1891d362714bc40c8d23b0b1d7090f022ea27cc",
- "reference": "a1891d362714bc40c8d23b0b1d7090f022ea27cc",
+ "url": "https://api.github.com/repos/laravel/prompts/zipball/a19af51bb144bf87f08397921fa619f85c7d4e72",
+ "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2.2",
"ext-mbstring": "*",
"php": "^8.1",
- "symfony/console": "^6.2|^7.0"
+ "symfony/console": "^6.2|^7.0|^8.0"
},
"conflict": {
"illuminate/console": ">=10.17.0 <10.25.0",
"laravel/framework": ">=10.17.0 <10.25.0"
},
"require-dev": {
- "illuminate/collections": "^10.0|^11.0|^12.0",
+ "illuminate/collections": "^10.0|^11.0|^12.0|^13.0",
"mockery/mockery": "^1.5",
- "pestphp/pest": "^2.3|^3.4",
+ "pestphp/pest": "^2.3|^3.4|^4.0",
"phpstan/phpstan": "^1.12.28",
"phpstan/phpstan-mockery": "^1.1.3"
},
@@ -3414,38 +2420,37 @@
"description": "Add beautiful and user-friendly forms to your command-line applications.",
"support": {
"issues": "https://github.com/laravel/prompts/issues",
- "source": "https://github.com/laravel/prompts/tree/v0.3.7"
+ "source": "https://github.com/laravel/prompts/tree/v0.3.18"
},
- "time": "2025-09-19T13:47:56+00:00"
+ "time": "2026-05-19T00:47:18+00:00"
},
{
"name": "laravel/sanctum",
- "version": "v4.1.1",
+ "version": "v4.3.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/sanctum.git",
- "reference": "a360a6a1fd2400ead4eb9b6a9c1bb272939194f5"
+ "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/sanctum/zipball/a360a6a1fd2400ead4eb9b6a9c1bb272939194f5",
- "reference": "a360a6a1fd2400ead4eb9b6a9c1bb272939194f5",
+ "url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e",
+ "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e",
"shasum": ""
},
"require": {
"ext-json": "*",
- "illuminate/console": "^11.0|^12.0",
- "illuminate/contracts": "^11.0|^12.0",
- "illuminate/database": "^11.0|^12.0",
- "illuminate/support": "^11.0|^12.0",
+ "illuminate/console": "^11.0|^12.0|^13.0",
+ "illuminate/contracts": "^11.0|^12.0|^13.0",
+ "illuminate/database": "^11.0|^12.0|^13.0",
+ "illuminate/support": "^11.0|^12.0|^13.0",
"php": "^8.2",
- "symfony/console": "^7.0"
+ "symfony/console": "^7.0|^8.0"
},
"require-dev": {
"mockery/mockery": "^1.6",
- "orchestra/testbench": "^9.0|^10.0",
- "phpstan/phpstan": "^1.10",
- "phpunit/phpunit": "^11.3"
+ "orchestra/testbench": "^9.15|^10.8|^11.0",
+ "phpstan/phpstan": "^1.10"
},
"type": "library",
"extra": {
@@ -3480,31 +2485,31 @@
"issues": "https://github.com/laravel/sanctum/issues",
"source": "https://github.com/laravel/sanctum"
},
- "time": "2025-04-23T13:03:38+00:00"
+ "time": "2026-04-30T11:46:25+00:00"
},
{
"name": "laravel/serializable-closure",
- "version": "v2.0.6",
+ "version": "v2.0.13",
"source": {
"type": "git",
"url": "https://github.com/laravel/serializable-closure.git",
- "reference": "038ce42edee619599a1debb7e81d7b3759492819"
+ "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/038ce42edee619599a1debb7e81d7b3759492819",
- "reference": "038ce42edee619599a1debb7e81d7b3759492819",
+ "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b566ee0dd251f3c4078bed003a7ce015f5ea6dce",
+ "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
- "illuminate/support": "^10.0|^11.0|^12.0",
+ "illuminate/support": "^10.0|^11.0|^12.0|^13.0",
"nesbot/carbon": "^2.67|^3.0",
- "pestphp/pest": "^2.36|^3.0",
+ "pestphp/pest": "^2.36|^3.0|^4.0",
"phpstan/phpstan": "^2.0",
- "symfony/var-dumper": "^6.2.0|^7.0.0"
+ "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0"
},
"type": "library",
"extra": {
@@ -3541,37 +2546,37 @@
"issues": "https://github.com/laravel/serializable-closure/issues",
"source": "https://github.com/laravel/serializable-closure"
},
- "time": "2025-10-09T13:42:30+00:00"
+ "time": "2026-04-16T14:03:50+00:00"
},
{
"name": "laravel/tinker",
- "version": "v2.10.1",
+ "version": "v3.0.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/tinker.git",
- "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3"
+ "reference": "4faba77764bd33411735936acdf30446d058c78b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/tinker/zipball/22177cc71807d38f2810c6204d8f7183d88a57d3",
- "reference": "22177cc71807d38f2810c6204d8f7183d88a57d3",
+ "url": "https://api.github.com/repos/laravel/tinker/zipball/4faba77764bd33411735936acdf30446d058c78b",
+ "reference": "4faba77764bd33411735936acdf30446d058c78b",
"shasum": ""
},
"require": {
- "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
- "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
- "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
- "php": "^7.2.5|^8.0",
- "psy/psysh": "^0.11.1|^0.12.0",
- "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0"
+ "illuminate/console": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
+ "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
+ "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
+ "php": "^8.1",
+ "psy/psysh": "^0.12.0",
+ "symfony/var-dumper": "^5.4|^6.0|^7.0|^8.0"
},
"require-dev": {
"mockery/mockery": "~1.3.3|^1.4.2",
"phpstan/phpstan": "^1.10",
- "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0"
+ "phpunit/phpunit": "^10.5|^11.5"
},
"suggest": {
- "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)."
+ "illuminate/database": "The Illuminate Database package (^8.0|^9.0|^10.0|^11.0|^12.0|^13.0)."
},
"type": "library",
"extra": {
@@ -3579,6 +2584,9 @@
"providers": [
"Laravel\\Tinker\\TinkerServiceProvider"
]
+ },
+ "branch-alias": {
+ "dev-master": "3.x-dev"
}
},
"autoload": {
@@ -3605,22 +2613,22 @@
],
"support": {
"issues": "https://github.com/laravel/tinker/issues",
- "source": "https://github.com/laravel/tinker/tree/v2.10.1"
+ "source": "https://github.com/laravel/tinker/tree/v3.0.2"
},
- "time": "2025-01-27T14:24:01+00:00"
+ "time": "2026-03-17T14:54:13+00:00"
},
{
"name": "laravel/vapor-core",
- "version": "v2.41.0",
+ "version": "v2.43.5",
"source": {
"type": "git",
"url": "https://github.com/laravel/vapor-core.git",
- "reference": "80faabfd88b6b93316e600ade54c5e97bab974fe"
+ "reference": "b815562a14cfc98db6741bc9521c24d08dae60c0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/vapor-core/zipball/80faabfd88b6b93316e600ade54c5e97bab974fe",
- "reference": "80faabfd88b6b93316e600ade54c5e97bab974fe",
+ "url": "https://api.github.com/repos/laravel/vapor-core/zipball/b815562a14cfc98db6741bc9521c24d08dae60c0",
+ "reference": "b815562a14cfc98db6741bc9521c24d08dae60c0",
"shasum": ""
},
"require": {
@@ -3628,23 +2636,23 @@
"guzzlehttp/guzzle": "^6.3|^7.0",
"guzzlehttp/promises": "^1.4|^2.0",
"hollodotme/fast-cgi-client": "^3.0",
- "illuminate/container": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
- "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
- "illuminate/queue": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
- "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
+ "illuminate/container": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
+ "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
+ "illuminate/queue": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
+ "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
"monolog/monolog": "^1.12|^2.0|^3.2",
"nyholm/psr7": "^1.0",
"php": "^7.2|^8.0",
"riverline/multipart-parser": "^2.0.9",
- "symfony/process": "^4.3|^5.0|^6.0|^7.0",
- "symfony/psr-http-message-bridge": "^1.0|^2.0|^6.4|^7.0"
+ "symfony/process": "^4.3|^5.0|^6.0|^7.0|^8.0",
+ "symfony/psr-http-message-bridge": "^1.0|^2.0|^6.4|^7.0|^8.0"
},
"require-dev": {
"laravel/octane": "*",
"mockery/mockery": "^1.2",
- "orchestra/testbench": "^4.0|^5.0|^6.0|^7.0|^8.0|^9.0|^10.0",
+ "orchestra/testbench": "^4.0|^5.0|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0",
"phpstan/phpstan": "^1.10|^2.1",
- "phpunit/phpunit": "^8.0|^9.0|^10.4|^11.5.3"
+ "phpunit/phpunit": "^8.0|^9.0|^10.4|^11.5.3|^12.5.12"
},
"type": "library",
"extra": {
@@ -3685,28 +2693,28 @@
"vapor"
],
"support": {
- "source": "https://github.com/laravel/vapor-core/tree/v2.41.0"
+ "source": "https://github.com/laravel/vapor-core/tree/v2.43.5"
},
- "time": "2025-09-10T14:36:21+00:00"
+ "time": "2026-05-01T12:47:18+00:00"
},
{
"name": "lcobucci/jwt",
- "version": "5.5.0",
+ "version": "5.6.0",
"source": {
"type": "git",
"url": "https://github.com/lcobucci/jwt.git",
- "reference": "a835af59b030d3f2967725697cf88300f579088e"
+ "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/lcobucci/jwt/zipball/a835af59b030d3f2967725697cf88300f579088e",
- "reference": "a835af59b030d3f2967725697cf88300f579088e",
+ "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e",
+ "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"ext-sodium": "*",
- "php": "~8.2.0 || ~8.3.0 || ~8.4.0",
+ "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"psr/clock": "^1.0"
},
"require-dev": {
@@ -3748,7 +2756,7 @@
],
"support": {
"issues": "https://github.com/lcobucci/jwt/issues",
- "source": "https://github.com/lcobucci/jwt/tree/5.5.0"
+ "source": "https://github.com/lcobucci/jwt/tree/5.6.0"
},
"funding": [
{
@@ -3760,20 +2768,20 @@
"type": "patreon"
}
],
- "time": "2025-01-26T21:29:45+00:00"
+ "time": "2025-10-17T11:30:53+00:00"
},
{
"name": "league/commonmark",
- "version": "2.7.1",
+ "version": "2.8.2",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
- "reference": "10732241927d3971d28e7ea7b5712721fa2296ca"
+ "reference": "59fb075d2101740c337c7216e3f32b36c204218b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/10732241927d3971d28e7ea7b5712721fa2296ca",
- "reference": "10732241927d3971d28e7ea7b5712721fa2296ca",
+ "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b",
+ "reference": "59fb075d2101740c337c7216e3f32b36c204218b",
"shasum": ""
},
"require": {
@@ -3798,9 +2806,9 @@
"phpstan/phpstan": "^1.8.2",
"phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0",
"scrutinizer/ocular": "^1.8.1",
- "symfony/finder": "^5.3 | ^6.0 | ^7.0",
- "symfony/process": "^5.4 | ^6.0 | ^7.0",
- "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0",
+ "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0",
+ "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0",
+ "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0",
"unleashedtech/php-coding-standard": "^3.1.1",
"vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0"
},
@@ -3810,7 +2818,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "2.8-dev"
+ "dev-main": "2.9-dev"
}
},
"autoload": {
@@ -3867,7 +2875,7 @@
"type": "tidelift"
}
],
- "time": "2025-07-20T12:47:49+00:00"
+ "time": "2026-03-19T13:16:38+00:00"
},
{
"name": "league/config",
@@ -3953,16 +2961,16 @@
},
{
"name": "league/flysystem",
- "version": "3.30.0",
+ "version": "3.34.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem.git",
- "reference": "2203e3151755d874bb2943649dae1eb8533ac93e"
+ "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2203e3151755d874bb2943649dae1eb8533ac93e",
- "reference": "2203e3151755d874bb2943649dae1eb8533ac93e",
+ "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e",
+ "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e",
"shasum": ""
},
"require": {
@@ -4030,26 +3038,26 @@
],
"support": {
"issues": "https://github.com/thephpleague/flysystem/issues",
- "source": "https://github.com/thephpleague/flysystem/tree/3.30.0"
+ "source": "https://github.com/thephpleague/flysystem/tree/3.34.0"
},
- "time": "2025-06-25T13:29:59+00:00"
+ "time": "2026-05-14T10:28:08+00:00"
},
{
"name": "league/flysystem-aws-s3-v3",
- "version": "3.29.0",
+ "version": "3.34.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
- "reference": "c6ff6d4606e48249b63f269eba7fabdb584e76a9"
+ "reference": "0c62fdac907791d8649ad3c61cb7a77628344fb8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/c6ff6d4606e48249b63f269eba7fabdb584e76a9",
- "reference": "c6ff6d4606e48249b63f269eba7fabdb584e76a9",
+ "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/0c62fdac907791d8649ad3c61cb7a77628344fb8",
+ "reference": "0c62fdac907791d8649ad3c61cb7a77628344fb8",
"shasum": ""
},
"require": {
- "aws/aws-sdk-php": "^3.295.10",
+ "aws/aws-sdk-php": "^3.371.5",
"league/flysystem": "^3.10.0",
"league/mime-type-detection": "^1.0.0",
"php": "^8.0.2"
@@ -4085,22 +3093,22 @@
"storage"
],
"support": {
- "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.29.0"
+ "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.34.0"
},
- "time": "2024-08-17T13:10:48+00:00"
+ "time": "2026-05-04T08:24:00+00:00"
},
{
"name": "league/flysystem-local",
- "version": "3.30.0",
+ "version": "3.31.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem-local.git",
- "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10"
+ "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/6691915f77c7fb69adfb87dcd550052dc184ee10",
- "reference": "6691915f77c7fb69adfb87dcd550052dc184ee10",
+ "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079",
+ "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079",
"shasum": ""
},
"require": {
@@ -4134,9 +3142,9 @@
"local"
],
"support": {
- "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.0"
+ "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0"
},
- "time": "2025-05-21T10:34:19+00:00"
+ "time": "2026-01-23T15:30:45+00:00"
},
{
"name": "league/mime-type-detection",
@@ -4196,33 +3204,38 @@
},
{
"name": "league/uri",
- "version": "7.5.1",
+ "version": "7.8.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/uri.git",
- "reference": "81fb5145d2644324614cc532b28efd0215bda430"
+ "reference": "08cf38e3924d4f56238125547b5720496fac8fd4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430",
- "reference": "81fb5145d2644324614cc532b28efd0215bda430",
+ "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4",
+ "reference": "08cf38e3924d4f56238125547b5720496fac8fd4",
"shasum": ""
},
"require": {
- "league/uri-interfaces": "^7.5",
- "php": "^8.1"
+ "league/uri-interfaces": "^7.8.1",
+ "php": "^8.1",
+ "psr/http-factory": "^1"
},
"conflict": {
"league/uri-schemes": "^1.0"
},
"suggest": {
"ext-bcmath": "to improve IPV4 host parsing",
+ "ext-dom": "to convert the URI into an HTML anchor tag",
"ext-fileinfo": "to create Data URI from file contennts",
"ext-gmp": "to improve IPV4 host parsing",
"ext-intl": "to handle IDN host with the best performance",
- "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain",
- "league/uri-components": "Needed to easily manipulate URI objects components",
+ "ext-uri": "to use the PHP native URI class",
+ "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain",
+ "league/uri-components": "to provide additional tools to manipulate URI objects components",
+ "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP",
"php-64bit": "to improve IPV4 host parsing",
+ "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification",
"symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
},
"type": "library",
@@ -4250,6 +3263,7 @@
"description": "URI manipulation library",
"homepage": "https://uri.thephpleague.com",
"keywords": [
+ "URN",
"data-uri",
"file-uri",
"ftp",
@@ -4262,9 +3276,11 @@
"psr-7",
"query-string",
"querystring",
+ "rfc2141",
"rfc3986",
"rfc3987",
"rfc6570",
+ "rfc8141",
"uri",
"uri-template",
"url",
@@ -4274,7 +3290,7 @@
"docs": "https://uri.thephpleague.com",
"forum": "https://thephpleague.slack.com",
"issues": "https://github.com/thephpleague/uri-src/issues",
- "source": "https://github.com/thephpleague/uri/tree/7.5.1"
+ "source": "https://github.com/thephpleague/uri/tree/7.8.1"
},
"funding": [
{
@@ -4282,26 +3298,25 @@
"type": "github"
}
],
- "time": "2024-12-08T08:40:02+00:00"
+ "time": "2026-03-15T20:22:25+00:00"
},
{
"name": "league/uri-interfaces",
- "version": "7.5.0",
+ "version": "7.8.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/uri-interfaces.git",
- "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742"
+ "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742",
- "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742",
+ "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928",
+ "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928",
"shasum": ""
},
"require": {
"ext-filter": "*",
"php": "^8.1",
- "psr/http-factory": "^1",
"psr/http-message": "^1.1 || ^2.0"
},
"suggest": {
@@ -4309,6 +3324,7 @@
"ext-gmp": "to improve IPV4 host parsing",
"ext-intl": "to handle IDN host with the best performance",
"php-64bit": "to improve IPV4 host parsing",
+ "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification",
"symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
},
"type": "library",
@@ -4333,7 +3349,7 @@
"homepage": "https://nyamsprod.com"
}
],
- "description": "Common interfaces and classes for URI representation and interaction",
+ "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI",
"homepage": "https://uri.thephpleague.com",
"keywords": [
"data-uri",
@@ -4358,7 +3374,7 @@
"docs": "https://uri.thephpleague.com",
"forum": "https://thephpleague.slack.com",
"issues": "https://github.com/thephpleague/uri-src/issues",
- "source": "https://github.com/thephpleague/uri-interfaces/tree/7.5.0"
+ "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1"
},
"funding": [
{
@@ -4366,7 +3382,7 @@
"type": "github"
}
],
- "time": "2024-12-08T08:18:47+00:00"
+ "time": "2026-03-08T20:05:35+00:00"
},
{
"name": "liquid/liquid",
@@ -4439,30 +3455,40 @@
},
{
"name": "maatwebsite/excel",
- "version": "3.1.67",
+ "version": "4.x-dev",
"source": {
"type": "git",
"url": "https://github.com/SpartnerNL/Laravel-Excel.git",
- "reference": "e508e34a502a3acc3329b464dad257378a7edb4d"
+ "reference": "86cce13606e7cdf0f0b02007307c82d5c0b5fb3e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/e508e34a502a3acc3329b464dad257378a7edb4d",
- "reference": "e508e34a502a3acc3329b464dad257378a7edb4d",
+ "url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/86cce13606e7cdf0f0b02007307c82d5c0b5fb3e",
+ "reference": "86cce13606e7cdf0f0b02007307c82d5c0b5fb3e",
"shasum": ""
},
"require": {
"composer/semver": "^3.3",
"ext-json": "*",
- "illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0",
- "php": "^7.0||^8.0",
- "phpoffice/phpspreadsheet": "^1.30.0",
+ "illuminate/support": "^12.0||^13.0",
+ "php": "^8.3",
+ "phpoffice/phpspreadsheet": "^5.3",
"psr/simple-cache": "^1.0||^2.0||^3.0"
},
"require-dev": {
- "laravel/scout": "^7.0||^8.0||^9.0||^10.0",
- "orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0",
- "predis/predis": "^1.1"
+ "brianium/paratest": "^7.19||^8.0",
+ "driftingly/rector-laravel": "^2.3",
+ "ext-sqlite3": "*",
+ "larastan/larastan": "^3.9",
+ "laravel/pint": "^1.0",
+ "laravel/scout": "^10.0||^11.0",
+ "orchestra/testbench": "^10.0||^11.0",
+ "phpstan/extension-installer": "^1.4",
+ "phpstan/phpstan": "^2.1.55",
+ "phpstan/phpstan-mockery": "^2.0",
+ "phpunit/phpunit": "^12.5.3||^13.0.0",
+ "predis/predis": "^1.1",
+ "rector/rector": "^2.4.2"
},
"type": "library",
"extra": {
@@ -4504,7 +3530,7 @@
],
"support": {
"issues": "https://github.com/SpartnerNL/Laravel-Excel/issues",
- "source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.67"
+ "source": "https://github.com/SpartnerNL/Laravel-Excel/tree/4.x"
},
"funding": [
{
@@ -4516,35 +3542,35 @@
"type": "github"
}
],
- "time": "2025-08-26T09:13:16+00:00"
+ "time": "2026-05-24T11:02:38+00:00"
},
{
"name": "maennchen/zipstream-php",
- "version": "3.1.2",
+ "version": "3.2.2",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
- "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f"
+ "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f",
- "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f",
+ "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
+ "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
- "php-64bit": "^8.2"
+ "php-64bit": "^8.3"
},
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
- "friendsofphp/php-cs-fixer": "^3.16",
+ "friendsofphp/php-cs-fixer": "^3.86",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
- "phpunit/phpunit": "^11.0",
+ "phpunit/phpunit": "^12.0",
"vimeo/psalm": "^6.0"
},
"suggest": {
@@ -4586,7 +3612,7 @@
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
- "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2"
+ "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
},
"funding": [
{
@@ -4594,7 +3620,7 @@
"type": "github"
}
],
- "time": "2025-01-27T12:07:53+00:00"
+ "time": "2026-04-11T18:38:28+00:00"
},
{
"name": "markbaker/complex",
@@ -4705,16 +3731,16 @@
},
{
"name": "masterminds/html5",
- "version": "2.9.0",
+ "version": "2.10.0",
"source": {
"type": "git",
"url": "https://github.com/Masterminds/html5-php.git",
- "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6"
+ "reference": "fcf91eb64359852f00d921887b219479b4f21251"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/f5ac2c0b0a2eefca70b2ce32a5809992227e75a6",
- "reference": "f5ac2c0b0a2eefca70b2ce32a5809992227e75a6",
+ "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
+ "reference": "fcf91eb64359852f00d921887b219479b4f21251",
"shasum": ""
},
"require": {
@@ -4766,22 +3792,22 @@
],
"support": {
"issues": "https://github.com/Masterminds/html5-php/issues",
- "source": "https://github.com/Masterminds/html5-php/tree/2.9.0"
+ "source": "https://github.com/Masterminds/html5-php/tree/2.10.0"
},
- "time": "2024-03-31T07:05:07+00:00"
+ "time": "2025-07-25T09:04:22+00:00"
},
{
"name": "monolog/monolog",
- "version": "3.9.0",
+ "version": "3.10.0",
"source": {
"type": "git",
"url": "https://github.com/Seldaek/monolog.git",
- "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6"
+ "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6",
- "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6",
+ "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0",
+ "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0",
"shasum": ""
},
"require": {
@@ -4799,7 +3825,7 @@
"graylog2/gelf-php": "^1.4.2 || ^2.0",
"guzzlehttp/guzzle": "^7.4.5",
"guzzlehttp/psr7": "^2.2",
- "mongodb/mongodb": "^1.8",
+ "mongodb/mongodb": "^1.8 || ^2.0",
"php-amqplib/php-amqplib": "~2.4 || ^3",
"php-console/php-console": "^3.1.8",
"phpstan/phpstan": "^2",
@@ -4859,7 +3885,7 @@
],
"support": {
"issues": "https://github.com/Seldaek/monolog/issues",
- "source": "https://github.com/Seldaek/monolog/tree/3.9.0"
+ "source": "https://github.com/Seldaek/monolog/tree/3.10.0"
},
"funding": [
{
@@ -4871,7 +3897,7 @@
"type": "tidelift"
}
],
- "time": "2025-03-24T10:02:05+00:00"
+ "time": "2026-01-02T08:56:05+00:00"
},
{
"name": "mtdowling/jmespath.php",
@@ -5008,16 +4034,16 @@
},
{
"name": "nesbot/carbon",
- "version": "3.10.3",
+ "version": "3.11.4",
"source": {
"type": "git",
"url": "https://github.com/CarbonPHP/carbon.git",
- "reference": "8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f"
+ "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f",
- "reference": "8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f",
+ "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60",
+ "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60",
"shasum": ""
},
"require": {
@@ -5025,9 +4051,9 @@
"ext-json": "*",
"php": "^8.1",
"psr/clock": "^1.0",
- "symfony/clock": "^6.3.12 || ^7.0",
+ "symfony/clock": "^6.3.12 || ^7.0 || ^8.0",
"symfony/polyfill-mbstring": "^1.0",
- "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0"
+ "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0"
},
"provide": {
"psr/clock-implementation": "1.0"
@@ -5041,7 +4067,7 @@
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^2.1.22",
"phpunit/phpunit": "^10.5.53",
- "squizlabs/php_codesniffer": "^3.13.4"
+ "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0"
},
"bin": [
"bin/carbon"
@@ -5084,14 +4110,14 @@
}
],
"description": "An API extension for DateTime that supports 281 different languages.",
- "homepage": "https://carbon.nesbot.com",
+ "homepage": "https://carbonphp.github.io/carbon/",
"keywords": [
"date",
"datetime",
"time"
],
"support": {
- "docs": "https://carbon.nesbot.com/docs",
+ "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html",
"issues": "https://github.com/CarbonPHP/carbon/issues",
"source": "https://github.com/CarbonPHP/carbon"
},
@@ -5109,31 +4135,33 @@
"type": "tidelift"
}
],
- "time": "2025-09-06T13:39:36+00:00"
+ "time": "2026-04-07T09:57:54+00:00"
},
{
"name": "nette/php-generator",
- "version": "v4.1.8",
+ "version": "v4.2.2",
"source": {
"type": "git",
"url": "https://github.com/nette/php-generator.git",
- "reference": "42806049a7774a2bd316c958f5dcf01c6b5c56fa"
+ "reference": "0d7060926f5c3e8c488b9b9ced42d857f12a34b5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nette/php-generator/zipball/42806049a7774a2bd316c958f5dcf01c6b5c56fa",
- "reference": "42806049a7774a2bd316c958f5dcf01c6b5c56fa",
+ "url": "https://api.github.com/repos/nette/php-generator/zipball/0d7060926f5c3e8c488b9b9ced42d857f12a34b5",
+ "reference": "0d7060926f5c3e8c488b9b9ced42d857f12a34b5",
"shasum": ""
},
"require": {
- "nette/utils": "^3.2.9 || ^4.0",
- "php": "8.0 - 8.4"
+ "nette/utils": "^4.0.6",
+ "php": "8.1 - 8.5"
},
"require-dev": {
- "jetbrains/phpstorm-attributes": "dev-master",
- "nette/tester": "^2.4",
- "nikic/php-parser": "^4.18 || ^5.0",
- "phpstan/phpstan": "^1.0",
+ "jetbrains/phpstorm-attributes": "^1.2",
+ "nette/phpstan-rules": "^1.0",
+ "nette/tester": "^2.6",
+ "nikic/php-parser": "^5.0",
+ "phpstan/extension-installer": "^1.4@stable",
+ "phpstan/phpstan": "^2.1.40@stable",
"tracy/tracy": "^2.8"
},
"suggest": {
@@ -5142,10 +4170,13 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "4.1-dev"
+ "dev-master": "4.2-dev"
}
},
"autoload": {
+ "psr-4": {
+ "Nette\\": "src"
+ },
"classmap": [
"src/"
]
@@ -5166,7 +4197,7 @@
"homepage": "https://nette.org/contributors"
}
],
- "description": "🐘 Nette PHP Generator: generates neat PHP code for you. Supports new PHP 8.4 features.",
+ "description": "🐘 Nette PHP Generator: generates neat PHP code for you. Supports new PHP 8.5 features.",
"homepage": "https://nette.org",
"keywords": [
"code",
@@ -5176,31 +4207,33 @@
],
"support": {
"issues": "https://github.com/nette/php-generator/issues",
- "source": "https://github.com/nette/php-generator/tree/v4.1.8"
+ "source": "https://github.com/nette/php-generator/tree/v4.2.2"
},
- "time": "2025-03-31T00:29:29+00:00"
+ "time": "2026-02-26T00:58:33+00:00"
},
{
"name": "nette/schema",
- "version": "v1.3.2",
+ "version": "v1.3.5",
"source": {
"type": "git",
"url": "https://github.com/nette/schema.git",
- "reference": "da801d52f0354f70a638673c4a0f04e16529431d"
+ "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nette/schema/zipball/da801d52f0354f70a638673c4a0f04e16529431d",
- "reference": "da801d52f0354f70a638673c4a0f04e16529431d",
+ "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002",
+ "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002",
"shasum": ""
},
"require": {
"nette/utils": "^4.0",
- "php": "8.1 - 8.4"
+ "php": "8.1 - 8.5"
},
"require-dev": {
- "nette/tester": "^2.5.2",
- "phpstan/phpstan-nette": "^1.0",
+ "nette/phpstan-rules": "^1.0",
+ "nette/tester": "^2.6",
+ "phpstan/extension-installer": "^1.4@stable",
+ "phpstan/phpstan": "^2.1.39@stable",
"tracy/tracy": "^2.8"
},
"type": "library",
@@ -5210,6 +4243,9 @@
}
},
"autoload": {
+ "psr-4": {
+ "Nette\\": "src"
+ },
"classmap": [
"src/"
]
@@ -5238,26 +4274,26 @@
],
"support": {
"issues": "https://github.com/nette/schema/issues",
- "source": "https://github.com/nette/schema/tree/v1.3.2"
+ "source": "https://github.com/nette/schema/tree/v1.3.5"
},
- "time": "2024-10-06T23:10:23+00:00"
+ "time": "2026-02-23T03:47:12+00:00"
},
{
"name": "nette/utils",
- "version": "v4.0.8",
+ "version": "v4.1.4",
"source": {
"type": "git",
"url": "https://github.com/nette/utils.git",
- "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede"
+ "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nette/utils/zipball/c930ca4e3cf4f17dcfb03037703679d2396d2ede",
- "reference": "c930ca4e3cf4f17dcfb03037703679d2396d2ede",
+ "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
+ "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
"shasum": ""
},
"require": {
- "php": "8.0 - 8.5"
+ "php": "8.2 - 8.5"
},
"conflict": {
"nette/finder": "<3",
@@ -5265,8 +4301,10 @@
},
"require-dev": {
"jetbrains/phpstorm-attributes": "^1.2",
+ "nette/phpstan-rules": "^1.0",
"nette/tester": "^2.5",
- "phpstan/phpstan-nette": "^2.0@stable",
+ "phpstan/extension-installer": "^1.4@stable",
+ "phpstan/phpstan": "^2.1@stable",
"tracy/tracy": "^2.9"
},
"suggest": {
@@ -5280,7 +4318,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "4.0-dev"
+ "dev-master": "4.1-dev"
}
},
"autoload": {
@@ -5327,22 +4365,22 @@
],
"support": {
"issues": "https://github.com/nette/utils/issues",
- "source": "https://github.com/nette/utils/tree/v4.0.8"
+ "source": "https://github.com/nette/utils/tree/v4.1.4"
},
- "time": "2025-08-06T21:43:34+00:00"
+ "time": "2026-05-11T20:49:54+00:00"
},
{
"name": "nikic/php-parser",
- "version": "v5.6.1",
+ "version": "v5.7.0",
"source": {
"type": "git",
"url": "https://github.com/nikic/PHP-Parser.git",
- "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2"
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2",
- "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
"shasum": ""
},
"require": {
@@ -5385,37 +4423,37 @@
],
"support": {
"issues": "https://github.com/nikic/PHP-Parser/issues",
- "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.1"
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
},
- "time": "2025-08-13T20:13:15+00:00"
+ "time": "2025-12-06T11:56:16+00:00"
},
{
"name": "nunomaduro/termwind",
- "version": "v2.3.1",
+ "version": "v2.4.0",
"source": {
"type": "git",
"url": "https://github.com/nunomaduro/termwind.git",
- "reference": "dfa08f390e509967a15c22493dc0bac5733d9123"
+ "reference": "712a31b768f5daea284c2169a7d227031001b9a8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/dfa08f390e509967a15c22493dc0bac5733d9123",
- "reference": "dfa08f390e509967a15c22493dc0bac5733d9123",
+ "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8",
+ "reference": "712a31b768f5daea284c2169a7d227031001b9a8",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^8.2",
- "symfony/console": "^7.2.6"
+ "symfony/console": "^7.4.4 || ^8.0.4"
},
"require-dev": {
- "illuminate/console": "^11.44.7",
- "laravel/pint": "^1.22.0",
+ "illuminate/console": "^11.47.0",
+ "laravel/pint": "^1.27.1",
"mockery/mockery": "^1.6.12",
- "pestphp/pest": "^2.36.0 || ^3.8.2",
- "phpstan/phpstan": "^1.12.25",
+ "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2",
+ "phpstan/phpstan": "^1.12.32",
"phpstan/phpstan-strict-rules": "^1.6.2",
- "symfony/var-dumper": "^7.2.6",
+ "symfony/var-dumper": "^7.3.5 || ^8.0.4",
"thecodingmachine/phpstan-strict-rules": "^1.0.0"
},
"type": "library",
@@ -5447,7 +4485,7 @@
"email": "enunomaduro@gmail.com"
}
],
- "description": "Its like Tailwind CSS, but for the console.",
+ "description": "It's like Tailwind CSS, but for the console.",
"keywords": [
"cli",
"console",
@@ -5458,7 +4496,7 @@
],
"support": {
"issues": "https://github.com/nunomaduro/termwind/issues",
- "source": "https://github.com/nunomaduro/termwind/tree/v2.3.1"
+ "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0"
},
"funding": [
{
@@ -5474,7 +4512,7 @@
"type": "github"
}
],
- "time": "2025-05-08T08:14:37+00:00"
+ "time": "2026-02-16T23:10:27+00:00"
},
{
"name": "nyholm/psr7",
@@ -5556,35 +4594,35 @@
},
{
"name": "php-open-source-saver/jwt-auth",
- "version": "v2.8.2",
+ "version": "2.9.2",
"source": {
"type": "git",
"url": "https://github.com/PHP-Open-Source-Saver/jwt-auth.git",
- "reference": "9af3bd953b5671247c330562183e159f10700533"
+ "reference": "ce08363a9986e5253efd3663ed4f75c976bec89a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHP-Open-Source-Saver/jwt-auth/zipball/9af3bd953b5671247c330562183e159f10700533",
- "reference": "9af3bd953b5671247c330562183e159f10700533",
+ "url": "https://api.github.com/repos/PHP-Open-Source-Saver/jwt-auth/zipball/ce08363a9986e5253efd3663ed4f75c976bec89a",
+ "reference": "ce08363a9986e5253efd3663ed4f75c976bec89a",
"shasum": ""
},
"require": {
"ext-json": "*",
- "illuminate/auth": "^10|^11|^12",
- "illuminate/contracts": "^10|^11|^12",
- "illuminate/http": "^10|^11|^12",
- "illuminate/support": "^10|^11|^12",
+ "illuminate/auth": "^12|^13",
+ "illuminate/contracts": "^12|^13",
+ "illuminate/http": "^12|^13",
+ "illuminate/support": "^12|^13",
"lcobucci/jwt": "^5.4",
"namshi/jose": "^7.0",
"nesbot/carbon": "^2.0|^3.0",
- "php": "^8.2"
+ "php": "^8.3"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3",
- "illuminate/console": "^10|^11|^12",
- "illuminate/routing": "^10|^11|^12",
+ "illuminate/console": "^12|^13",
+ "illuminate/routing": "^12|^13",
"mockery/mockery": "^1.6",
- "orchestra/testbench": "^8|^9|^10",
+ "orchestra/testbench": "^10|^11",
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^10.5|^11"
},
@@ -5598,9 +4636,6 @@
"providers": [
"PHPOpenSourceSaver\\JWTAuth\\Providers\\LaravelServiceProvider"
]
- },
- "branch-alias": {
- "dev-develop": "2.0-dev"
}
},
"autoload": {
@@ -5648,79 +4683,7 @@
"issues": "https://github.com/PHP-Open-Source-Saver/jwt-auth/issues",
"source": "https://github.com/PHP-Open-Source-Saver/jwt-auth"
},
- "time": "2025-03-17T11:41:37+00:00"
- },
- {
- "name": "phpdocumentor/reflection",
- "version": "6.3.0",
- "source": {
- "type": "git",
- "url": "https://github.com/phpDocumentor/Reflection.git",
- "reference": "d91b3270832785602adcc24ae2d0974ba99a8ff8"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/phpDocumentor/Reflection/zipball/d91b3270832785602adcc24ae2d0974ba99a8ff8",
- "reference": "d91b3270832785602adcc24ae2d0974ba99a8ff8",
- "shasum": ""
- },
- "require": {
- "composer-runtime-api": "^2",
- "nikic/php-parser": "~4.18 || ^5.0",
- "php": "8.1.*|8.2.*|8.3.*|8.4.*",
- "phpdocumentor/reflection-common": "^2.1",
- "phpdocumentor/reflection-docblock": "^5",
- "phpdocumentor/type-resolver": "^1.2",
- "symfony/polyfill-php80": "^1.28",
- "webmozart/assert": "^1.7"
- },
- "require-dev": {
- "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
- "doctrine/coding-standard": "^13.0",
- "eliashaeussler/phpunit-attributes": "^1.7",
- "mikey179/vfsstream": "~1.2",
- "mockery/mockery": "~1.6.0",
- "phpspec/prophecy-phpunit": "^2.0",
- "phpstan/extension-installer": "^1.1",
- "phpstan/phpstan": "^1.8",
- "phpstan/phpstan-webmozart-assert": "^1.2",
- "phpunit/phpunit": "^10.0",
- "psalm/phar": "^6.0",
- "rector/rector": "^1.0.0",
- "squizlabs/php_codesniffer": "^3.8"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-5.x": "5.3.x-dev",
- "dev-6.x": "6.0.x-dev"
- }
- },
- "autoload": {
- "files": [
- "src/php-parser/Modifiers.php"
- ],
- "psr-4": {
- "phpDocumentor\\": "src/phpDocumentor"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "description": "Reflection library to do Static Analysis for PHP Projects",
- "homepage": "http://www.phpdoc.org",
- "keywords": [
- "phpDocumentor",
- "phpdoc",
- "reflection",
- "static analysis"
- ],
- "support": {
- "issues": "https://github.com/phpDocumentor/Reflection/issues",
- "source": "https://github.com/phpDocumentor/Reflection/tree/6.3.0"
- },
- "time": "2025-06-06T13:39:18+00:00"
+ "time": "2026-05-07T16:44:01+00:00"
},
{
"name": "phpdocumentor/reflection-common",
@@ -5777,16 +4740,16 @@
},
{
"name": "phpdocumentor/reflection-docblock",
- "version": "5.6.3",
+ "version": "6.0.3",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
- "reference": "94f8051919d1b0369a6bcc7931d679a511c03fe9"
+ "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/94f8051919d1b0369a6bcc7931d679a511c03fe9",
- "reference": "94f8051919d1b0369a6bcc7931d679a511c03fe9",
+ "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582",
+ "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582",
"shasum": ""
},
"require": {
@@ -5794,9 +4757,9 @@
"ext-filter": "*",
"php": "^7.4 || ^8.0",
"phpdocumentor/reflection-common": "^2.2",
- "phpdocumentor/type-resolver": "^1.7",
- "phpstan/phpdoc-parser": "^1.7|^2.0",
- "webmozart/assert": "^1.9.1"
+ "phpdocumentor/type-resolver": "^2.0",
+ "phpstan/phpdoc-parser": "^2.0",
+ "webmozart/assert": "^1.9.1 || ^2"
},
"require-dev": {
"mockery/mockery": "~1.3.5 || ~1.6.0",
@@ -5805,7 +4768,8 @@
"phpstan/phpstan-mockery": "^1.1",
"phpstan/phpstan-webmozart-assert": "^1.2",
"phpunit/phpunit": "^9.5",
- "psalm/phar": "^5.26"
+ "psalm/phar": "^5.26",
+ "shipmonk/dead-code-detector": "^0.5.1"
},
"type": "library",
"extra": {
@@ -5835,44 +4799,44 @@
"description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
"support": {
"issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
- "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.3"
+ "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3"
},
- "time": "2025-08-01T19:43:32+00:00"
+ "time": "2026-03-18T20:49:53+00:00"
},
{
"name": "phpdocumentor/type-resolver",
- "version": "1.10.0",
+ "version": "2.0.0",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/TypeResolver.git",
- "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a"
+ "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/679e3ce485b99e84c775d28e2e96fade9a7fb50a",
- "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a",
+ "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9",
+ "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9",
"shasum": ""
},
"require": {
"doctrine/deprecations": "^1.0",
- "php": "^7.3 || ^8.0",
+ "php": "^7.4 || ^8.0",
"phpdocumentor/reflection-common": "^2.0",
- "phpstan/phpdoc-parser": "^1.18|^2.0"
+ "phpstan/phpdoc-parser": "^2.0"
},
"require-dev": {
"ext-tokenizer": "*",
"phpbench/phpbench": "^1.2",
- "phpstan/extension-installer": "^1.1",
- "phpstan/phpstan": "^1.8",
- "phpstan/phpstan-phpunit": "^1.1",
+ "phpstan/extension-installer": "^1.4",
+ "phpstan/phpstan": "^2.1",
+ "phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
- "rector/rector": "^0.13.9",
- "vimeo/psalm": "^4.25"
+ "psalm/phar": "^4"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-1.x": "1.x-dev"
+ "dev-1.x": "1.x-dev",
+ "dev-2.x": "2.x-dev"
}
},
"autoload": {
@@ -5893,22 +4857,22 @@
"description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
"support": {
"issues": "https://github.com/phpDocumentor/TypeResolver/issues",
- "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.10.0"
+ "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0"
},
- "time": "2024-11-09T15:12:26+00:00"
+ "time": "2026-01-06T21:53:42+00:00"
},
{
"name": "phpoffice/phpspreadsheet",
- "version": "1.30.0",
+ "version": "5.7.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
- "reference": "2f39286e0136673778b7a142b3f0d141e43d1714"
+ "reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/2f39286e0136673778b7a142b3f0d141e43d1714",
- "reference": "2f39286e0136673778b7a142b3f0d141e43d1714",
+ "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
+ "reference": "9f55d3b9b7bcb1084fda8340e4b7ce4ed10cd0c8",
"shasum": ""
},
"require": {
@@ -5916,6 +4880,7 @@
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
+ "ext-filter": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
@@ -5926,31 +4891,30 @@
"ext-xmlwriter": "*",
"ext-zip": "*",
"ext-zlib": "*",
- "ezyang/htmlpurifier": "^4.15",
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
- "php": "^7.4 || ^8.0",
- "psr/http-client": "^1.0",
- "psr/http-factory": "^1.0",
+ "php": "^8.1",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
- "dompdf/dompdf": "^1.0 || ^2.0 || ^3.0",
+ "dompdf/dompdf": "^2.0 || ^3.0",
+ "ext-intl": "*",
"friendsofphp/php-cs-fixer": "^3.2",
- "mitoteam/jpgraph": "^10.3",
+ "mitoteam/jpgraph": "^10.5",
"mpdf/mpdf": "^8.1.1",
"phpcompatibility/php-compatibility": "^9.3",
- "phpstan/phpstan": "^1.1",
- "phpstan/phpstan-phpunit": "^1.0",
- "phpunit/phpunit": "^8.5 || ^9.0",
+ "phpstan/phpstan": "^1.1 || ^2.0",
+ "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
+ "phpstan/phpstan-phpunit": "^1.0 || ^2.0",
+ "phpunit/phpunit": "^10.5",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
},
"suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
- "ext-intl": "PHP Internationalization Functions",
+ "ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
@@ -5983,6 +4947,9 @@
},
{
"name": "Adrien Crivelli"
+ },
+ {
+ "name": "Owen Leibman"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
@@ -5999,22 +4966,22 @@
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
- "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.0"
+ "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.7.0"
},
- "time": "2025-08-10T06:28:02+00:00"
+ "time": "2026-04-20T02:42:17+00:00"
},
{
"name": "phpoption/phpoption",
- "version": "1.9.4",
+ "version": "1.9.5",
"source": {
"type": "git",
"url": "https://github.com/schmittjoh/php-option.git",
- "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d"
+ "reference": "75365b91986c2405cf5e1e012c5595cd487a98be"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d",
- "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d",
+ "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be",
+ "reference": "75365b91986c2405cf5e1e012c5595cd487a98be",
"shasum": ""
},
"require": {
@@ -6064,7 +5031,7 @@
],
"support": {
"issues": "https://github.com/schmittjoh/php-option/issues",
- "source": "https://github.com/schmittjoh/php-option/tree/1.9.4"
+ "source": "https://github.com/schmittjoh/php-option/tree/1.9.5"
},
"funding": [
{
@@ -6076,20 +5043,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-21T11:53:16+00:00"
+ "time": "2025-12-27T19:41:33+00:00"
},
{
"name": "phpstan/phpdoc-parser",
- "version": "2.3.0",
+ "version": "2.3.2",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git",
- "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495"
+ "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495",
- "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495",
+ "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a",
+ "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a",
"shasum": ""
},
"require": {
@@ -6121,9 +5088,9 @@
"description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
- "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0"
+ "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2"
},
- "time": "2025-08-30T15:50:23+00:00"
+ "time": "2026-01-25T14:56:51+00:00"
},
{
"name": "psr/cache",
@@ -6588,16 +5555,16 @@
},
{
"name": "psy/psysh",
- "version": "v0.12.8",
+ "version": "v0.12.23",
"source": {
"type": "git",
"url": "https://github.com/bobthecow/psysh.git",
- "reference": "85057ceedee50c49d4f6ecaff73ee96adb3b3625"
+ "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/bobthecow/psysh/zipball/85057ceedee50c49d4f6ecaff73ee96adb3b3625",
- "reference": "85057ceedee50c49d4f6ecaff73ee96adb3b3625",
+ "url": "https://api.github.com/repos/bobthecow/psysh/zipball/4dcc0f08047d52bbde475eda481146fd8e27e1a4",
+ "reference": "4dcc0f08047d52bbde475eda481146fd8e27e1a4",
"shasum": ""
},
"require": {
@@ -6605,18 +5572,19 @@
"ext-tokenizer": "*",
"nikic/php-parser": "^5.0 || ^4.0",
"php": "^8.0 || ^7.4",
- "symfony/console": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4",
- "symfony/var-dumper": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4"
+ "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4",
+ "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4"
},
"conflict": {
"symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4"
},
"require-dev": {
- "bamarni/composer-bin-plugin": "^1.2"
+ "bamarni/composer-bin-plugin": "^1.2",
+ "composer/class-map-generator": "^1.6"
},
"suggest": {
+ "composer/class-map-generator": "Improved tab completion performance with better class discovery.",
"ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)",
- "ext-pdo-sqlite": "The doc command requires SQLite to work.",
"ext-posix": "If you have PCNTL, you'll want the POSIX extension as well."
},
"bin": [
@@ -6647,12 +5615,11 @@
"authors": [
{
"name": "Justin Hileman",
- "email": "justin@justinhileman.info",
- "homepage": "http://justinhileman.com"
+ "email": "justin@justinhileman.info"
}
],
"description": "An interactive shell for modern PHP.",
- "homepage": "http://psysh.org",
+ "homepage": "https://psysh.org",
"keywords": [
"REPL",
"console",
@@ -6661,9 +5628,9 @@
],
"support": {
"issues": "https://github.com/bobthecow/psysh/issues",
- "source": "https://github.com/bobthecow/psysh/tree/v0.12.8"
+ "source": "https://github.com/bobthecow/psysh/tree/v0.12.23"
},
- "time": "2025-03-16T03:05:19+00:00"
+ "time": "2026-05-23T13:41:31+00:00"
},
{
"name": "ralouphie/getallheaders",
@@ -6787,20 +5754,20 @@
},
{
"name": "ramsey/uuid",
- "version": "4.9.1",
+ "version": "4.9.2",
"source": {
"type": "git",
"url": "https://github.com/ramsey/uuid.git",
- "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440"
+ "reference": "8429c78ca35a09f27565311b98101e2826affde0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440",
- "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440",
+ "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0",
+ "reference": "8429c78ca35a09f27565311b98101e2826affde0",
"shasum": ""
},
"require": {
- "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14",
+ "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14",
"php": "^8.0",
"ramsey/collection": "^1.2 || ^2.0"
},
@@ -6859,105 +5826,33 @@
],
"support": {
"issues": "https://github.com/ramsey/uuid/issues",
- "source": "https://github.com/ramsey/uuid/tree/4.9.1"
- },
- "time": "2025-09-04T20:59:21+00:00"
- },
- {
- "name": "revolt/event-loop",
- "version": "v1.0.7",
- "source": {
- "type": "git",
- "url": "https://github.com/revoltphp/event-loop.git",
- "reference": "09bf1bf7f7f574453efe43044b06fafe12216eb3"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/09bf1bf7f7f574453efe43044b06fafe12216eb3",
- "reference": "09bf1bf7f7f574453efe43044b06fafe12216eb3",
- "shasum": ""
- },
- "require": {
- "php": ">=8.1"
- },
- "require-dev": {
- "ext-json": "*",
- "jetbrains/phpstorm-stubs": "^2019.3",
- "phpunit/phpunit": "^9",
- "psalm/phar": "^5.15"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "1.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Revolt\\": "src"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Aaron Piotrowski",
- "email": "aaron@trowski.com"
- },
- {
- "name": "Cees-Jan Kiewiet",
- "email": "ceesjank@gmail.com"
- },
- {
- "name": "Christian Lück",
- "email": "christian@clue.engineering"
- },
- {
- "name": "Niklas Keller",
- "email": "me@kelunik.com"
- }
- ],
- "description": "Rock-solid event loop for concurrent PHP applications.",
- "keywords": [
- "async",
- "asynchronous",
- "concurrency",
- "event",
- "event-loop",
- "non-blocking",
- "scheduler"
- ],
- "support": {
- "issues": "https://github.com/revoltphp/event-loop/issues",
- "source": "https://github.com/revoltphp/event-loop/tree/v1.0.7"
+ "source": "https://github.com/ramsey/uuid/tree/4.9.2"
},
- "time": "2025-01-25T19:27:39+00:00"
+ "time": "2025-12-14T04:43:48+00:00"
},
{
"name": "riverline/multipart-parser",
- "version": "2.2.0",
+ "version": "2.2.2",
"source": {
"type": "git",
"url": "https://github.com/Riverline/multipart-parser.git",
- "reference": "1410f23a8fd416a0cf5c8867ea9c95544016c831"
+ "reference": "fadbb1c1f8e66f96eaa36ab8ed13cbc451c6ded7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Riverline/multipart-parser/zipball/1410f23a8fd416a0cf5c8867ea9c95544016c831",
- "reference": "1410f23a8fd416a0cf5c8867ea9c95544016c831",
+ "url": "https://api.github.com/repos/Riverline/multipart-parser/zipball/fadbb1c1f8e66f96eaa36ab8ed13cbc451c6ded7",
+ "reference": "fadbb1c1f8e66f96eaa36ab8ed13cbc451c6ded7",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
- "php": ">=5.6.0"
+ "php": ">=7.0"
},
"require-dev": {
- "laminas/laminas-diactoros": "^1.8.7 || ^2.11.1",
- "phpunit/phpunit": "^5.7 || ^9.0",
- "psr/http-message": "^1.0",
- "symfony/psr-http-message-bridge": "^1.1 || ^2.0"
+ "laminas/laminas-diactoros": "*",
+ "phpunit/phpunit": "*",
+ "psr/http-message": "*",
+ "symfony/psr-http-message-bridge": "*"
},
"type": "library",
"autoload": {
@@ -6987,30 +5882,41 @@
],
"support": {
"issues": "https://github.com/Riverline/multipart-parser/issues",
- "source": "https://github.com/Riverline/multipart-parser/tree/2.2.0"
+ "source": "https://github.com/Riverline/multipart-parser/tree/2.2.2"
},
- "time": "2025-04-29T08:38:14+00:00"
+ "time": "2026-01-15T11:08:16+00:00"
},
{
"name": "sabberworm/php-css-parser",
- "version": "v8.8.0",
+ "version": "v9.3.0",
"source": {
"type": "git",
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
- "reference": "3de493bdddfd1f051249af725c7e0d2c38fed740"
+ "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/3de493bdddfd1f051249af725c7e0d2c38fed740",
- "reference": "3de493bdddfd1f051249af725c7e0d2c38fed740",
+ "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949",
+ "reference": "88dbd0f7f91abbfe4402d0a3071e9ff4d81ed949",
"shasum": ""
},
"require": {
"ext-iconv": "*",
- "php": "^5.6.20 || ^7.0.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0"
- },
- "require-dev": {
- "phpunit/phpunit": "5.7.27 || 6.5.14 || 7.5.20 || 8.5.41"
+ "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
+ "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
+ },
+ "require-dev": {
+ "php-parallel-lint/php-parallel-lint": "1.4.0",
+ "phpstan/extension-installer": "1.4.3",
+ "phpstan/phpstan": "1.12.32 || 2.1.32",
+ "phpstan/phpstan-phpunit": "1.4.2 || 2.0.8",
+ "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7",
+ "phpunit/phpunit": "8.5.52",
+ "rawr/phpunit-data-provider": "3.3.1",
+ "rector/rector": "1.2.10 || 2.2.8",
+ "rector/type-perfect": "1.0.0 || 2.1.0",
+ "squizlabs/php_codesniffer": "4.0.1",
+ "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1"
},
"suggest": {
"ext-mbstring": "for parsing UTF-8 CSS"
@@ -7018,10 +5924,14 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "9.0.x-dev"
+ "dev-main": "9.4.x-dev"
}
},
"autoload": {
+ "files": [
+ "src/Rule/Rule.php",
+ "src/RuleSet/RuleContainer.php"
+ ],
"psr-4": {
"Sabberworm\\CSS\\": "src/"
}
@@ -7052,22 +5962,22 @@
],
"support": {
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
- "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.8.0"
+ "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.3.0"
},
- "time": "2025-03-23T17:59:05+00:00"
+ "time": "2026-03-03T17:31:43+00:00"
},
{
"name": "sentry/sentry",
- "version": "4.16.0",
+ "version": "4.27.0",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-php.git",
- "reference": "c5b086e4235762da175034bc463b0d31cbb38d2e"
+ "reference": "1f0544cff8443ac1d25d6521487118e28381a1c2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/c5b086e4235762da175034bc463b0d31cbb38d2e",
- "reference": "c5b086e4235762da175034bc463b0d31cbb38d2e",
+ "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/1f0544cff8443ac1d25d6521487118e28381a1c2",
+ "reference": "1f0544cff8443ac1d25d6521487118e28381a1c2",
"shasum": ""
},
"require": {
@@ -7078,23 +5988,29 @@
"jean85/pretty-package-versions": "^1.5|^2.0.4",
"php": "^7.2|^8.0",
"psr/log": "^1.0|^2.0|^3.0",
- "symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0"
+ "symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0"
},
"conflict": {
"raven/raven": "*"
},
"require-dev": {
+ "carthage-software/mago": "^1.13.3",
"friendsofphp/php-cs-fixer": "^3.4",
"guzzlehttp/promises": "^2.0.3",
"guzzlehttp/psr7": "^1.8.4|^2.1.1",
"monolog/monolog": "^1.6|^2.0|^3.0",
+ "nyholm/psr7": "^1.8",
+ "open-telemetry/api": "^1.0",
+ "open-telemetry/exporter-otlp": "^1.0",
+ "open-telemetry/sdk": "^1.0",
"phpbench/phpbench": "^1.0",
"phpstan/phpstan": "^1.3",
- "phpunit/phpunit": "^8.5|^9.6",
- "symfony/phpunit-bridge": "^5.2|^6.0|^7.0",
- "vimeo/psalm": "^4.17"
+ "phpunit/phpunit": "^8.5.52|^9.6.34",
+ "spiral/roadrunner-http": "^3.6",
+ "spiral/roadrunner-worker": "^3.6"
},
"suggest": {
+ "ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.",
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler."
},
"type": "library",
@@ -7131,7 +6047,7 @@
],
"support": {
"issues": "https://github.com/getsentry/sentry-php/issues",
- "source": "https://github.com/getsentry/sentry-php/tree/4.16.0"
+ "source": "https://github.com/getsentry/sentry-php/tree/4.27.0"
},
"funding": [
{
@@ -7143,39 +6059,41 @@
"type": "custom"
}
],
- "time": "2025-09-22T13:38:03+00:00"
+ "time": "2026-05-06T14:32:16+00:00"
},
{
"name": "sentry/sentry-laravel",
- "version": "4.16.0",
+ "version": "4.25.1",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-laravel.git",
- "reference": "b33b2e487b02db02d92988228f142d7fa2be2bfa"
+ "reference": "67efbdd74a752fcc1038676986b055a4df7d5084"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/b33b2e487b02db02d92988228f142d7fa2be2bfa",
- "reference": "b33b2e487b02db02d92988228f142d7fa2be2bfa",
+ "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/67efbdd74a752fcc1038676986b055a4df7d5084",
+ "reference": "67efbdd74a752fcc1038676986b055a4df7d5084",
"shasum": ""
},
"require": {
- "illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0",
+ "illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0",
"nyholm/psr7": "^1.0",
"php": "^7.2 | ^8.0",
- "sentry/sentry": "^4.15.2",
- "symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0"
+ "sentry/sentry": "^4.23.0",
+ "symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0 | ^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.11",
"guzzlehttp/guzzle": "^7.2",
"laravel/folio": "^1.1",
- "laravel/framework": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0",
- "livewire/livewire": "^2.0 | ^3.0",
+ "laravel/framework": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0",
+ "laravel/octane": "^2.15",
+ "laravel/pennant": "^1.0",
+ "livewire/livewire": "^2.0 | ^3.0 | ^4.0",
"mockery/mockery": "^1.3",
- "orchestra/testbench": "^4.7 | ^5.1 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0",
+ "orchestra/testbench": "^4.7 | ^5.1 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0",
"phpstan/phpstan": "^1.10",
- "phpunit/phpunit": "^8.4 | ^9.3 | ^10.4 | ^11.5"
+ "phpunit/phpunit": "^8.5 | ^9.6 | ^10.4 | ^11.5"
},
"type": "library",
"extra": {
@@ -7220,7 +6138,7 @@
],
"support": {
"issues": "https://github.com/getsentry/sentry-laravel/issues",
- "source": "https://github.com/getsentry/sentry-laravel/tree/4.16.0"
+ "source": "https://github.com/getsentry/sentry-laravel/tree/4.25.1"
},
"funding": [
{
@@ -7232,31 +6150,31 @@
"type": "custom"
}
],
- "time": "2025-09-10T16:38:18+00:00"
+ "time": "2026-05-05T09:22:46+00:00"
},
{
"name": "spatie/icalendar-generator",
- "version": "3.0.0",
+ "version": "3.3.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/icalendar-generator.git",
- "reference": "32797f6e5afa3142d073f38d5f22ab377f4d8f90"
+ "reference": "6817d3f405563eca1afc9ea870077a898e11bc27"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/icalendar-generator/zipball/32797f6e5afa3142d073f38d5f22ab377f4d8f90",
- "reference": "32797f6e5afa3142d073f38d5f22ab377f4d8f90",
+ "url": "https://api.github.com/repos/spatie/icalendar-generator/zipball/6817d3f405563eca1afc9ea870077a898e11bc27",
+ "reference": "6817d3f405563eca1afc9ea870077a898e11bc27",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
- "php": "^8.1"
+ "php": "^8.2"
},
"require-dev": {
"ext-json": "*",
"larapack/dd": "^1.1",
"nesbot/carbon": "^3.5",
- "pestphp/pest": "^2.34",
+ "pestphp/pest": "^2.34 || ^3.0 || ^4.0",
"phpstan/phpstan": "^2.0",
"spatie/pest-plugin-snapshots": "^2.1"
},
@@ -7289,45 +6207,46 @@
],
"support": {
"issues": "https://github.com/spatie/icalendar-generator/issues",
- "source": "https://github.com/spatie/icalendar-generator/tree/3.0.0"
+ "source": "https://github.com/spatie/icalendar-generator/tree/3.3.0"
},
- "time": "2025-04-17T14:50:03+00:00"
+ "time": "2026-03-18T09:51:41+00:00"
},
{
"name": "spatie/laravel-data",
- "version": "4.17.0",
+ "version": "4.23.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-data.git",
- "reference": "6b110d25ad4219774241b083d09695b20a7fb472"
+ "reference": "230543769c996e407fec2873930626aed7dd0d3b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/laravel-data/zipball/6b110d25ad4219774241b083d09695b20a7fb472",
- "reference": "6b110d25ad4219774241b083d09695b20a7fb472",
+ "url": "https://api.github.com/repos/spatie/laravel-data/zipball/230543769c996e407fec2873930626aed7dd0d3b",
+ "reference": "230543769c996e407fec2873930626aed7dd0d3b",
"shasum": ""
},
"require": {
- "illuminate/contracts": "^10.0|^11.0|^12.0",
+ "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0",
"php": "^8.1",
- "phpdocumentor/reflection": "^6.0",
+ "phpdocumentor/reflection-common": "^2.2",
+ "phpdocumentor/reflection-docblock": "^5.3 || ^6.0",
+ "phpdocumentor/type-resolver": "^1.7 || ^2.0",
"spatie/laravel-package-tools": "^1.9.0",
"spatie/php-structure-discoverer": "^2.0"
},
"require-dev": {
"fakerphp/faker": "^1.14",
"friendsofphp/php-cs-fixer": "^3.0",
- "inertiajs/inertia-laravel": "^2.0",
- "livewire/livewire": "^3.0",
+ "inertiajs/inertia-laravel": "^2.0|^3.0",
+ "livewire/livewire": "^3.0|^4.0",
"mockery/mockery": "^1.6",
"nesbot/carbon": "^2.63|^3.0",
- "orchestra/testbench": "^8.0|^9.0|^10.0",
- "pestphp/pest": "^2.31|^3.0",
- "pestphp/pest-plugin-laravel": "^2.0|^3.0",
- "pestphp/pest-plugin-livewire": "^2.1|^3.0",
+ "orchestra/testbench": "^8.37.0|^9.16|^10.9|^11.0",
+ "pestphp/pest": "^2.36|^3.8|^4.3",
+ "pestphp/pest-plugin-laravel": "^2.4|^3.0|^4.0",
+ "pestphp/pest-plugin-livewire": "^2.1|^3.0|^4.0",
"phpbench/phpbench": "^1.2",
"phpstan/extension-installer": "^1.1",
- "phpunit/phpunit": "^10.0|^11.0|^12.0",
"spatie/invade": "^1.0",
"spatie/laravel-typescript-transformer": "^2.5",
"spatie/pest-plugin-snapshots": "^2.1",
@@ -7366,7 +6285,7 @@
],
"support": {
"issues": "https://github.com/spatie/laravel-data/issues",
- "source": "https://github.com/spatie/laravel-data/tree/4.17.0"
+ "source": "https://github.com/spatie/laravel-data/tree/4.23.0"
},
"funding": [
{
@@ -7374,33 +6293,33 @@
"type": "github"
}
],
- "time": "2025-06-25T11:36:37+00:00"
+ "time": "2026-05-08T14:41:13+00:00"
},
{
"name": "spatie/laravel-package-tools",
- "version": "1.92.7",
+ "version": "1.93.1",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-package-tools.git",
- "reference": "f09a799850b1ed765103a4f0b4355006360c49a5"
+ "reference": "d5552849801f2642aea710557463234b59ef65eb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/f09a799850b1ed765103a4f0b4355006360c49a5",
- "reference": "f09a799850b1ed765103a4f0b4355006360c49a5",
+ "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d5552849801f2642aea710557463234b59ef65eb",
+ "reference": "d5552849801f2642aea710557463234b59ef65eb",
"shasum": ""
},
"require": {
- "illuminate/contracts": "^9.28|^10.0|^11.0|^12.0",
- "php": "^8.0"
+ "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0",
+ "php": "^8.1"
},
"require-dev": {
"mockery/mockery": "^1.5",
- "orchestra/testbench": "^7.7|^8.0|^9.0|^10.0",
- "pestphp/pest": "^1.23|^2.1|^3.1",
- "phpunit/php-code-coverage": "^9.0|^10.0|^11.0",
- "phpunit/phpunit": "^9.5.24|^10.5|^11.5",
- "spatie/pest-plugin-test-time": "^1.1|^2.2"
+ "orchestra/testbench": "^8.0|^9.2|^10.0|^11.0",
+ "pestphp/pest": "^2.1|^3.1|^4.0",
+ "phpunit/php-code-coverage": "^10.0|^11.0|^12.0",
+ "phpunit/phpunit": "^10.5|^11.5|^12.5",
+ "spatie/pest-plugin-test-time": "^2.2|^3.0"
},
"type": "library",
"autoload": {
@@ -7427,7 +6346,7 @@
],
"support": {
"issues": "https://github.com/spatie/laravel-package-tools/issues",
- "source": "https://github.com/spatie/laravel-package-tools/tree/1.92.7"
+ "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.1"
},
"funding": [
{
@@ -7435,36 +6354,36 @@
"type": "github"
}
],
- "time": "2025-07-17T15:46:43+00:00"
+ "time": "2026-05-19T14:06:37+00:00"
},
{
"name": "spatie/laravel-webhook-server",
- "version": "3.8.3",
+ "version": "3.10.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-webhook-server.git",
- "reference": "e3d8f24030bbb4087867cd0be681c028736b772f"
+ "reference": "6106840254e22b667e77a885da69fff6035549ba"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/laravel-webhook-server/zipball/e3d8f24030bbb4087867cd0be681c028736b772f",
- "reference": "e3d8f24030bbb4087867cd0be681c028736b772f",
+ "url": "https://api.github.com/repos/spatie/laravel-webhook-server/zipball/6106840254e22b667e77a885da69fff6035549ba",
+ "reference": "6106840254e22b667e77a885da69fff6035549ba",
"shasum": ""
},
"require": {
"ext-json": "*",
"guzzlehttp/guzzle": "^6.3|^7.3",
- "illuminate/bus": "^8.50|^9.0|^10.0|^11.0|^12.0",
- "illuminate/queue": "^8.50|^9.0|^10.0|^11.0|^12.0",
- "illuminate/support": "^8.50|^9.0|^10.0|^11.0|^12.0",
+ "illuminate/bus": "^8.50|^9.0|^10.0|^11.0|^12.0|^13.0",
+ "illuminate/queue": "^8.50|^9.0|^10.0|^11.0|^12.0|^13.0",
+ "illuminate/support": "^8.50|^9.0|^10.0|^11.0|^12.0|^13.0",
"php": "^8.0",
"spatie/laravel-package-tools": "^1.11"
},
"require-dev": {
"mockery/mockery": "^1.4.3",
- "orchestra/testbench": "^6.19|^7.0|^8.0|^10.0",
- "pestphp/pest": "^1.22|^2.0|^3.0",
- "pestphp/pest-plugin-laravel": "^1.3|^2.0|^3.0",
+ "orchestra/testbench": "^6.19|^7.0|^8.0|^10.0|^11.0",
+ "pestphp/pest": "^1.22|^2.0|^3.0|^4.0",
+ "pestphp/pest-plugin-laravel": "^1.3|^2.0|^3.0|^4.0",
"spatie/test-time": "^1.2.2"
},
"type": "library",
@@ -7501,7 +6420,7 @@
"webhook"
],
"support": {
- "source": "https://github.com/spatie/laravel-webhook-server/tree/3.8.3"
+ "source": "https://github.com/spatie/laravel-webhook-server/tree/3.10.0"
},
"funding": [
{
@@ -7509,42 +6428,42 @@
"type": "custom"
}
],
- "time": "2025-02-14T12:55:41+00:00"
+ "time": "2026-02-21T15:14:00+00:00"
},
{
"name": "spatie/php-structure-discoverer",
- "version": "2.3.1",
+ "version": "2.4.2",
"source": {
"type": "git",
"url": "https://github.com/spatie/php-structure-discoverer.git",
- "reference": "42f4d731d3dd4b3b85732e05a8c1928fcfa2f4bc"
+ "reference": "10cd4e0018450d23e2bd8f8472569ad0c445c0fc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/42f4d731d3dd4b3b85732e05a8c1928fcfa2f4bc",
- "reference": "42f4d731d3dd4b3b85732e05a8c1928fcfa2f4bc",
+ "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/10cd4e0018450d23e2bd8f8472569ad0c445c0fc",
+ "reference": "10cd4e0018450d23e2bd8f8472569ad0c445c0fc",
"shasum": ""
},
"require": {
- "amphp/amp": "^v3.0",
- "amphp/parallel": "^2.2",
- "illuminate/collections": "^10.0|^11.0|^12.0",
- "php": "^8.1",
- "spatie/laravel-package-tools": "^1.4.3",
- "symfony/finder": "^6.0|^7.0"
+ "illuminate/collections": "^11.0|^12.0|^13.0",
+ "php": "^8.3",
+ "spatie/laravel-package-tools": "^1.92.7",
+ "symfony/finder": "^6.0|^7.3.5|^8.0"
},
"require-dev": {
- "illuminate/console": "^10.0|^11.0|^12.0",
- "laravel/pint": "^1.0",
- "nunomaduro/collision": "^7.0|^8.0",
- "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0",
- "pestphp/pest": "^2.0|^3.0",
- "pestphp/pest-plugin-laravel": "^2.0|^3.0",
- "phpstan/extension-installer": "^1.1",
- "phpstan/phpstan-deprecation-rules": "^1.0",
- "phpstan/phpstan-phpunit": "^1.0",
- "phpunit/phpunit": "^9.5|^10.0|^11.5.3",
- "spatie/laravel-ray": "^1.26"
+ "amphp/parallel": "^2.3.2",
+ "illuminate/console": "^11.0|^12.0|^13.0",
+ "nunomaduro/collision": "^7.0|^8.8.3",
+ "orchestra/testbench": "^9.5|^10.8|^11.0",
+ "pestphp/pest": "^3.8|^4.0",
+ "pestphp/pest-plugin-laravel": "^3.2|^4.0",
+ "phpstan/extension-installer": "^1.4.3",
+ "phpstan/phpstan-deprecation-rules": "^1.2.1",
+ "phpstan/phpstan-phpunit": "^1.4.2",
+ "spatie/laravel-ray": "^1.43.1"
+ },
+ "suggest": {
+ "amphp/parallel": "When you want to use the Parallel discover worker"
},
"type": "library",
"extra": {
@@ -7580,7 +6499,7 @@
],
"support": {
"issues": "https://github.com/spatie/php-structure-discoverer/issues",
- "source": "https://github.com/spatie/php-structure-discoverer/tree/2.3.1"
+ "source": "https://github.com/spatie/php-structure-discoverer/tree/2.4.2"
},
"funding": [
{
@@ -7588,20 +6507,20 @@
"type": "github"
}
],
- "time": "2025-02-14T10:18:38+00:00"
+ "time": "2026-04-28T06:26:02+00:00"
},
{
"name": "stripe/stripe-php",
- "version": "v17.2.0",
+ "version": "v17.6.0",
"source": {
"type": "git",
"url": "https://github.com/stripe/stripe-php.git",
- "reference": "ff2364c75533b71116ea11994d6bd08989b7f67b"
+ "reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/stripe/stripe-php/zipball/ff2364c75533b71116ea11994d6bd08989b7f67b",
- "reference": "ff2364c75533b71116ea11994d6bd08989b7f67b",
+ "url": "https://api.github.com/repos/stripe/stripe-php/zipball/a6219df5df1324a0d3f1da25fb5e4b8a3307ea16",
+ "reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16",
"shasum": ""
},
"require": {
@@ -7645,22 +6564,22 @@
],
"support": {
"issues": "https://github.com/stripe/stripe-php/issues",
- "source": "https://github.com/stripe/stripe-php/tree/v17.2.0"
+ "source": "https://github.com/stripe/stripe-php/tree/v17.6.0"
},
- "time": "2025-04-30T19:20:34+00:00"
+ "time": "2025-08-27T19:32:42+00:00"
},
{
"name": "symfony/clock",
- "version": "v7.3.0",
+ "version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/clock.git",
- "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24"
+ "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/clock/zipball/b81435fbd6648ea425d1ee96a2d8e68f4ceacd24",
- "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24",
+ "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111",
+ "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111",
"shasum": ""
},
"require": {
@@ -7705,7 +6624,7 @@
"time"
],
"support": {
- "source": "https://github.com/symfony/clock/tree/v7.3.0"
+ "source": "https://github.com/symfony/clock/tree/v7.4.8"
},
"funding": [
{
@@ -7716,25 +6635,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-09-25T14:21:43+00:00"
+ "time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/console",
- "version": "v7.3.4",
+ "version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "2b9c5fafbac0399a20a2e82429e2bd735dcfb7db"
+ "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/2b9c5fafbac0399a20a2e82429e2bd735dcfb7db",
- "reference": "2b9c5fafbac0399a20a2e82429e2bd735dcfb7db",
+ "url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217",
+ "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217",
"shasum": ""
},
"require": {
@@ -7742,7 +6665,7 @@
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-mbstring": "~1.0",
"symfony/service-contracts": "^2.5|^3",
- "symfony/string": "^7.2"
+ "symfony/string": "^7.2|^8.0"
},
"conflict": {
"symfony/dependency-injection": "<6.4",
@@ -7756,16 +6679,16 @@
},
"require-dev": {
"psr/log": "^1|^2|^3",
- "symfony/config": "^6.4|^7.0",
- "symfony/dependency-injection": "^6.4|^7.0",
- "symfony/event-dispatcher": "^6.4|^7.0",
- "symfony/http-foundation": "^6.4|^7.0",
- "symfony/http-kernel": "^6.4|^7.0",
- "symfony/lock": "^6.4|^7.0",
- "symfony/messenger": "^6.4|^7.0",
- "symfony/process": "^6.4|^7.0",
- "symfony/stopwatch": "^6.4|^7.0",
- "symfony/var-dumper": "^6.4|^7.0"
+ "symfony/config": "^6.4|^7.0|^8.0",
+ "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+ "symfony/event-dispatcher": "^6.4|^7.0|^8.0",
+ "symfony/http-foundation": "^6.4|^7.0|^8.0",
+ "symfony/http-kernel": "^6.4|^7.0|^8.0",
+ "symfony/lock": "^6.4|^7.0|^8.0",
+ "symfony/messenger": "^6.4|^7.0|^8.0",
+ "symfony/process": "^6.4|^7.0|^8.0",
+ "symfony/stopwatch": "^6.4|^7.0|^8.0",
+ "symfony/var-dumper": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
@@ -7799,7 +6722,7 @@
"terminal"
],
"support": {
- "source": "https://github.com/symfony/console/tree/v7.3.4"
+ "source": "https://github.com/symfony/console/tree/v7.4.13"
},
"funding": [
{
@@ -7819,20 +6742,20 @@
"type": "tidelift"
}
],
- "time": "2025-09-22T15:31:00+00:00"
+ "time": "2026-05-24T08:56:14+00:00"
},
{
"name": "symfony/css-selector",
- "version": "v7.3.0",
+ "version": "v7.4.9",
"source": {
"type": "git",
"url": "https://github.com/symfony/css-selector.git",
- "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2"
+ "reference": "b75663ed96cf4756e28e3105476f220f92886cc4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/css-selector/zipball/601a5ce9aaad7bf10797e3663faefce9e26c24e2",
- "reference": "601a5ce9aaad7bf10797e3663faefce9e26c24e2",
+ "url": "https://api.github.com/repos/symfony/css-selector/zipball/b75663ed96cf4756e28e3105476f220f92886cc4",
+ "reference": "b75663ed96cf4756e28e3105476f220f92886cc4",
"shasum": ""
},
"require": {
@@ -7868,7 +6791,7 @@
"description": "Converts CSS selectors to XPath expressions",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/css-selector/tree/v7.3.0"
+ "source": "https://github.com/symfony/css-selector/tree/v7.4.9"
},
"funding": [
{
@@ -7879,25 +6802,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-09-25T14:21:43+00:00"
+ "time": "2026-04-18T13:18:21+00:00"
},
{
"name": "symfony/deprecation-contracts",
- "version": "v3.6.0",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
- "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
+ "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
- "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b",
+ "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b",
"shasum": ""
},
"require": {
@@ -7910,7 +6837,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -7935,7 +6862,7 @@
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -7946,41 +6873,46 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-09-25T14:21:43+00:00"
+ "time": "2026-04-13T15:52:40+00:00"
},
{
"name": "symfony/error-handler",
- "version": "v7.3.4",
+ "version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/error-handler.git",
- "reference": "99f81bc944ab8e5dae4f21b4ca9972698bbad0e4"
+ "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/error-handler/zipball/99f81bc944ab8e5dae4f21b4ca9972698bbad0e4",
- "reference": "99f81bc944ab8e5dae4f21b4ca9972698bbad0e4",
+ "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa",
+ "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa",
"shasum": ""
},
"require": {
"php": ">=8.2",
"psr/log": "^1|^2|^3",
- "symfony/var-dumper": "^6.4|^7.0"
+ "symfony/polyfill-php85": "^1.32",
+ "symfony/var-dumper": "^6.4|^7.0|^8.0"
},
"conflict": {
"symfony/deprecation-contracts": "<2.5",
"symfony/http-kernel": "<6.4"
},
"require-dev": {
- "symfony/console": "^6.4|^7.0",
+ "symfony/console": "^6.4|^7.0|^8.0",
"symfony/deprecation-contracts": "^2.5|^3",
- "symfony/http-kernel": "^6.4|^7.0",
- "symfony/serializer": "^6.4|^7.0",
+ "symfony/http-kernel": "^6.4|^7.0|^8.0",
+ "symfony/serializer": "^6.4|^7.0|^8.0",
"symfony/webpack-encore-bundle": "^1.0|^2.0"
},
"bin": [
@@ -8012,7 +6944,7 @@
"description": "Provides tools to manage errors and ease debugging PHP code",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/error-handler/tree/v7.3.4"
+ "source": "https://github.com/symfony/error-handler/tree/v7.4.8"
},
"funding": [
{
@@ -8032,20 +6964,20 @@
"type": "tidelift"
}
],
- "time": "2025-09-11T10:12:26+00:00"
+ "time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/event-dispatcher",
- "version": "v7.3.3",
+ "version": "v7.4.9",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher.git",
- "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191"
+ "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/b7dc69e71de420ac04bc9ab830cf3ffebba48191",
- "reference": "b7dc69e71de420ac04bc9ab830cf3ffebba48191",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/e4a2e29753c7801f7a8340e066cfa788f3bc8101",
+ "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101",
"shasum": ""
},
"require": {
@@ -8062,13 +6994,14 @@
},
"require-dev": {
"psr/log": "^1|^2|^3",
- "symfony/config": "^6.4|^7.0",
- "symfony/dependency-injection": "^6.4|^7.0",
- "symfony/error-handler": "^6.4|^7.0",
- "symfony/expression-language": "^6.4|^7.0",
- "symfony/http-foundation": "^6.4|^7.0",
+ "symfony/config": "^6.4|^7.0|^8.0",
+ "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+ "symfony/error-handler": "^6.4|^7.0|^8.0",
+ "symfony/expression-language": "^6.4|^7.0|^8.0",
+ "symfony/framework-bundle": "^6.4|^7.0|^8.0",
+ "symfony/http-foundation": "^6.4|^7.0|^8.0",
"symfony/service-contracts": "^2.5|^3",
- "symfony/stopwatch": "^6.4|^7.0"
+ "symfony/stopwatch": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
@@ -8085,18 +7018,98 @@
],
"authors": [
{
- "name": "Fabien Potencier",
- "email": "fabien@symfony.com"
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.9"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-18T13:18:21+00:00"
+ },
+ {
+ "name": "symfony/event-dispatcher-contracts",
+ "version": "v3.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/event-dispatcher-contracts.git",
+ "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32",
+ "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1",
+ "psr/event-dispatcher": "^1"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "3.7-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\EventDispatcher\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
+ "description": "Generic abstractions related to dispatching event",
"homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
"support": {
- "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.3"
+ "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -8116,40 +7129,38 @@
"type": "tidelift"
}
],
- "time": "2025-08-13T11:49:31+00:00"
+ "time": "2026-01-05T13:30:16+00:00"
},
{
- "name": "symfony/event-dispatcher-contracts",
- "version": "v3.6.0",
+ "name": "symfony/filesystem",
+ "version": "v7.4.11",
"source": {
"type": "git",
- "url": "https://github.com/symfony/event-dispatcher-contracts.git",
- "reference": "59eb412e93815df44f05f342958efa9f46b1e586"
+ "url": "https://github.com/symfony/filesystem.git",
+ "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586",
- "reference": "59eb412e93815df44f05f342958efa9f46b1e586",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/d721ea61b4a5fba8c5b6e7c1feda19efea144b50",
+ "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50",
"shasum": ""
},
"require": {
- "php": ">=8.1",
- "psr/event-dispatcher": "^1"
+ "php": ">=8.2",
+ "symfony/polyfill-ctype": "~1.8",
+ "symfony/polyfill-mbstring": "~1.8"
},
- "type": "library",
- "extra": {
- "thanks": {
- "url": "https://github.com/symfony/contracts",
- "name": "symfony/contracts"
- },
- "branch-alias": {
- "dev-main": "3.6-dev"
- }
+ "require-dev": {
+ "symfony/process": "^6.4|^7.0|^8.0"
},
+ "type": "library",
"autoload": {
"psr-4": {
- "Symfony\\Contracts\\EventDispatcher\\": ""
- }
+ "Symfony\\Component\\Filesystem\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
@@ -8157,26 +7168,18 @@
],
"authors": [
{
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
- "description": "Generic abstractions related to dispatching event",
+ "description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
- "keywords": [
- "abstractions",
- "contracts",
- "decoupling",
- "interfaces",
- "interoperability",
- "standards"
- ],
"support": {
- "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0"
+ "source": "https://github.com/symfony/filesystem/tree/v7.4.11"
},
"funding": [
{
@@ -8187,32 +7190,36 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-09-25T14:21:43+00:00"
+ "time": "2026-05-11T16:38:44+00:00"
},
{
"name": "symfony/finder",
- "version": "v7.3.2",
+ "version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
- "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe"
+ "reference": "e0be088d22278583a82da281886e8c3592fbf149"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/finder/zipball/2a6614966ba1074fa93dae0bc804227422df4dfe",
- "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149",
+ "reference": "e0be088d22278583a82da281886e8c3592fbf149",
"shasum": ""
},
"require": {
"php": ">=8.2"
},
"require-dev": {
- "symfony/filesystem": "^6.4|^7.0"
+ "symfony/filesystem": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
@@ -8240,7 +7247,7 @@
"description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/finder/tree/v7.3.2"
+ "source": "https://github.com/symfony/finder/tree/v7.4.8"
},
"funding": [
{
@@ -8260,20 +7267,20 @@
"type": "tidelift"
}
],
- "time": "2025-07-15T13:41:35+00:00"
+ "time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/http-client",
- "version": "v7.4.1",
+ "version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
- "reference": "26cc224ea7103dda90e9694d9e139a389092d007"
+ "reference": "e8a112b8415707265a7e614278136a9d92989a6a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-client/zipball/26cc224ea7103dda90e9694d9e139a389092d007",
- "reference": "26cc224ea7103dda90e9694d9e139a389092d007",
+ "url": "https://api.github.com/repos/symfony/http-client/zipball/e8a112b8415707265a7e614278136a9d92989a6a",
+ "reference": "e8a112b8415707265a7e614278136a9d92989a6a",
"shasum": ""
},
"require": {
@@ -8341,7 +7348,7 @@
"http"
],
"support": {
- "source": "https://github.com/symfony/http-client/tree/v7.4.1"
+ "source": "https://github.com/symfony/http-client/tree/v7.4.13"
},
"funding": [
{
@@ -8361,20 +7368,20 @@
"type": "tidelift"
}
],
- "time": "2025-12-04T21:12:57+00:00"
+ "time": "2026-05-24T09:57:54+00:00"
},
{
"name": "symfony/http-client-contracts",
- "version": "v3.6.0",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
- "reference": "75d7043853a42837e68111812f4d964b01e5101c"
+ "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
- "reference": "75d7043853a42837e68111812f4d964b01e5101c",
+ "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d",
+ "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d",
"shasum": ""
},
"require": {
@@ -8387,7 +7394,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -8423,7 +7430,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
+ "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -8434,32 +7441,35 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-04-29T11:18:49+00:00"
+ "time": "2026-03-06T13:17:50+00:00"
},
{
"name": "symfony/http-foundation",
- "version": "v7.3.4",
+ "version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
- "reference": "c061c7c18918b1b64268771aad04b40be41dd2e6"
+ "reference": "bc354f47c62301e990b7874fa662326368508e2c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-foundation/zipball/c061c7c18918b1b64268771aad04b40be41dd2e6",
- "reference": "c061c7c18918b1b64268771aad04b40be41dd2e6",
+ "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bc354f47c62301e990b7874fa662326368508e2c",
+ "reference": "bc354f47c62301e990b7874fa662326368508e2c",
"shasum": ""
},
"require": {
"php": ">=8.2",
- "symfony/deprecation-contracts": "^2.5|^3.0",
- "symfony/polyfill-mbstring": "~1.1",
- "symfony/polyfill-php83": "^1.27"
+ "symfony/deprecation-contracts": "^2.5|^3",
+ "symfony/polyfill-mbstring": "^1.1"
},
"conflict": {
"doctrine/dbal": "<3.6",
@@ -8468,13 +7478,13 @@
"require-dev": {
"doctrine/dbal": "^3.6|^4",
"predis/predis": "^1.1|^2.0",
- "symfony/cache": "^6.4.12|^7.1.5",
- "symfony/clock": "^6.4|^7.0",
- "symfony/dependency-injection": "^6.4|^7.0",
- "symfony/expression-language": "^6.4|^7.0",
- "symfony/http-kernel": "^6.4|^7.0",
- "symfony/mime": "^6.4|^7.0",
- "symfony/rate-limiter": "^6.4|^7.0"
+ "symfony/cache": "^6.4.12|^7.1.5|^8.0",
+ "symfony/clock": "^6.4|^7.0|^8.0",
+ "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+ "symfony/expression-language": "^6.4|^7.0|^8.0",
+ "symfony/http-kernel": "^6.4|^7.0|^8.0",
+ "symfony/mime": "^6.4|^7.0|^8.0",
+ "symfony/rate-limiter": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
@@ -8502,7 +7512,7 @@
"description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/http-foundation/tree/v7.3.4"
+ "source": "https://github.com/symfony/http-foundation/tree/v7.4.13"
},
"funding": [
{
@@ -8522,29 +7532,29 @@
"type": "tidelift"
}
],
- "time": "2025-09-16T08:38:17+00:00"
+ "time": "2026-05-24T11:20:33+00:00"
},
{
"name": "symfony/http-kernel",
- "version": "v7.3.4",
+ "version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-kernel.git",
- "reference": "b796dffea7821f035047235e076b60ca2446e3cf"
+ "reference": "9df847980c436451f4f51d1284491bb4356dd989"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-kernel/zipball/b796dffea7821f035047235e076b60ca2446e3cf",
- "reference": "b796dffea7821f035047235e076b60ca2446e3cf",
+ "url": "https://api.github.com/repos/symfony/http-kernel/zipball/9df847980c436451f4f51d1284491bb4356dd989",
+ "reference": "9df847980c436451f4f51d1284491bb4356dd989",
"shasum": ""
},
"require": {
"php": ">=8.2",
"psr/log": "^1|^2|^3",
"symfony/deprecation-contracts": "^2.5|^3",
- "symfony/error-handler": "^6.4|^7.0",
- "symfony/event-dispatcher": "^7.3",
- "symfony/http-foundation": "^7.3",
+ "symfony/error-handler": "^6.4|^7.0|^8.0",
+ "symfony/event-dispatcher": "^7.3|^8.0",
+ "symfony/http-foundation": "^7.4|^8.0",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
@@ -8554,6 +7564,7 @@
"symfony/console": "<6.4",
"symfony/dependency-injection": "<6.4",
"symfony/doctrine-bridge": "<6.4",
+ "symfony/flex": "<2.10",
"symfony/form": "<6.4",
"symfony/http-client": "<6.4",
"symfony/http-client-contracts": "<2.5",
@@ -8571,27 +7582,27 @@
},
"require-dev": {
"psr/cache": "^1.0|^2.0|^3.0",
- "symfony/browser-kit": "^6.4|^7.0",
- "symfony/clock": "^6.4|^7.0",
- "symfony/config": "^6.4|^7.0",
- "symfony/console": "^6.4|^7.0",
- "symfony/css-selector": "^6.4|^7.0",
- "symfony/dependency-injection": "^6.4|^7.0",
- "symfony/dom-crawler": "^6.4|^7.0",
- "symfony/expression-language": "^6.4|^7.0",
- "symfony/finder": "^6.4|^7.0",
+ "symfony/browser-kit": "^6.4|^7.0|^8.0",
+ "symfony/clock": "^6.4|^7.0|^8.0",
+ "symfony/config": "^6.4|^7.0|^8.0",
+ "symfony/console": "^6.4|^7.0|^8.0",
+ "symfony/css-selector": "^6.4|^7.0|^8.0",
+ "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0",
+ "symfony/dom-crawler": "^6.4|^7.0|^8.0",
+ "symfony/expression-language": "^6.4|^7.0|^8.0",
+ "symfony/finder": "^6.4|^7.0|^8.0",
"symfony/http-client-contracts": "^2.5|^3",
- "symfony/process": "^6.4|^7.0",
- "symfony/property-access": "^7.1",
- "symfony/routing": "^6.4|^7.0",
- "symfony/serializer": "^7.1",
- "symfony/stopwatch": "^6.4|^7.0",
- "symfony/translation": "^6.4|^7.0",
+ "symfony/process": "^6.4|^7.0|^8.0",
+ "symfony/property-access": "^7.1|^8.0",
+ "symfony/routing": "^6.4|^7.0|^8.0",
+ "symfony/serializer": "^7.1|^8.0",
+ "symfony/stopwatch": "^6.4|^7.0|^8.0",
+ "symfony/translation": "^6.4|^7.0|^8.0",
"symfony/translation-contracts": "^2.5|^3",
- "symfony/uid": "^6.4|^7.0",
- "symfony/validator": "^6.4|^7.0",
- "symfony/var-dumper": "^6.4|^7.0",
- "symfony/var-exporter": "^6.4|^7.0",
+ "symfony/uid": "^6.4|^7.0|^8.0",
+ "symfony/validator": "^6.4|^7.0|^8.0",
+ "symfony/var-dumper": "^6.4|^7.0|^8.0",
+ "symfony/var-exporter": "^6.4|^7.0|^8.0",
"twig/twig": "^3.12"
},
"type": "library",
@@ -8620,7 +7631,7 @@
"description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/http-kernel/tree/v7.3.4"
+ "source": "https://github.com/symfony/http-kernel/tree/v7.4.13"
},
"funding": [
{
@@ -8640,20 +7651,20 @@
"type": "tidelift"
}
],
- "time": "2025-09-27T12:32:17+00:00"
+ "time": "2026-05-27T08:31:43+00:00"
},
{
"name": "symfony/mailer",
- "version": "v7.3.4",
+ "version": "v7.4.12",
"source": {
"type": "git",
"url": "https://github.com/symfony/mailer.git",
- "reference": "ab97ef2f7acf0216955f5845484235113047a31d"
+ "reference": "5cefb712a25f320579615ba9e1942abaeade7dff"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/mailer/zipball/ab97ef2f7acf0216955f5845484235113047a31d",
- "reference": "ab97ef2f7acf0216955f5845484235113047a31d",
+ "url": "https://api.github.com/repos/symfony/mailer/zipball/5cefb712a25f320579615ba9e1942abaeade7dff",
+ "reference": "5cefb712a25f320579615ba9e1942abaeade7dff",
"shasum": ""
},
"require": {
@@ -8661,8 +7672,8 @@
"php": ">=8.2",
"psr/event-dispatcher": "^1",
"psr/log": "^1|^2|^3",
- "symfony/event-dispatcher": "^6.4|^7.0",
- "symfony/mime": "^7.2",
+ "symfony/event-dispatcher": "^6.4|^7.0|^8.0",
+ "symfony/mime": "^7.2|^8.0",
"symfony/service-contracts": "^2.5|^3"
},
"conflict": {
@@ -8673,10 +7684,10 @@
"symfony/twig-bridge": "<6.4"
},
"require-dev": {
- "symfony/console": "^6.4|^7.0",
- "symfony/http-client": "^6.4|^7.0",
- "symfony/messenger": "^6.4|^7.0",
- "symfony/twig-bridge": "^6.4|^7.0"
+ "symfony/console": "^6.4|^7.0|^8.0",
+ "symfony/http-client": "^6.4|^7.0|^8.0",
+ "symfony/messenger": "^6.4|^7.0|^8.0",
+ "symfony/twig-bridge": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
@@ -8704,7 +7715,7 @@
"description": "Helps sending emails",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/mailer/tree/v7.3.4"
+ "source": "https://github.com/symfony/mailer/tree/v7.4.12"
},
"funding": [
{
@@ -8724,43 +7735,44 @@
"type": "tidelift"
}
],
- "time": "2025-09-17T05:51:54+00:00"
+ "time": "2026-05-20T07:20:23+00:00"
},
{
"name": "symfony/mime",
- "version": "v7.3.4",
+ "version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
- "reference": "b1b828f69cbaf887fa835a091869e55df91d0e35"
+ "reference": "a845722765c4f6b2ce88beaf4f4479975b186770"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/mime/zipball/b1b828f69cbaf887fa835a091869e55df91d0e35",
- "reference": "b1b828f69cbaf887fa835a091869e55df91d0e35",
+ "url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770",
+ "reference": "a845722765c4f6b2ce88beaf4f4479975b186770",
"shasum": ""
},
"require": {
"php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-intl-idn": "^1.10",
"symfony/polyfill-mbstring": "^1.0"
},
"conflict": {
"egulias/email-validator": "~3.0.0",
- "phpdocumentor/reflection-docblock": "<3.2.2",
- "phpdocumentor/type-resolver": "<1.4.0",
+ "phpdocumentor/reflection-docblock": "<5.2|>=7",
+ "phpdocumentor/type-resolver": "<1.5.1",
"symfony/mailer": "<6.4",
"symfony/serializer": "<6.4.3|>7.0,<7.0.3"
},
"require-dev": {
"egulias/email-validator": "^2.1.10|^3.1|^4",
"league/html-to-markdown": "^5.0",
- "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0",
- "symfony/dependency-injection": "^6.4|^7.0",
- "symfony/process": "^6.4|^7.0",
- "symfony/property-access": "^6.4|^7.0",
- "symfony/property-info": "^6.4|^7.0",
- "symfony/serializer": "^6.4.3|^7.0.3"
+ "phpdocumentor/reflection-docblock": "^5.2|^6.0",
+ "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+ "symfony/process": "^6.4|^7.0|^8.0",
+ "symfony/property-access": "^6.4|^7.0|^8.0",
+ "symfony/property-info": "^6.4|^7.0|^8.0",
+ "symfony/serializer": "^6.4.3|^7.0.3|^8.0"
},
"type": "library",
"autoload": {
@@ -8792,7 +7804,7 @@
"mime-type"
],
"support": {
- "source": "https://github.com/symfony/mime/tree/v7.3.4"
+ "source": "https://github.com/symfony/mime/tree/v7.4.13"
},
"funding": [
{
@@ -8812,20 +7824,20 @@
"type": "tidelift"
}
],
- "time": "2025-09-16T08:38:17+00:00"
+ "time": "2026-05-23T16:22:37+00:00"
},
{
"name": "symfony/options-resolver",
- "version": "v7.3.3",
+ "version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/options-resolver.git",
- "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d"
+ "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/options-resolver/zipball/0ff2f5c3df08a395232bbc3c2eb7e84912df911d",
- "reference": "0ff2f5c3df08a395232bbc3c2eb7e84912df911d",
+ "url": "https://api.github.com/repos/symfony/options-resolver/zipball/2888fcdc4dc2fd5f7c7397be78631e8af12e02b4",
+ "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4",
"shasum": ""
},
"require": {
@@ -8863,7 +7875,7 @@
"options"
],
"support": {
- "source": "https://github.com/symfony/options-resolver/tree/v7.3.3"
+ "source": "https://github.com/symfony/options-resolver/tree/v7.4.8"
},
"funding": [
{
@@ -8883,20 +7895,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-05T10:16:07+00:00"
+ "time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/polyfill-ctype",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
- "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638"
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638",
- "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2",
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2",
"shasum": ""
},
"require": {
@@ -8946,7 +7958,7 @@
"portable"
],
"support": {
- "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
},
"funding": [
{
@@ -8966,20 +7978,20 @@
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/polyfill-intl-grapheme",
- "version": "v1.33.0",
+ "version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-grapheme.git",
- "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70"
+ "reference": "e9247d281d694a5120554d9afaf54e070e88a603"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70",
- "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603",
+ "reference": "e9247d281d694a5120554d9afaf54e070e88a603",
"shasum": ""
},
"require": {
@@ -9028,7 +8040,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1"
},
"funding": [
{
@@ -9048,20 +8060,20 @@
"type": "tidelift"
}
],
- "time": "2025-06-27T09:58:17+00:00"
+ "time": "2026-05-26T05:58:03+00:00"
},
{
"name": "symfony/polyfill-intl-idn",
- "version": "v1.33.0",
+ "version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
- "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3"
+ "reference": "dc21118016c039a66235cf93d96b435ffb282412"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3",
- "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412",
+ "reference": "dc21118016c039a66235cf93d96b435ffb282412",
"shasum": ""
},
"require": {
@@ -9115,7 +8127,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1"
},
"funding": [
{
@@ -9135,20 +8147,20 @@
"type": "tidelift"
}
],
- "time": "2024-09-10T14:38:51+00:00"
+ "time": "2026-05-25T15:22:23+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
- "version": "v1.33.0",
+ "version": "v1.38.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
- "reference": "3833d7255cc303546435cb650316bff708a1c75c"
+ "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c",
- "reference": "3833d7255cc303546435cb650316bff708a1c75c",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b",
+ "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b",
"shasum": ""
},
"require": {
@@ -9200,7 +8212,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0"
},
"funding": [
{
@@ -9220,20 +8232,20 @@
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2026-05-25T13:48:31+00:00"
},
{
"name": "symfony/polyfill-mbstring",
- "version": "v1.33.0",
+ "version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
- "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493"
+ "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493",
- "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92",
+ "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92",
"shasum": ""
},
"require": {
@@ -9285,7 +8297,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1"
},
"funding": [
{
@@ -9305,7 +8317,7 @@
"type": "tidelift"
}
],
- "time": "2024-12-23T08:48:59+00:00"
+ "time": "2026-05-26T12:51:13+00:00"
},
{
"name": "symfony/polyfill-php56",
@@ -9377,16 +8389,16 @@
},
{
"name": "symfony/polyfill-php80",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
- "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608"
+ "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
- "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
+ "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
+ "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
"shasum": ""
},
"require": {
@@ -9437,7 +8449,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0"
},
"funding": [
{
@@ -9457,20 +8469,20 @@
"type": "tidelift"
}
],
- "time": "2025-01-02T08:10:11+00:00"
+ "time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/polyfill-php83",
- "version": "v1.33.0",
+ "version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php83.git",
- "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5"
+ "reference": "8339098cae28673c15cce00d80734af0453054e2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5",
- "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5",
+ "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/8339098cae28673c15cce00d80734af0453054e2",
+ "reference": "8339098cae28673c15cce00d80734af0453054e2",
"shasum": ""
},
"require": {
@@ -9517,7 +8529,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.1"
},
"funding": [
{
@@ -9537,20 +8549,20 @@
"type": "tidelift"
}
],
- "time": "2025-07-08T02:45:35+00:00"
+ "time": "2026-05-26T12:51:13+00:00"
},
{
"name": "symfony/polyfill-php84",
- "version": "v1.33.0",
+ "version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php84.git",
- "reference": "d8ced4d875142b6a7426000426b8abc631d6b191"
+ "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191",
- "reference": "d8ced4d875142b6a7426000426b8abc631d6b191",
+ "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa",
+ "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa",
"shasum": ""
},
"require": {
@@ -9597,7 +8609,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1"
},
"funding": [
{
@@ -9617,20 +8629,20 @@
"type": "tidelift"
}
],
- "time": "2025-06-24T13:30:11+00:00"
+ "time": "2026-05-26T12:51:13+00:00"
},
{
"name": "symfony/polyfill-php85",
- "version": "v1.33.0",
+ "version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php85.git",
- "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91"
+ "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91",
- "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91",
+ "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1",
+ "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1",
"shasum": ""
},
"require": {
@@ -9677,7 +8689,87 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-26T02:25:22+00:00"
+ },
+ {
+ "name": "symfony/polyfill-php86",
+ "version": "v1.38.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php86.git",
+ "reference": "fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad",
+ "reference": "fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Php86\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php86/tree/v1.38.0"
},
"funding": [
{
@@ -9697,20 +8789,20 @@
"type": "tidelift"
}
],
- "time": "2025-06-23T16:12:55+00:00"
+ "time": "2026-05-25T11:52:35+00:00"
},
{
"name": "symfony/polyfill-uuid",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-uuid.git",
- "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2"
+ "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2",
- "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2",
+ "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94",
+ "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94",
"shasum": ""
},
"require": {
@@ -9760,7 +8852,7 @@
"uuid"
],
"support": {
- "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0"
},
"funding": [
{
@@ -9780,20 +8872,20 @@
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/postmark-mailer",
- "version": "v7.4.0",
+ "version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/postmark-mailer.git",
- "reference": "67eab9e06ff2adf74152df2ac95a07cef48eb7c5"
+ "reference": "ffda8a32cfbef6cd33ed544e811eb992d6f62347"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/postmark-mailer/zipball/67eab9e06ff2adf74152df2ac95a07cef48eb7c5",
- "reference": "67eab9e06ff2adf74152df2ac95a07cef48eb7c5",
+ "url": "https://api.github.com/repos/symfony/postmark-mailer/zipball/ffda8a32cfbef6cd33ed544e811eb992d6f62347",
+ "reference": "ffda8a32cfbef6cd33ed544e811eb992d6f62347",
"shasum": ""
},
"require": {
@@ -9834,7 +8926,7 @@
"description": "Symfony Postmark Mailer Bridge",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/postmark-mailer/tree/v7.4.0"
+ "source": "https://github.com/symfony/postmark-mailer/tree/v7.4.13"
},
"funding": [
{
@@ -9854,20 +8946,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-04T07:05:15+00:00"
+ "time": "2026-05-23T16:05:06+00:00"
},
{
"name": "symfony/process",
- "version": "v7.3.4",
+ "version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
- "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b"
+ "reference": "f5804be144caceb570f6747519999636b664f24c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/process/zipball/f24f8f316367b30810810d4eb30c543d7003ff3b",
- "reference": "f24f8f316367b30810810d4eb30c543d7003ff3b",
+ "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c",
+ "reference": "f5804be144caceb570f6747519999636b664f24c",
"shasum": ""
},
"require": {
@@ -9899,7 +8991,7 @@
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/process/tree/v7.3.4"
+ "source": "https://github.com/symfony/process/tree/v7.4.13"
},
"funding": [
{
@@ -9919,26 +9011,26 @@
"type": "tidelift"
}
],
- "time": "2025-09-11T10:12:26+00:00"
+ "time": "2026-05-23T16:05:06+00:00"
},
{
"name": "symfony/psr-http-message-bridge",
- "version": "v7.3.0",
+ "version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/psr-http-message-bridge.git",
- "reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f"
+ "reference": "76f1a57719a4a04c0ea18678a6c9305b5dcb9da8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/03f2f72319e7acaf2a9f6fcbe30ef17eec51594f",
- "reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f",
+ "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/76f1a57719a4a04c0ea18678a6c9305b5dcb9da8",
+ "reference": "76f1a57719a4a04c0ea18678a6c9305b5dcb9da8",
"shasum": ""
},
"require": {
"php": ">=8.2",
"psr/http-message": "^1.0|^2.0",
- "symfony/http-foundation": "^6.4|^7.0"
+ "symfony/http-foundation": "^6.4|^7.0|^8.0"
},
"conflict": {
"php-http/discovery": "<1.15",
@@ -9948,11 +9040,12 @@
"nyholm/psr7": "^1.1",
"php-http/discovery": "^1.15",
"psr/log": "^1.1.4|^2|^3",
- "symfony/browser-kit": "^6.4|^7.0",
- "symfony/config": "^6.4|^7.0",
- "symfony/event-dispatcher": "^6.4|^7.0",
- "symfony/framework-bundle": "^6.4|^7.0",
- "symfony/http-kernel": "^6.4|^7.0"
+ "symfony/browser-kit": "^6.4|^7.0|^8.0",
+ "symfony/config": "^6.4|^7.0|^8.0",
+ "symfony/event-dispatcher": "^6.4|^7.0|^8.0",
+ "symfony/framework-bundle": "^6.4.13|^7.1.6|^8.0",
+ "symfony/http-kernel": "^6.4.13|^7.1.6|^8.0",
+ "symfony/runtime": "^6.4.13|^7.1.6|^8.0"
},
"type": "symfony-bridge",
"autoload": {
@@ -9986,7 +9079,7 @@
"psr-7"
],
"support": {
- "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.3.0"
+ "source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.4.8"
},
"funding": [
{
@@ -9997,25 +9090,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-09-26T08:57:56+00:00"
+ "time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/routing",
- "version": "v7.3.4",
+ "version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/routing.git",
- "reference": "8dc648e159e9bac02b703b9fbd937f19ba13d07c"
+ "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/routing/zipball/8dc648e159e9bac02b703b9fbd937f19ba13d07c",
- "reference": "8dc648e159e9bac02b703b9fbd937f19ba13d07c",
+ "url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d",
+ "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d",
"shasum": ""
},
"require": {
@@ -10029,11 +9126,11 @@
},
"require-dev": {
"psr/log": "^1|^2|^3",
- "symfony/config": "^6.4|^7.0",
- "symfony/dependency-injection": "^6.4|^7.0",
- "symfony/expression-language": "^6.4|^7.0",
- "symfony/http-foundation": "^6.4|^7.0",
- "symfony/yaml": "^6.4|^7.0"
+ "symfony/config": "^6.4|^7.0|^8.0",
+ "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+ "symfony/expression-language": "^6.4|^7.0|^8.0",
+ "symfony/http-foundation": "^6.4|^7.0|^8.0",
+ "symfony/yaml": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
@@ -10067,7 +9164,7 @@
"url"
],
"support": {
- "source": "https://github.com/symfony/routing/tree/v7.3.4"
+ "source": "https://github.com/symfony/routing/tree/v7.4.13"
},
"funding": [
{
@@ -10087,20 +9184,20 @@
"type": "tidelift"
}
],
- "time": "2025-09-11T10:12:26+00:00"
+ "time": "2026-05-24T11:20:33+00:00"
},
{
"name": "symfony/service-contracts",
- "version": "v3.6.0",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/service-contracts.git",
- "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4"
+ "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4",
- "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a",
+ "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a",
"shasum": ""
},
"require": {
@@ -10118,7 +9215,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -10154,7 +9251,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/service-contracts/tree/v3.6.0"
+ "source": "https://github.com/symfony/service-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -10165,31 +9262,36 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-04-25T09:37:31+00:00"
+ "time": "2026-03-28T09:44:51+00:00"
},
{
"name": "symfony/string",
- "version": "v7.3.4",
+ "version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
- "reference": "f96476035142921000338bad71e5247fbc138872"
+ "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/string/zipball/f96476035142921000338bad71e5247fbc138872",
- "reference": "f96476035142921000338bad71e5247fbc138872",
+ "url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde",
+ "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde",
"shasum": ""
},
"require": {
"php": ">=8.2",
+ "symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/polyfill-ctype": "~1.8",
- "symfony/polyfill-intl-grapheme": "~1.0",
+ "symfony/polyfill-intl-grapheme": "~1.33",
"symfony/polyfill-intl-normalizer": "~1.0",
"symfony/polyfill-mbstring": "~1.0"
},
@@ -10197,11 +9299,11 @@
"symfony/translation-contracts": "<2.5"
},
"require-dev": {
- "symfony/emoji": "^7.1",
- "symfony/http-client": "^6.4|^7.0",
- "symfony/intl": "^6.4|^7.0",
+ "symfony/emoji": "^7.1|^8.0",
+ "symfony/http-client": "^6.4|^7.0|^8.0",
+ "symfony/intl": "^6.4|^7.0|^8.0",
"symfony/translation-contracts": "^2.5|^3.0",
- "symfony/var-exporter": "^6.4|^7.0"
+ "symfony/var-exporter": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
@@ -10240,7 +9342,7 @@
"utf8"
],
"support": {
- "source": "https://github.com/symfony/string/tree/v7.3.4"
+ "source": "https://github.com/symfony/string/tree/v7.4.13"
},
"funding": [
{
@@ -10260,27 +9362,27 @@
"type": "tidelift"
}
],
- "time": "2025-09-11T14:36:48+00:00"
+ "time": "2026-05-23T15:23:29+00:00"
},
{
"name": "symfony/translation",
- "version": "v7.3.4",
+ "version": "v7.4.10",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation.git",
- "reference": "ec25870502d0c7072d086e8ffba1420c85965174"
+ "reference": "ada7578c30dd5feaa8259cff3e885069ea81ddde"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/translation/zipball/ec25870502d0c7072d086e8ffba1420c85965174",
- "reference": "ec25870502d0c7072d086e8ffba1420c85965174",
+ "url": "https://api.github.com/repos/symfony/translation/zipball/ada7578c30dd5feaa8259cff3e885069ea81ddde",
+ "reference": "ada7578c30dd5feaa8259cff3e885069ea81ddde",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-mbstring": "~1.0",
- "symfony/translation-contracts": "^2.5|^3.0"
+ "symfony/translation-contracts": "^2.5.3|^3.3"
},
"conflict": {
"nikic/php-parser": "<5.0",
@@ -10299,17 +9401,17 @@
"require-dev": {
"nikic/php-parser": "^5.0",
"psr/log": "^1|^2|^3",
- "symfony/config": "^6.4|^7.0",
- "symfony/console": "^6.4|^7.0",
- "symfony/dependency-injection": "^6.4|^7.0",
- "symfony/finder": "^6.4|^7.0",
+ "symfony/config": "^6.4|^7.0|^8.0",
+ "symfony/console": "^6.4|^7.0|^8.0",
+ "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+ "symfony/finder": "^6.4|^7.0|^8.0",
"symfony/http-client-contracts": "^2.5|^3.0",
- "symfony/http-kernel": "^6.4|^7.0",
- "symfony/intl": "^6.4|^7.0",
+ "symfony/http-kernel": "^6.4|^7.0|^8.0",
+ "symfony/intl": "^6.4|^7.0|^8.0",
"symfony/polyfill-intl-icu": "^1.21",
- "symfony/routing": "^6.4|^7.0",
+ "symfony/routing": "^6.4|^7.0|^8.0",
"symfony/service-contracts": "^2.5|^3",
- "symfony/yaml": "^6.4|^7.0"
+ "symfony/yaml": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
@@ -10340,7 +9442,7 @@
"description": "Provides tools to internationalize your application",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/translation/tree/v7.3.4"
+ "source": "https://github.com/symfony/translation/tree/v7.4.10"
},
"funding": [
{
@@ -10360,20 +9462,20 @@
"type": "tidelift"
}
],
- "time": "2025-09-07T11:39:36+00:00"
+ "time": "2026-05-06T11:19:24+00:00"
},
{
"name": "symfony/translation-contracts",
- "version": "v3.6.0",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation-contracts.git",
- "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d"
+ "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d",
- "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d",
+ "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d",
+ "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d",
"shasum": ""
},
"require": {
@@ -10386,7 +9488,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -10422,7 +9524,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/translation-contracts/tree/v3.6.0"
+ "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -10433,25 +9535,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-09-27T08:32:26+00:00"
+ "time": "2026-01-05T13:30:16+00:00"
},
{
"name": "symfony/uid",
- "version": "v7.3.1",
+ "version": "v7.4.9",
"source": {
"type": "git",
"url": "https://github.com/symfony/uid.git",
- "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb"
+ "reference": "2676b524340abcfe4d6151ec698463cebafee439"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/uid/zipball/a69f69f3159b852651a6bf45a9fdd149520525bb",
- "reference": "a69f69f3159b852651a6bf45a9fdd149520525bb",
+ "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439",
+ "reference": "2676b524340abcfe4d6151ec698463cebafee439",
"shasum": ""
},
"require": {
@@ -10459,7 +9565,7 @@
"symfony/polyfill-uuid": "^1.15"
},
"require-dev": {
- "symfony/console": "^6.4|^7.0"
+ "symfony/console": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
@@ -10496,7 +9602,7 @@
"uuid"
],
"support": {
- "source": "https://github.com/symfony/uid/tree/v7.3.1"
+ "source": "https://github.com/symfony/uid/tree/v7.4.9"
},
"funding": [
{
@@ -10507,25 +9613,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-06-27T19:55:54+00:00"
+ "time": "2026-04-30T15:19:22+00:00"
},
{
"name": "symfony/var-dumper",
- "version": "v7.3.4",
+ "version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/var-dumper.git",
- "reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb"
+ "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/var-dumper/zipball/b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb",
- "reference": "b8abe7daf2730d07dfd4b2ee1cecbf0dd2fbdabb",
+ "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd",
+ "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd",
"shasum": ""
},
"require": {
@@ -10537,10 +9647,10 @@
"symfony/console": "<6.4"
},
"require-dev": {
- "symfony/console": "^6.4|^7.0",
- "symfony/http-kernel": "^6.4|^7.0",
- "symfony/process": "^6.4|^7.0",
- "symfony/uid": "^6.4|^7.0",
+ "symfony/console": "^6.4|^7.0|^8.0",
+ "symfony/http-kernel": "^6.4|^7.0|^8.0",
+ "symfony/process": "^6.4|^7.0|^8.0",
+ "symfony/uid": "^6.4|^7.0|^8.0",
"twig/twig": "^3.12"
},
"bin": [
@@ -10579,7 +9689,7 @@
"dump"
],
"support": {
- "source": "https://github.com/symfony/var-dumper/tree/v7.3.4"
+ "source": "https://github.com/symfony/var-dumper/tree/v7.4.8"
},
"funding": [
{
@@ -10599,27 +9709,170 @@
"type": "tidelift"
}
],
- "time": "2025-09-11T10:12:26+00:00"
+ "time": "2026-03-30T13:44:50+00:00"
+ },
+ {
+ "name": "thecodingmachine/safe",
+ "version": "v3.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thecodingmachine/safe.git",
+ "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
+ "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "php-parallel-lint/php-parallel-lint": "^1.4",
+ "phpstan/phpstan": "^2",
+ "phpunit/phpunit": "^10",
+ "squizlabs/php_codesniffer": "^3.2"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "lib/special_cases.php",
+ "generated/apache.php",
+ "generated/apcu.php",
+ "generated/array.php",
+ "generated/bzip2.php",
+ "generated/calendar.php",
+ "generated/classobj.php",
+ "generated/com.php",
+ "generated/cubrid.php",
+ "generated/curl.php",
+ "generated/datetime.php",
+ "generated/dir.php",
+ "generated/eio.php",
+ "generated/errorfunc.php",
+ "generated/exec.php",
+ "generated/fileinfo.php",
+ "generated/filesystem.php",
+ "generated/filter.php",
+ "generated/fpm.php",
+ "generated/ftp.php",
+ "generated/funchand.php",
+ "generated/gettext.php",
+ "generated/gmp.php",
+ "generated/gnupg.php",
+ "generated/hash.php",
+ "generated/ibase.php",
+ "generated/ibmDb2.php",
+ "generated/iconv.php",
+ "generated/image.php",
+ "generated/imap.php",
+ "generated/info.php",
+ "generated/inotify.php",
+ "generated/json.php",
+ "generated/ldap.php",
+ "generated/libxml.php",
+ "generated/lzf.php",
+ "generated/mailparse.php",
+ "generated/mbstring.php",
+ "generated/misc.php",
+ "generated/mysql.php",
+ "generated/mysqli.php",
+ "generated/network.php",
+ "generated/oci8.php",
+ "generated/opcache.php",
+ "generated/openssl.php",
+ "generated/outcontrol.php",
+ "generated/pcntl.php",
+ "generated/pcre.php",
+ "generated/pgsql.php",
+ "generated/posix.php",
+ "generated/ps.php",
+ "generated/pspell.php",
+ "generated/readline.php",
+ "generated/rnp.php",
+ "generated/rpminfo.php",
+ "generated/rrd.php",
+ "generated/sem.php",
+ "generated/session.php",
+ "generated/shmop.php",
+ "generated/sockets.php",
+ "generated/sodium.php",
+ "generated/solr.php",
+ "generated/spl.php",
+ "generated/sqlsrv.php",
+ "generated/ssdeep.php",
+ "generated/ssh2.php",
+ "generated/stream.php",
+ "generated/strings.php",
+ "generated/swoole.php",
+ "generated/uodbc.php",
+ "generated/uopz.php",
+ "generated/url.php",
+ "generated/var.php",
+ "generated/xdiff.php",
+ "generated/xml.php",
+ "generated/xmlrpc.php",
+ "generated/yaml.php",
+ "generated/yaz.php",
+ "generated/zip.php",
+ "generated/zlib.php"
+ ],
+ "classmap": [
+ "lib/DateTime.php",
+ "lib/DateTimeImmutable.php",
+ "lib/Exceptions/",
+ "generated/Exceptions/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "PHP core functions that throw exceptions instead of returning FALSE on error",
+ "support": {
+ "issues": "https://github.com/thecodingmachine/safe/issues",
+ "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/OskarStark",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/shish",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/silasjoisten",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/staabm",
+ "type": "github"
+ }
+ ],
+ "time": "2026-02-04T18:08:13+00:00"
},
{
"name": "tijsverkoyen/css-to-inline-styles",
- "version": "v2.3.0",
+ "version": "v2.4.0",
"source": {
"type": "git",
"url": "https://github.com/tijsverkoyen/CssToInlineStyles.git",
- "reference": "0d72ac1c00084279c1816675284073c5a337c20d"
+ "reference": "f0292ccf0ec75843d65027214426b6b163b48b41"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d",
- "reference": "0d72ac1c00084279c1816675284073c5a337c20d",
+ "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41",
+ "reference": "f0292ccf0ec75843d65027214426b6b163b48b41",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-libxml": "*",
"php": "^7.4 || ^8.0",
- "symfony/css-selector": "^5.4 || ^6.0 || ^7.0"
+ "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^2.0",
@@ -10652,32 +9905,32 @@
"homepage": "https://github.com/tijsverkoyen/CssToInlineStyles",
"support": {
"issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues",
- "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0"
+ "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0"
},
- "time": "2024-12-21T16:25:41+00:00"
+ "time": "2025-12-02T11:56:42+00:00"
},
{
"name": "vlucas/phpdotenv",
- "version": "v5.6.2",
+ "version": "v5.6.3",
"source": {
"type": "git",
"url": "https://github.com/vlucas/phpdotenv.git",
- "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af"
+ "reference": "955e7815d677a3eaa7075231212f2110983adecc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af",
- "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af",
+ "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc",
+ "reference": "955e7815d677a3eaa7075231212f2110983adecc",
"shasum": ""
},
"require": {
"ext-pcre": "*",
- "graham-campbell/result-type": "^1.1.3",
+ "graham-campbell/result-type": "^1.1.4",
"php": "^7.2.5 || ^8.0",
- "phpoption/phpoption": "^1.9.3",
- "symfony/polyfill-ctype": "^1.24",
- "symfony/polyfill-mbstring": "^1.24",
- "symfony/polyfill-php80": "^1.24"
+ "phpoption/phpoption": "^1.9.5",
+ "symfony/polyfill-ctype": "^1.26",
+ "symfony/polyfill-mbstring": "^1.26",
+ "symfony/polyfill-php80": "^1.26"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
@@ -10726,7 +9979,7 @@
],
"support": {
"issues": "https://github.com/vlucas/phpdotenv/issues",
- "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2"
+ "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3"
},
"funding": [
{
@@ -10738,27 +9991,27 @@
"type": "tidelift"
}
],
- "time": "2025-04-30T23:37:27+00:00"
+ "time": "2025-12-27T19:49:13+00:00"
},
{
"name": "voku/portable-ascii",
- "version": "2.0.3",
+ "version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/voku/portable-ascii.git",
- "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d"
+ "reference": "8e1051fe39379367aecf014f41744ce7539a856f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d",
- "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d",
+ "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f",
+ "reference": "8e1051fe39379367aecf014f41744ce7539a856f",
"shasum": ""
},
"require": {
- "php": ">=7.0.0"
+ "php": ">=7.1.0"
},
"require-dev": {
- "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0"
+ "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5"
},
"suggest": {
"ext-intl": "Use Intl for transliterator_transliterate() support"
@@ -10788,7 +10041,7 @@
],
"support": {
"issues": "https://github.com/voku/portable-ascii/issues",
- "source": "https://github.com/voku/portable-ascii/tree/2.0.3"
+ "source": "https://github.com/voku/portable-ascii/tree/2.1.1"
},
"funding": [
{
@@ -10812,37 +10065,41 @@
"type": "tidelift"
}
],
- "time": "2024-11-21T01:49:47+00:00"
+ "time": "2026-04-26T05:33:54+00:00"
},
{
"name": "webmozart/assert",
- "version": "1.11.0",
+ "version": "2.4.0",
"source": {
"type": "git",
"url": "https://github.com/webmozarts/assert.git",
- "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991"
+ "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991",
- "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991",
+ "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155",
+ "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155",
"shasum": ""
},
"require": {
"ext-ctype": "*",
- "php": "^7.2 || ^8.0"
- },
- "conflict": {
- "phpstan/phpstan": "<0.12.20",
- "vimeo/psalm": "<4.6.1 || 4.6.2"
+ "ext-date": "*",
+ "ext-filter": "*",
+ "php": "^8.2"
},
- "require-dev": {
- "phpunit/phpunit": "^8.5.13"
+ "suggest": {
+ "ext-intl": "",
+ "ext-simplexml": "",
+ "ext-spl": ""
},
"type": "library",
"extra": {
+ "psalm": {
+ "pluginClass": "Webmozart\\Assert\\PsalmPlugin"
+ },
"branch-alias": {
- "dev-master": "1.10-dev"
+ "dev-master": "2.0-dev",
+ "dev-feature/2-0": "2.0-dev"
}
},
"autoload": {
@@ -10858,6 +10115,10 @@
{
"name": "Bernhard Schussek",
"email": "bschussek@gmail.com"
+ },
+ {
+ "name": "Woody Gilk",
+ "email": "woody.gilk@gmail.com"
}
],
"description": "Assertions to validate method input/output with nice error messages.",
@@ -10868,37 +10129,37 @@
],
"support": {
"issues": "https://github.com/webmozarts/assert/issues",
- "source": "https://github.com/webmozarts/assert/tree/1.11.0"
+ "source": "https://github.com/webmozarts/assert/tree/2.4.0"
},
- "time": "2022-06-03T18:03:27+00:00"
+ "time": "2026-05-20T13:07:01+00:00"
}
],
"packages-dev": [
{
"name": "druc/laravel-langscanner",
- "version": "dev-l12-compatibility",
+ "version": "dev-l13-compatibility",
"source": {
"type": "git",
"url": "https://github.com/laravel-shift/laravel-langscanner.git",
- "reference": "a4efee46f730e389a8ae53f7495468d123cfee5c"
+ "reference": "5928a524a209c62dde4fef0265a0a03938d672e0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel-shift/laravel-langscanner/zipball/a4efee46f730e389a8ae53f7495468d123cfee5c",
- "reference": "a4efee46f730e389a8ae53f7495468d123cfee5c",
+ "url": "https://api.github.com/repos/laravel-shift/laravel-langscanner/zipball/5928a524a209c62dde4fef0265a0a03938d672e0",
+ "reference": "5928a524a209c62dde4fef0265a0a03938d672e0",
"shasum": ""
},
"require": {
"ext-json": "*",
- "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0",
+ "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0|^13.0",
"php": "^8.0",
"spatie/laravel-package-tools": "^1.11.3"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.3",
- "nunomaduro/collision": "^6.2",
- "orchestra/testbench": "^7.02|^9.0|^10.0",
- "phpunit/phpunit": "^9.5|^10.1|^11.5.3"
+ "nunomaduro/collision": "^6.2|^7|^8",
+ "orchestra/testbench": "^7.02|^9.0|^11.0",
+ "phpunit/phpunit": "^9.5|^10.1|^11.0|^12.5.12"
},
"type": "library",
"extra": {
@@ -10943,7 +10204,7 @@
"laravel-langscanner"
],
"support": {
- "source": "https://github.com/laravel-shift/laravel-langscanner/tree/l12-compatibility"
+ "source": "https://github.com/laravel-shift/laravel-langscanner/tree/l13-compatibility"
},
"funding": [
{
@@ -10951,7 +10212,7 @@
"url": "https://github.com/druc"
}
],
- "time": "2025-02-19T14:38:40+00:00"
+ "time": "2026-03-05T14:57:48+00:00"
},
{
"name": "fakerphp/faker",
@@ -11018,16 +10279,16 @@
},
{
"name": "filp/whoops",
- "version": "2.18.0",
+ "version": "2.18.4",
"source": {
"type": "git",
"url": "https://github.com/filp/whoops.git",
- "reference": "a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e"
+ "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/filp/whoops/zipball/a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e",
- "reference": "a7de6c3c6c3c022f5cfc337f8ede6a14460cf77e",
+ "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d",
+ "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d",
"shasum": ""
},
"require": {
@@ -11077,7 +10338,7 @@
],
"support": {
"issues": "https://github.com/filp/whoops/issues",
- "source": "https://github.com/filp/whoops/tree/2.18.0"
+ "source": "https://github.com/filp/whoops/tree/2.18.4"
},
"funding": [
{
@@ -11085,7 +10346,7 @@
"type": "github"
}
],
- "time": "2025-03-15T12:00:00+00:00"
+ "time": "2025-08-08T12:00:00+00:00"
},
{
"name": "gettext/gettext",
@@ -11163,16 +10424,16 @@
},
{
"name": "gettext/languages",
- "version": "2.12.1",
+ "version": "2.12.2",
"source": {
"type": "git",
"url": "https://github.com/php-gettext/Languages.git",
- "reference": "0b0b0851c55168e1dfb14305735c64019732b5f1"
+ "reference": "079d6f4842cbcbf5673a70d8e93169a684e7aadd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-gettext/Languages/zipball/0b0b0851c55168e1dfb14305735c64019732b5f1",
- "reference": "0b0b0851c55168e1dfb14305735c64019732b5f1",
+ "url": "https://api.github.com/repos/php-gettext/Languages/zipball/079d6f4842cbcbf5673a70d8e93169a684e7aadd",
+ "reference": "079d6f4842cbcbf5673a70d8e93169a684e7aadd",
"shasum": ""
},
"require": {
@@ -11222,7 +10483,7 @@
],
"support": {
"issues": "https://github.com/php-gettext/Languages/issues",
- "source": "https://github.com/php-gettext/Languages/tree/2.12.1"
+ "source": "https://github.com/php-gettext/Languages/tree/2.12.2"
},
"funding": [
{
@@ -11234,7 +10495,7 @@
"type": "github"
}
],
- "time": "2025-03-19T11:14:02+00:00"
+ "time": "2026-02-23T14:05:50+00:00"
},
{
"name": "hamcrest/hamcrest-php",
@@ -11289,16 +10550,16 @@
},
{
"name": "laravel/pint",
- "version": "v1.22.1",
+ "version": "v1.29.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/pint.git",
- "reference": "941d1927c5ca420c22710e98420287169c7bcaf7"
+ "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/pint/zipball/941d1927c5ca420c22710e98420287169c7bcaf7",
- "reference": "941d1927c5ca420c22710e98420287169c7bcaf7",
+ "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80",
+ "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80",
"shasum": ""
},
"require": {
@@ -11309,13 +10570,14 @@
"php": "^8.2.0"
},
"require-dev": {
- "friendsofphp/php-cs-fixer": "^3.75.0",
- "illuminate/view": "^11.44.7",
- "larastan/larastan": "^3.4.0",
- "laravel-zero/framework": "^11.36.1",
+ "friendsofphp/php-cs-fixer": "^3.95.1",
+ "illuminate/view": "^12.56.0",
+ "larastan/larastan": "^3.9.6",
+ "laravel-zero/framework": "^12.1.0",
"mockery/mockery": "^1.6.12",
- "nunomaduro/termwind": "^2.3.1",
- "pestphp/pest": "^2.36.0"
+ "nunomaduro/termwind": "^2.4.0",
+ "pestphp/pest": "^3.8.6",
+ "shipfastlabs/agent-detector": "^1.1.3"
},
"bin": [
"builds/pint"
@@ -11341,6 +10603,7 @@
"description": "An opinionated code formatter for PHP.",
"homepage": "https://laravel.com",
"keywords": [
+ "dev",
"format",
"formatter",
"lint",
@@ -11351,33 +10614,33 @@
"issues": "https://github.com/laravel/pint/issues",
"source": "https://github.com/laravel/pint"
},
- "time": "2025-05-08T08:38:12+00:00"
+ "time": "2026-04-20T15:26:14+00:00"
},
{
"name": "laravel/sail",
- "version": "v1.43.0",
+ "version": "v1.61.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/sail.git",
- "reference": "71a509b14b2621ce58574274a74290f933c687f7"
+ "reference": "68ef35015630fe510432e63e11e21749006df688"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/sail/zipball/71a509b14b2621ce58574274a74290f933c687f7",
- "reference": "71a509b14b2621ce58574274a74290f933c687f7",
+ "url": "https://api.github.com/repos/laravel/sail/zipball/68ef35015630fe510432e63e11e21749006df688",
+ "reference": "68ef35015630fe510432e63e11e21749006df688",
"shasum": ""
},
"require": {
- "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0",
- "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0",
- "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0",
+ "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0|^13.0",
+ "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0|^13.0",
+ "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0|^13.0",
"php": "^8.0",
- "symfony/console": "^6.0|^7.0",
- "symfony/yaml": "^6.0|^7.0"
+ "symfony/console": "^6.0|^7.0|^8.0",
+ "symfony/yaml": "^6.0|^7.0|^8.0"
},
"require-dev": {
- "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0",
- "phpstan/phpstan": "^1.10"
+ "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0",
+ "phpstan/phpstan": "^2.0"
},
"bin": [
"bin/sail"
@@ -11414,7 +10677,7 @@
"issues": "https://github.com/laravel/sail/issues",
"source": "https://github.com/laravel/sail"
},
- "time": "2025-05-13T13:34:34+00:00"
+ "time": "2026-05-23T23:33:57+00:00"
},
{
"name": "mockery/mockery",
@@ -11501,16 +10764,16 @@
},
{
"name": "myclabs/deep-copy",
- "version": "1.13.1",
+ "version": "1.13.4",
"source": {
"type": "git",
"url": "https://github.com/myclabs/DeepCopy.git",
- "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c"
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/1720ddd719e16cf0db4eb1c6eca108031636d46c",
- "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
"shasum": ""
},
"require": {
@@ -11549,7 +10812,7 @@
],
"support": {
"issues": "https://github.com/myclabs/DeepCopy/issues",
- "source": "https://github.com/myclabs/DeepCopy/tree/1.13.1"
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
},
"funding": [
{
@@ -11557,43 +10820,40 @@
"type": "tidelift"
}
],
- "time": "2025-04-29T12:36:36+00:00"
+ "time": "2025-08-01T08:46:24+00:00"
},
{
"name": "nunomaduro/collision",
- "version": "v8.8.0",
+ "version": "v8.9.4",
"source": {
"type": "git",
"url": "https://github.com/nunomaduro/collision.git",
- "reference": "4cf9f3b47afff38b139fb79ce54fc71799022ce8"
+ "reference": "716af8f95a470e9094cfca09ed897b023be191a5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nunomaduro/collision/zipball/4cf9f3b47afff38b139fb79ce54fc71799022ce8",
- "reference": "4cf9f3b47afff38b139fb79ce54fc71799022ce8",
+ "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5",
+ "reference": "716af8f95a470e9094cfca09ed897b023be191a5",
"shasum": ""
},
"require": {
- "filp/whoops": "^2.18.0",
- "nunomaduro/termwind": "^2.3.0",
+ "filp/whoops": "^2.18.4",
+ "nunomaduro/termwind": "^2.4.0",
"php": "^8.2.0",
- "symfony/console": "^7.2.5"
+ "symfony/console": "^7.4.8 || ^8.0.8"
},
"conflict": {
- "laravel/framework": "<11.44.2 || >=13.0.0",
- "phpunit/phpunit": "<11.5.15 || >=13.0.0"
+ "laravel/framework": "<11.48.0 || >=14.0.0",
+ "phpunit/phpunit": "<11.5.50 || >=14.0.0"
},
"require-dev": {
- "brianium/paratest": "^7.8.3",
- "larastan/larastan": "^3.2",
- "laravel/framework": "^11.44.2 || ^12.6",
- "laravel/pint": "^1.21.2",
- "laravel/sail": "^1.41.0",
- "laravel/sanctum": "^4.0.8",
- "laravel/tinker": "^2.10.1",
- "orchestra/testbench-core": "^9.12.0 || ^10.1",
- "pestphp/pest": "^3.8.0",
- "sebastian/environment": "^7.2.0 || ^8.0"
+ "brianium/paratest": "^7.8.5",
+ "larastan/larastan": "^3.9.6",
+ "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0",
+ "laravel/pint": "^1.29.1",
+ "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1",
+ "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0",
+ "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0"
},
"type": "library",
"extra": {
@@ -11656,7 +10916,7 @@
"type": "patreon"
}
],
- "time": "2025-04-03T14:33:09+00:00"
+ "time": "2026-04-21T14:04:20+00:00"
},
{
"name": "phar-io/manifest",
@@ -11778,35 +11038,33 @@
},
{
"name": "phpunit/php-code-coverage",
- "version": "11.0.9",
+ "version": "12.5.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
- "reference": "14d63fbcca18457e49c6f8bebaa91a87e8e188d7"
+ "reference": "876099a072646c7745f673d7aeab5382c4439691"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/14d63fbcca18457e49c6f8bebaa91a87e8e188d7",
- "reference": "14d63fbcca18457e49c6f8bebaa91a87e8e188d7",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691",
+ "reference": "876099a072646c7745f673d7aeab5382c4439691",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-libxml": "*",
"ext-xmlwriter": "*",
- "nikic/php-parser": "^5.4.0",
- "php": ">=8.2",
- "phpunit/php-file-iterator": "^5.1.0",
- "phpunit/php-text-template": "^4.0.1",
- "sebastian/code-unit-reverse-lookup": "^4.0.1",
- "sebastian/complexity": "^4.0.1",
- "sebastian/environment": "^7.2.0",
- "sebastian/lines-of-code": "^3.0.1",
- "sebastian/version": "^5.0.2",
- "theseer/tokenizer": "^1.2.3"
+ "nikic/php-parser": "^5.7.0",
+ "php": ">=8.3",
+ "phpunit/php-text-template": "^5.0",
+ "sebastian/complexity": "^5.0",
+ "sebastian/environment": "^8.0.3",
+ "sebastian/lines-of-code": "^4.0",
+ "sebastian/version": "^6.0",
+ "theseer/tokenizer": "^2.0.1"
},
"require-dev": {
- "phpunit/phpunit": "^11.5.2"
+ "phpunit/phpunit": "^12.5.1"
},
"suggest": {
"ext-pcov": "PHP extension that provides line coverage",
@@ -11815,7 +11073,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "11.0.x-dev"
+ "dev-main": "12.5.x-dev"
}
},
"autoload": {
@@ -11844,40 +11102,52 @@
"support": {
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
"security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
- "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.9"
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage",
+ "type": "tidelift"
}
],
- "time": "2025-02-25T13:26:39+00:00"
+ "time": "2026-04-15T08:23:17+00:00"
},
{
"name": "phpunit/php-file-iterator",
- "version": "5.1.0",
+ "version": "6.0.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-file-iterator.git",
- "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6"
+ "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6",
- "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5",
+ "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5",
"shasum": ""
},
"require": {
- "php": ">=8.2"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^11.0"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "5.0-dev"
+ "dev-main": "6.0-dev"
}
},
"autoload": {
@@ -11905,36 +11175,48 @@
"support": {
"issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
"security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
- "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0"
+ "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator",
+ "type": "tidelift"
}
],
- "time": "2024-08-27T05:02:59+00:00"
+ "time": "2026-02-02T14:04:18+00:00"
},
{
"name": "phpunit/php-invoker",
- "version": "5.0.1",
+ "version": "6.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-invoker.git",
- "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2"
+ "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2",
- "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406",
+ "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406",
"shasum": ""
},
"require": {
- "php": ">=8.2"
+ "php": ">=8.3"
},
"require-dev": {
"ext-pcntl": "*",
- "phpunit/phpunit": "^11.0"
+ "phpunit/phpunit": "^12.0"
},
"suggest": {
"ext-pcntl": "*"
@@ -11942,7 +11224,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "5.0-dev"
+ "dev-main": "6.0-dev"
}
},
"autoload": {
@@ -11969,7 +11251,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/php-invoker/issues",
"security": "https://github.com/sebastianbergmann/php-invoker/security/policy",
- "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1"
+ "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0"
},
"funding": [
{
@@ -11977,32 +11259,32 @@
"type": "github"
}
],
- "time": "2024-07-03T05:07:44+00:00"
+ "time": "2025-02-07T04:58:58+00:00"
},
{
"name": "phpunit/php-text-template",
- "version": "4.0.1",
+ "version": "5.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-text-template.git",
- "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964"
+ "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964",
- "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53",
+ "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53",
"shasum": ""
},
"require": {
- "php": ">=8.2"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^11.0"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "4.0-dev"
+ "dev-main": "5.0-dev"
}
},
"autoload": {
@@ -12029,7 +11311,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/php-text-template/issues",
"security": "https://github.com/sebastianbergmann/php-text-template/security/policy",
- "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1"
+ "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0"
},
"funding": [
{
@@ -12037,32 +11319,32 @@
"type": "github"
}
],
- "time": "2024-07-03T05:08:43+00:00"
+ "time": "2025-02-07T04:59:16+00:00"
},
{
"name": "phpunit/php-timer",
- "version": "7.0.1",
+ "version": "8.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-timer.git",
- "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3"
+ "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3",
- "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc",
+ "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc",
"shasum": ""
},
"require": {
- "php": ">=8.2"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^11.0"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "7.0-dev"
+ "dev-main": "8.0-dev"
}
},
"autoload": {
@@ -12089,7 +11371,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/php-timer/issues",
"security": "https://github.com/sebastianbergmann/php-timer/security/policy",
- "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1"
+ "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0"
},
"funding": [
{
@@ -12097,20 +11379,20 @@
"type": "github"
}
],
- "time": "2024-07-03T05:09:35+00:00"
+ "time": "2025-02-07T04:59:38+00:00"
},
{
"name": "phpunit/phpunit",
- "version": "11.5.20",
+ "version": "12.5.28",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "e6bdea63ecb7a8287d2cdab25bdde3126e0cfe6f"
+ "reference": "5895d05f5bf421ed230fbd76e1277e4b8955def4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e6bdea63ecb7a8287d2cdab25bdde3126e0cfe6f",
- "reference": "e6bdea63ecb7a8287d2cdab25bdde3126e0cfe6f",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5895d05f5bf421ed230fbd76e1277e4b8955def4",
+ "reference": "5895d05f5bf421ed230fbd76e1277e4b8955def4",
"shasum": ""
},
"require": {
@@ -12120,37 +11402,34 @@
"ext-mbstring": "*",
"ext-xml": "*",
"ext-xmlwriter": "*",
- "myclabs/deep-copy": "^1.13.1",
+ "myclabs/deep-copy": "^1.13.4",
"phar-io/manifest": "^2.0.4",
"phar-io/version": "^3.2.1",
- "php": ">=8.2",
- "phpunit/php-code-coverage": "^11.0.9",
- "phpunit/php-file-iterator": "^5.1.0",
- "phpunit/php-invoker": "^5.0.1",
- "phpunit/php-text-template": "^4.0.1",
- "phpunit/php-timer": "^7.0.1",
- "sebastian/cli-parser": "^3.0.2",
- "sebastian/code-unit": "^3.0.3",
- "sebastian/comparator": "^6.3.1",
- "sebastian/diff": "^6.0.2",
- "sebastian/environment": "^7.2.0",
- "sebastian/exporter": "^6.3.0",
- "sebastian/global-state": "^7.0.2",
- "sebastian/object-enumerator": "^6.0.1",
- "sebastian/type": "^5.1.2",
- "sebastian/version": "^5.0.2",
+ "php": ">=8.3",
+ "phpunit/php-code-coverage": "^12.5.6",
+ "phpunit/php-file-iterator": "^6.0.1",
+ "phpunit/php-invoker": "^6.0.0",
+ "phpunit/php-text-template": "^5.0.0",
+ "phpunit/php-timer": "^8.0.0",
+ "sebastian/cli-parser": "^4.2.1",
+ "sebastian/comparator": "^7.1.8",
+ "sebastian/diff": "^7.0.0",
+ "sebastian/environment": "^8.1.2",
+ "sebastian/exporter": "^7.0.3",
+ "sebastian/global-state": "^8.0.2",
+ "sebastian/object-enumerator": "^7.0.0",
+ "sebastian/recursion-context": "^7.0.1",
+ "sebastian/type": "^6.0.4",
+ "sebastian/version": "^6.0.0",
"staabm/side-effects-detector": "^1.0.5"
},
- "suggest": {
- "ext-soap": "To be able to generate mocks based on WSDL files"
- },
"bin": [
"phpunit"
],
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "11.5-dev"
+ "dev-main": "12.5-dev"
}
},
"autoload": {
@@ -12182,56 +11461,40 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
- "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.20"
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.28"
},
"funding": [
{
- "url": "https://phpunit.de/sponsors.html",
- "type": "custom"
- },
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- },
- {
- "url": "https://liberapay.com/sebastianbergmann",
- "type": "liberapay"
- },
- {
- "url": "https://thanks.dev/u/gh/sebastianbergmann",
- "type": "thanks_dev"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
- "type": "tidelift"
+ "url": "https://phpunit.de/sponsoring.html",
+ "type": "other"
}
],
- "time": "2025-05-11T06:39:52+00:00"
+ "time": "2026-05-27T14:01:10+00:00"
},
{
"name": "sebastian/cli-parser",
- "version": "3.0.2",
+ "version": "4.2.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/cli-parser.git",
- "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180"
+ "reference": "7d05781b13f7dec9043a629a21d086ed74582a15"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180",
- "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15",
+ "reference": "7d05781b13f7dec9043a629a21d086ed74582a15",
"shasum": ""
},
"require": {
- "php": ">=8.2"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^11.0"
+ "phpunit/phpunit": "^12.5.25"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "3.0-dev"
+ "dev-main": "4.2-dev"
}
},
"autoload": {
@@ -12255,152 +11518,51 @@
"support": {
"issues": "https://github.com/sebastianbergmann/cli-parser/issues",
"security": "https://github.com/sebastianbergmann/cli-parser/security/policy",
- "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2"
+ "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
- }
- ],
- "time": "2024-07-03T04:41:36+00:00"
- },
- {
- "name": "sebastian/code-unit",
- "version": "3.0.3",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/code-unit.git",
- "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64",
- "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2"
- },
- "require-dev": {
- "phpunit/phpunit": "^11.5"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "3.0-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
- {
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de",
- "role": "lead"
- }
- ],
- "description": "Collection of value objects that represent the PHP code units",
- "homepage": "https://github.com/sebastianbergmann/code-unit",
- "support": {
- "issues": "https://github.com/sebastianbergmann/code-unit/issues",
- "security": "https://github.com/sebastianbergmann/code-unit/security/policy",
- "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3"
- },
- "funding": [
+ },
{
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- }
- ],
- "time": "2025-03-19T07:56:08+00:00"
- },
- {
- "name": "sebastian/code-unit-reverse-lookup",
- "version": "4.0.1",
- "source": {
- "type": "git",
- "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
- "reference": "183a9b2632194febd219bb9246eee421dad8d45e"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e",
- "reference": "183a9b2632194febd219bb9246eee421dad8d45e",
- "shasum": ""
- },
- "require": {
- "php": ">=8.2"
- },
- "require-dev": {
- "phpunit/phpunit": "^11.0"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-main": "4.0-dev"
- }
- },
- "autoload": {
- "classmap": [
- "src/"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-3-Clause"
- ],
- "authors": [
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
{
- "name": "Sebastian Bergmann",
- "email": "sebastian@phpunit.de"
- }
- ],
- "description": "Looks up which function or method a line of code belongs to",
- "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
- "support": {
- "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
- "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy",
- "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1"
- },
- "funding": [
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
{
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser",
+ "type": "tidelift"
}
],
- "time": "2024-07-03T04:45:54+00:00"
+ "time": "2026-05-17T05:29:34+00:00"
},
{
"name": "sebastian/comparator",
- "version": "6.3.1",
+ "version": "7.1.8",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git",
- "reference": "24b8fbc2c8e201bb1308e7b05148d6ab393b6959"
+ "reference": "7c65c1e79836812819705b473a90c12399542485"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/24b8fbc2c8e201bb1308e7b05148d6ab393b6959",
- "reference": "24b8fbc2c8e201bb1308e7b05148d6ab393b6959",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485",
+ "reference": "7c65c1e79836812819705b473a90c12399542485",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-mbstring": "*",
- "php": ">=8.2",
- "sebastian/diff": "^6.0",
- "sebastian/exporter": "^6.0"
+ "php": ">=8.3",
+ "sebastian/diff": "^7.0",
+ "sebastian/exporter": "^7.0.3"
},
"require-dev": {
- "phpunit/phpunit": "^11.4"
+ "phpunit/phpunit": "^12.5.25"
},
"suggest": {
"ext-bcmath": "For comparing BcMath\\Number objects"
@@ -12408,7 +11570,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "6.3-dev"
+ "dev-main": "7.1-dev"
}
},
"autoload": {
@@ -12448,41 +11610,53 @@
"support": {
"issues": "https://github.com/sebastianbergmann/comparator/issues",
"security": "https://github.com/sebastianbergmann/comparator/security/policy",
- "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.1"
+ "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
+ "type": "tidelift"
}
],
- "time": "2025-03-07T06:57:01+00:00"
+ "time": "2026-05-21T04:45:25+00:00"
},
{
"name": "sebastian/complexity",
- "version": "4.0.1",
+ "version": "5.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/complexity.git",
- "reference": "ee41d384ab1906c68852636b6de493846e13e5a0"
+ "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0",
- "reference": "ee41d384ab1906c68852636b6de493846e13e5a0",
+ "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb",
+ "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb",
"shasum": ""
},
"require": {
"nikic/php-parser": "^5.0",
- "php": ">=8.2"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^11.0"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "4.0-dev"
+ "dev-main": "5.0-dev"
}
},
"autoload": {
@@ -12506,7 +11680,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/complexity/issues",
"security": "https://github.com/sebastianbergmann/complexity/security/policy",
- "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1"
+ "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0"
},
"funding": [
{
@@ -12514,33 +11688,33 @@
"type": "github"
}
],
- "time": "2024-07-03T04:49:50+00:00"
+ "time": "2025-02-07T04:55:25+00:00"
},
{
"name": "sebastian/diff",
- "version": "6.0.2",
+ "version": "7.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/diff.git",
- "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544"
+ "reference": "7ab1ea946c012266ca32390913653d844ecd085f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544",
- "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f",
+ "reference": "7ab1ea946c012266ca32390913653d844ecd085f",
"shasum": ""
},
"require": {
- "php": ">=8.2"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^11.0",
- "symfony/process": "^4.2 || ^5"
+ "phpunit/phpunit": "^12.0",
+ "symfony/process": "^7.2"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "6.0-dev"
+ "dev-main": "7.0-dev"
}
},
"autoload": {
@@ -12573,7 +11747,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/diff/issues",
"security": "https://github.com/sebastianbergmann/diff/security/policy",
- "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2"
+ "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0"
},
"funding": [
{
@@ -12581,27 +11755,27 @@
"type": "github"
}
],
- "time": "2024-07-03T04:53:05+00:00"
+ "time": "2025-02-07T04:55:46+00:00"
},
{
"name": "sebastian/environment",
- "version": "7.2.0",
+ "version": "8.1.2",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/environment.git",
- "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5"
+ "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5",
- "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439",
+ "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439",
"shasum": ""
},
"require": {
- "php": ">=8.2"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^11.0"
+ "phpunit/phpunit": "^12.5.26"
},
"suggest": {
"ext-posix": "*"
@@ -12609,7 +11783,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "7.2-dev"
+ "dev-main": "8.1-dev"
}
},
"autoload": {
@@ -12637,42 +11811,54 @@
"support": {
"issues": "https://github.com/sebastianbergmann/environment/issues",
"security": "https://github.com/sebastianbergmann/environment/security/policy",
- "source": "https://github.com/sebastianbergmann/environment/tree/7.2.0"
+ "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/environment",
+ "type": "tidelift"
}
],
- "time": "2024-07-03T04:54:44+00:00"
+ "time": "2026-05-25T13:40:20+00:00"
},
{
"name": "sebastian/exporter",
- "version": "6.3.0",
+ "version": "7.0.3",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/exporter.git",
- "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3"
+ "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/3473f61172093b2da7de1fb5782e1f24cc036dc3",
- "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23",
+ "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
- "php": ">=8.2",
- "sebastian/recursion-context": "^6.0"
+ "php": ">=8.3",
+ "sebastian/recursion-context": "^7.0.1"
},
"require-dev": {
- "phpunit/phpunit": "^11.3"
+ "phpunit/phpunit": "^12.5.25"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "6.1-dev"
+ "dev-main": "7.0-dev"
}
},
"autoload": {
@@ -12715,43 +11901,55 @@
"support": {
"issues": "https://github.com/sebastianbergmann/exporter/issues",
"security": "https://github.com/sebastianbergmann/exporter/security/policy",
- "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.0"
+ "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
+ "type": "tidelift"
}
],
- "time": "2024-12-05T09:17:50+00:00"
+ "time": "2026-05-20T04:37:17+00:00"
},
{
"name": "sebastian/global-state",
- "version": "7.0.2",
+ "version": "8.0.2",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/global-state.git",
- "reference": "3be331570a721f9a4b5917f4209773de17f747d7"
+ "reference": "ef1377171613d09edd25b7816f05be8313f9115d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7",
- "reference": "3be331570a721f9a4b5917f4209773de17f747d7",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d",
+ "reference": "ef1377171613d09edd25b7816f05be8313f9115d",
"shasum": ""
},
"require": {
- "php": ">=8.2",
- "sebastian/object-reflector": "^4.0",
- "sebastian/recursion-context": "^6.0"
+ "php": ">=8.3",
+ "sebastian/object-reflector": "^5.0",
+ "sebastian/recursion-context": "^7.0"
},
"require-dev": {
"ext-dom": "*",
- "phpunit/phpunit": "^11.0"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "7.0-dev"
+ "dev-main": "8.0-dev"
}
},
"autoload": {
@@ -12777,41 +11975,53 @@
"support": {
"issues": "https://github.com/sebastianbergmann/global-state/issues",
"security": "https://github.com/sebastianbergmann/global-state/security/policy",
- "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2"
+ "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state",
+ "type": "tidelift"
}
],
- "time": "2024-07-03T04:57:36+00:00"
+ "time": "2025-08-29T11:29:25+00:00"
},
{
"name": "sebastian/lines-of-code",
- "version": "3.0.1",
+ "version": "4.0.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/lines-of-code.git",
- "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a"
+ "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a",
- "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e",
+ "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e",
"shasum": ""
},
"require": {
- "nikic/php-parser": "^5.0",
- "php": ">=8.2"
+ "nikic/php-parser": "^5.7.0",
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^11.0"
+ "phpunit/phpunit": "^12.5.25"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "3.0-dev"
+ "dev-main": "4.0-dev"
}
},
"autoload": {
@@ -12835,42 +12045,54 @@
"support": {
"issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
"security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
- "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1"
+ "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code",
+ "type": "tidelift"
}
],
- "time": "2024-07-03T04:58:38+00:00"
+ "time": "2026-05-19T16:22:07+00:00"
},
{
"name": "sebastian/object-enumerator",
- "version": "6.0.1",
+ "version": "7.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/object-enumerator.git",
- "reference": "f5b498e631a74204185071eb41f33f38d64608aa"
+ "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa",
- "reference": "f5b498e631a74204185071eb41f33f38d64608aa",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894",
+ "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894",
"shasum": ""
},
"require": {
- "php": ">=8.2",
- "sebastian/object-reflector": "^4.0",
- "sebastian/recursion-context": "^6.0"
+ "php": ">=8.3",
+ "sebastian/object-reflector": "^5.0",
+ "sebastian/recursion-context": "^7.0"
},
"require-dev": {
- "phpunit/phpunit": "^11.0"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "6.0-dev"
+ "dev-main": "7.0-dev"
}
},
"autoload": {
@@ -12893,7 +12115,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
"security": "https://github.com/sebastianbergmann/object-enumerator/security/policy",
- "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1"
+ "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0"
},
"funding": [
{
@@ -12901,32 +12123,32 @@
"type": "github"
}
],
- "time": "2024-07-03T05:00:13+00:00"
+ "time": "2025-02-07T04:57:48+00:00"
},
{
"name": "sebastian/object-reflector",
- "version": "4.0.1",
+ "version": "5.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/object-reflector.git",
- "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9"
+ "reference": "4bfa827c969c98be1e527abd576533293c634f6a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9",
- "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a",
+ "reference": "4bfa827c969c98be1e527abd576533293c634f6a",
"shasum": ""
},
"require": {
- "php": ">=8.2"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^11.0"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "4.0-dev"
+ "dev-main": "5.0-dev"
}
},
"autoload": {
@@ -12949,7 +12171,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/object-reflector/issues",
"security": "https://github.com/sebastianbergmann/object-reflector/security/policy",
- "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1"
+ "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0"
},
"funding": [
{
@@ -12957,32 +12179,32 @@
"type": "github"
}
],
- "time": "2024-07-03T05:01:32+00:00"
+ "time": "2025-02-07T04:58:17+00:00"
},
{
"name": "sebastian/recursion-context",
- "version": "6.0.2",
+ "version": "7.0.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/recursion-context.git",
- "reference": "694d156164372abbd149a4b85ccda2e4670c0e16"
+ "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/694d156164372abbd149a4b85ccda2e4670c0e16",
- "reference": "694d156164372abbd149a4b85ccda2e4670c0e16",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c",
+ "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c",
"shasum": ""
},
"require": {
- "php": ">=8.2"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^11.0"
+ "phpunit/phpunit": "^12.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "6.0-dev"
+ "dev-main": "7.0-dev"
}
},
"autoload": {
@@ -13013,40 +12235,52 @@
"support": {
"issues": "https://github.com/sebastianbergmann/recursion-context/issues",
"security": "https://github.com/sebastianbergmann/recursion-context/security/policy",
- "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.2"
+ "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
+ "type": "tidelift"
}
],
- "time": "2024-07-03T05:10:34+00:00"
+ "time": "2025-08-13T04:44:59+00:00"
},
{
"name": "sebastian/type",
- "version": "5.1.2",
+ "version": "6.0.4",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/type.git",
- "reference": "a8a7e30534b0eb0c77cd9d07e82de1a114389f5e"
+ "reference": "82ff822c2edc46724be9f7411d3163021f602773"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/a8a7e30534b0eb0c77cd9d07e82de1a114389f5e",
- "reference": "a8a7e30534b0eb0c77cd9d07e82de1a114389f5e",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773",
+ "reference": "82ff822c2edc46724be9f7411d3163021f602773",
"shasum": ""
},
"require": {
- "php": ">=8.2"
+ "php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^11.3"
+ "phpunit/phpunit": "^12.5.25"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "5.1-dev"
+ "dev-main": "6.0-dev"
}
},
"autoload": {
@@ -13070,37 +12304,49 @@
"support": {
"issues": "https://github.com/sebastianbergmann/type/issues",
"security": "https://github.com/sebastianbergmann/type/security/policy",
- "source": "https://github.com/sebastianbergmann/type/tree/5.1.2"
+ "source": "https://github.com/sebastianbergmann/type/tree/6.0.4"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/type",
+ "type": "tidelift"
}
],
- "time": "2025-03-18T13:35:50+00:00"
+ "time": "2026-05-20T06:45:45+00:00"
},
{
"name": "sebastian/version",
- "version": "5.0.2",
+ "version": "6.0.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/version.git",
- "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874"
+ "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874",
- "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c",
+ "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c",
"shasum": ""
},
"require": {
- "php": ">=8.2"
+ "php": ">=8.3"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "5.0-dev"
+ "dev-main": "6.0-dev"
}
},
"autoload": {
@@ -13124,7 +12370,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/version/issues",
"security": "https://github.com/sebastianbergmann/version/security/policy",
- "source": "https://github.com/sebastianbergmann/version/tree/5.0.2"
+ "source": "https://github.com/sebastianbergmann/version/tree/6.0.0"
},
"funding": [
{
@@ -13132,20 +12378,20 @@
"type": "github"
}
],
- "time": "2024-10-09T05:16:32+00:00"
+ "time": "2025-02-07T05:00:38+00:00"
},
{
"name": "spatie/backtrace",
- "version": "1.7.4",
+ "version": "1.8.2",
"source": {
"type": "git",
"url": "https://github.com/spatie/backtrace.git",
- "reference": "cd37a49fce7137359ac30ecc44ef3e16404cccbe"
+ "reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/backtrace/zipball/cd37a49fce7137359ac30ecc44ef3e16404cccbe",
- "reference": "cd37a49fce7137359ac30ecc44ef3e16404cccbe",
+ "url": "https://api.github.com/repos/spatie/backtrace/zipball/8ffe78be5ed355b5009e3dd989d183433e9a5adc",
+ "reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc",
"shasum": ""
},
"require": {
@@ -13156,7 +12402,7 @@
"laravel/serializable-closure": "^1.3 || ^2.0",
"phpunit/phpunit": "^9.3 || ^11.4.3",
"spatie/phpunit-snapshot-assertions": "^4.2 || ^5.1.6",
- "symfony/var-dumper": "^5.1 || ^6.0 || ^7.0"
+ "symfony/var-dumper": "^5.1|^6.0|^7.0|^8.0"
},
"type": "library",
"autoload": {
@@ -13183,7 +12429,8 @@
"spatie"
],
"support": {
- "source": "https://github.com/spatie/backtrace/tree/1.7.4"
+ "issues": "https://github.com/spatie/backtrace/issues",
+ "source": "https://github.com/spatie/backtrace/tree/1.8.2"
},
"funding": [
{
@@ -13195,7 +12442,7 @@
"type": "other"
}
],
- "time": "2025-05-08T15:41:09+00:00"
+ "time": "2026-03-11T13:48:28+00:00"
},
{
"name": "spatie/error-solutions",
@@ -13273,26 +12520,26 @@
},
{
"name": "spatie/flare-client-php",
- "version": "1.10.1",
+ "version": "1.11.1",
"source": {
"type": "git",
"url": "https://github.com/spatie/flare-client-php.git",
- "reference": "bf1716eb98bd689451b071548ae9e70738dce62f"
+ "reference": "53f41b08a27cc039e1a8ed2be9a202e924f31bad"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/flare-client-php/zipball/bf1716eb98bd689451b071548ae9e70738dce62f",
- "reference": "bf1716eb98bd689451b071548ae9e70738dce62f",
+ "url": "https://api.github.com/repos/spatie/flare-client-php/zipball/53f41b08a27cc039e1a8ed2be9a202e924f31bad",
+ "reference": "53f41b08a27cc039e1a8ed2be9a202e924f31bad",
"shasum": ""
},
"require": {
- "illuminate/pipeline": "^8.0|^9.0|^10.0|^11.0|^12.0",
+ "illuminate/pipeline": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0",
"php": "^8.0",
"spatie/backtrace": "^1.6.1",
- "symfony/http-foundation": "^5.2|^6.0|^7.0",
- "symfony/mime": "^5.2|^6.0|^7.0",
- "symfony/process": "^5.2|^6.0|^7.0",
- "symfony/var-dumper": "^5.2|^6.0|^7.0"
+ "symfony/http-foundation": "^5.2|^6.0|^7.0|^8.0",
+ "symfony/mime": "^5.2|^6.0|^7.0|^8.0",
+ "symfony/process": "^5.2|^6.0|^7.0|^8.0",
+ "symfony/var-dumper": "^5.2|^6.0|^7.0|^8.0"
},
"require-dev": {
"dms/phpunit-arraysubset-asserts": "^0.5.0",
@@ -13330,7 +12577,7 @@
],
"support": {
"issues": "https://github.com/spatie/flare-client-php/issues",
- "source": "https://github.com/spatie/flare-client-php/tree/1.10.1"
+ "source": "https://github.com/spatie/flare-client-php/tree/1.11.1"
},
"funding": [
{
@@ -13338,41 +12585,44 @@
"type": "github"
}
],
- "time": "2025-02-14T13:42:06+00:00"
+ "time": "2026-05-15T09:31:32+00:00"
},
{
"name": "spatie/ignition",
- "version": "1.15.1",
+ "version": "1.16.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/ignition.git",
- "reference": "31f314153020aee5af3537e507fef892ffbf8c85"
+ "reference": "b59385bb7aa24dae81bcc15850ebecfda7b40838"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/ignition/zipball/31f314153020aee5af3537e507fef892ffbf8c85",
- "reference": "31f314153020aee5af3537e507fef892ffbf8c85",
+ "url": "https://api.github.com/repos/spatie/ignition/zipball/b59385bb7aa24dae81bcc15850ebecfda7b40838",
+ "reference": "b59385bb7aa24dae81bcc15850ebecfda7b40838",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-mbstring": "*",
"php": "^8.0",
- "spatie/error-solutions": "^1.0",
- "spatie/flare-client-php": "^1.7",
- "symfony/console": "^5.4|^6.0|^7.0",
- "symfony/var-dumper": "^5.4|^6.0|^7.0"
+ "spatie/backtrace": "^1.7.1",
+ "spatie/error-solutions": "^1.1.2",
+ "spatie/flare-client-php": "^1.9",
+ "symfony/console": "^5.4.42|^6.0|^7.0|^8.0",
+ "symfony/http-foundation": "^5.4.42|^6.0|^7.0|^8.0",
+ "symfony/mime": "^5.4.42|^6.0|^7.0|^8.0",
+ "symfony/var-dumper": "^5.4.42|^6.0|^7.0|^8.0"
},
"require-dev": {
- "illuminate/cache": "^9.52|^10.0|^11.0|^12.0",
+ "illuminate/cache": "^9.52|^10.0|^11.0|^12.0|^13.0",
"mockery/mockery": "^1.4",
- "pestphp/pest": "^1.20|^2.0",
+ "pestphp/pest": "^1.20|^2.0|^3.0",
"phpstan/extension-installer": "^1.1",
"phpstan/phpstan-deprecation-rules": "^1.0",
"phpstan/phpstan-phpunit": "^1.0",
"psr/simple-cache-implementation": "*",
- "symfony/cache": "^5.4|^6.0|^7.0",
- "symfony/process": "^5.4|^6.0|^7.0",
+ "symfony/cache": "^5.4.38|^6.0|^7.0|^8.0",
+ "symfony/process": "^5.4.35|^6.0|^7.0|^8.0",
"vlucas/phpdotenv": "^5.5"
},
"suggest": {
@@ -13421,42 +12671,43 @@
"type": "github"
}
],
- "time": "2025-02-21T14:31:39+00:00"
+ "time": "2026-03-17T10:51:08+00:00"
},
{
"name": "spatie/laravel-ignition",
- "version": "2.9.1",
+ "version": "2.12.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-ignition.git",
- "reference": "1baee07216d6748ebd3a65ba97381b051838707a"
+ "reference": "45b3b6e1e73fc161cba2149972698644b99594ee"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/laravel-ignition/zipball/1baee07216d6748ebd3a65ba97381b051838707a",
- "reference": "1baee07216d6748ebd3a65ba97381b051838707a",
+ "url": "https://api.github.com/repos/spatie/laravel-ignition/zipball/45b3b6e1e73fc161cba2149972698644b99594ee",
+ "reference": "45b3b6e1e73fc161cba2149972698644b99594ee",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
- "illuminate/support": "^10.0|^11.0|^12.0",
- "php": "^8.1",
- "spatie/ignition": "^1.15",
- "symfony/console": "^6.2.3|^7.0",
- "symfony/var-dumper": "^6.2.3|^7.0"
+ "illuminate/support": "^11.0|^12.0|^13.0",
+ "nesbot/carbon": "^2.72|^3.0",
+ "php": "^8.2",
+ "spatie/ignition": "^1.16",
+ "symfony/console": "^7.4|^8.0",
+ "symfony/var-dumper": "^7.4|^8.0"
},
"require-dev": {
- "livewire/livewire": "^2.11|^3.3.5",
- "mockery/mockery": "^1.5.1",
- "openai-php/client": "^0.8.1|^0.10",
- "orchestra/testbench": "8.22.3|^9.0|^10.0",
- "pestphp/pest": "^2.34|^3.7",
- "phpstan/extension-installer": "^1.3.1",
- "phpstan/phpstan-deprecation-rules": "^1.1.1|^2.0",
- "phpstan/phpstan-phpunit": "^1.3.16|^2.0",
- "vlucas/phpdotenv": "^5.5"
+ "livewire/livewire": "^3.7.0|^4.0|dev-josh/v3-laravel-13-support",
+ "mockery/mockery": "^1.6.12",
+ "openai-php/client": "^0.10.3|^0.19",
+ "orchestra/testbench": "^v9.16.0|^10.6|^11.0",
+ "pestphp/pest": "^3.7|^4.0",
+ "phpstan/extension-installer": "^1.4.3",
+ "phpstan/phpstan-deprecation-rules": "^2.0.3",
+ "phpstan/phpstan-phpunit": "^2.0.8",
+ "vlucas/phpdotenv": "^5.6.2"
},
"suggest": {
"openai-php/client": "Require get solutions from OpenAI",
@@ -13512,7 +12763,7 @@
"type": "github"
}
],
- "time": "2025-02-20T13:13:55+00:00"
+ "time": "2026-03-17T12:20:04+00:00"
},
{
"name": "staabm/side-effects-detector",
@@ -13568,28 +12819,28 @@
},
{
"name": "symfony/yaml",
- "version": "v7.2.6",
+ "version": "v7.4.13",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
- "reference": "0feafffb843860624ddfd13478f481f4c3cd8b23"
+ "reference": "a7ec3b1156faf8815db7683ec7c1e7338e6f977c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/yaml/zipball/0feafffb843860624ddfd13478f481f4c3cd8b23",
- "reference": "0feafffb843860624ddfd13478f481f4c3cd8b23",
+ "url": "https://api.github.com/repos/symfony/yaml/zipball/a7ec3b1156faf8815db7683ec7c1e7338e6f977c",
+ "reference": "a7ec3b1156faf8815db7683ec7c1e7338e6f977c",
"shasum": ""
},
"require": {
"php": ">=8.2",
- "symfony/deprecation-contracts": "^2.5|^3.0",
+ "symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
"symfony/console": "<6.4"
},
"require-dev": {
- "symfony/console": "^6.4|^7.0"
+ "symfony/console": "^6.4|^7.0|^8.0"
},
"bin": [
"Resources/bin/yaml-lint"
@@ -13620,7 +12871,7 @@
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/yaml/tree/v7.2.6"
+ "source": "https://github.com/symfony/yaml/tree/v7.4.13"
},
"funding": [
{
@@ -13631,32 +12882,36 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-04-04T10:10:11+00:00"
+ "time": "2026-05-25T06:06:12+00:00"
},
{
"name": "theseer/tokenizer",
- "version": "1.2.3",
+ "version": "2.0.1",
"source": {
"type": "git",
"url": "https://github.com/theseer/tokenizer.git",
- "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2"
+ "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2",
- "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2",
+ "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
+ "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-tokenizer": "*",
"ext-xmlwriter": "*",
- "php": "^7.2 || ^8.0"
+ "php": "^8.1"
},
"type": "library",
"autoload": {
@@ -13678,7 +12933,7 @@
"description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
"support": {
"issues": "https://github.com/theseer/tokenizer/issues",
- "source": "https://github.com/theseer/tokenizer/tree/1.2.3"
+ "source": "https://github.com/theseer/tokenizer/tree/2.0.1"
},
"funding": [
{
@@ -13686,21 +12941,25 @@
"type": "github"
}
],
- "time": "2024-03-03T12:36:25+00:00"
+ "time": "2025-12-08T11:19:18+00:00"
}
],
"aliases": [],
- "minimum-stability": "stable",
+ "minimum-stability": "dev",
"stability-flags": {
- "druc/laravel-langscanner": 20
+ "druc/laravel-langscanner": 20,
+ "maatwebsite/excel": 20
},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
- "php": "^8.2",
+ "php": "^8.3",
"ext-intl": "*",
"ext-xmlwriter": "*"
},
"platform-dev": {},
- "plugin-api-version": "2.6.0"
+ "platform-overrides": {
+ "php": "8.3.0"
+ },
+ "plugin-api-version": "2.9.0"
}
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/config/database.php b/backend/config/database.php
index a3d58d1988..c281b946f1 100644
--- a/backend/config/database.php
+++ b/backend/config/database.php
@@ -61,7 +61,7 @@
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
- PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
+ (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
diff --git a/backend/config/sanctum.php b/backend/config/sanctum.php
index 00096f88dc..e7f97fc45a 100644
--- a/backend/config/sanctum.php
+++ b/backend/config/sanctum.php
@@ -1,5 +1,7 @@
[
- 'verify_csrf_token' => \HiEvents\Http\Middleware\VerifyCsrfToken::class,
- 'encrypt_cookies' => \HiEvents\Http\Middleware\EncryptCookies::class,
+ 'verify_csrf_token' => PreventRequestForgery::class,
+ 'encrypt_cookies' => EncryptCookies::class,
],
];
diff --git a/backend/config/services.php b/backend/config/services.php
index 44f123a1e7..c6b82eedba 100644
--- a/backend/config/services.php
+++ b/backend/config/services.php
@@ -52,4 +52,10 @@
'open_exchange_rates' => [
'app_id' => env('OPEN_EXCHANGE_RATES_APP_ID'),
],
+ 'geo' => [
+ 'provider' => env('GEO_PROVIDER', 'google'),
+ 'google' => [
+ 'api_key' => env('GOOGLE_MAPS_API_KEY'),
+ ],
+ ],
];
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/database/migrations/2026_05_18_000001_create_locations_table.php b/backend/database/migrations/2026_05_18_000001_create_locations_table.php
new file mode 100644
index 0000000000..d317e04323
--- /dev/null
+++ b/backend/database/migrations/2026_05_18_000001_create_locations_table.php
@@ -0,0 +1,40 @@
+id();
+ $table->string('short_id', 32)->unique();
+ $table->foreignId('account_id')->constrained('accounts')->cascadeOnDelete();
+ $table->foreignId('organizer_id')->constrained('organizers')->cascadeOnDelete();
+ $table->string('name', 255)->nullable();
+ $table->jsonb('structured_address')->nullable();
+ $table->decimal('latitude', 10, 7)->nullable();
+ $table->decimal('longitude', 10, 7)->nullable();
+ $table->string('provider', 32)->nullable();
+ $table->string('provider_place_id', 255)->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+
+ $table->index('account_id');
+ $table->index('organizer_id');
+ });
+
+ DB::statement('
+ CREATE UNIQUE INDEX locations_provider_place_unique
+ ON locations (organizer_id, provider, provider_place_id)
+ WHERE provider_place_id IS NOT NULL AND deleted_at IS NULL
+ ');
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('locations');
+ }
+};
diff --git a/backend/database/migrations/2026_05_19_000001_add_location_id_to_organizers_and_backfill.php b/backend/database/migrations/2026_05_19_000001_add_location_id_to_organizers_and_backfill.php
new file mode 100644
index 0000000000..cbf713b95c
--- /dev/null
+++ b/backend/database/migrations/2026_05_19_000001_add_location_id_to_organizers_and_backfill.php
@@ -0,0 +1,89 @@
+foreignId('location_id')->nullable()
+ ->after('organizer_configuration_id')
+ ->constrained('locations')
+ ->nullOnDelete();
+ $table->index('location_id');
+ });
+
+ DB::table('organizer_settings')
+ ->whereNotNull('location_details')
+ ->orderBy('id')
+ ->chunkById(200, function ($settings) {
+ foreach ($settings as $row) {
+ $address = $this->normaliseAddress($row->location_details);
+ if ($address === null) {
+ continue;
+ }
+
+ $organizer = DB::table('organizers')->find($row->organizer_id);
+ if (! $organizer || $organizer->location_id !== null) {
+ continue;
+ }
+
+ $locationId = DB::table('locations')->insertGetId([
+ 'short_id' => IdHelper::shortId(IdHelper::LOCATION_PREFIX),
+ 'account_id' => $organizer->account_id,
+ 'organizer_id' => $organizer->id,
+ 'name' => $address['venue_name'] ?? null,
+ 'structured_address' => json_encode($address, JSON_UNESCAPED_UNICODE),
+ 'latitude' => null,
+ 'longitude' => null,
+ 'provider' => null,
+ 'provider_place_id' => null,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ DB::table('organizers')
+ ->where('id', $organizer->id)
+ ->update(['location_id' => $locationId]);
+ }
+ });
+
+ // Legacy organizer_settings.location_details is intentionally retained
+ // here. Dropping it is deferred to a follow-up migration once we've
+ // confirmed the new locations table is the only read path.
+ }
+
+ public function down(): void
+ {
+ Schema::table('organizers', function (Blueprint $table) {
+ $table->dropForeign(['location_id']);
+ $table->dropIndex(['location_id']);
+ $table->dropColumn('location_id');
+ });
+ }
+
+ private function normaliseAddress(mixed $raw): ?array
+ {
+ if ($raw === null) {
+ return null;
+ }
+
+ $decoded = is_string($raw) ? json_decode($raw, true) : $raw;
+ if (! is_array($decoded) || $decoded === []) {
+ return null;
+ }
+
+ foreach (['venue_name', 'address_line_1', 'city', 'state_or_region', 'zip_or_postal_code', 'country'] as $key) {
+ if (! empty($decoded[$key])) {
+ return $decoded;
+ }
+ }
+
+ return null;
+ }
+};
diff --git a/backend/database/migrations/2026_05_22_000001_create_event_locations_table.php b/backend/database/migrations/2026_05_22_000001_create_event_locations_table.php
new file mode 100644
index 0000000000..5f2900548a
--- /dev/null
+++ b/backend/database/migrations/2026_05_22_000001_create_event_locations_table.php
@@ -0,0 +1,28 @@
+id();
+ $table->string('short_id', 20)->unique();
+ $table->foreignId('event_id')->constrained('events')->cascadeOnDelete();
+ $table->string('type', 20)->default('IN_PERSON');
+ $table->foreignId('location_id')->nullable()->constrained('locations')->nullOnDelete();
+ $table->text('online_event_connection_details')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ $table->index('event_id');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('event_locations');
+ }
+};
diff --git a/backend/database/migrations/2026_05_22_000002_link_events_and_occurrences_to_event_locations.php b/backend/database/migrations/2026_05_22_000002_link_events_and_occurrences_to_event_locations.php
new file mode 100644
index 0000000000..36b536d475
--- /dev/null
+++ b/backend/database/migrations/2026_05_22_000002_link_events_and_occurrences_to_event_locations.php
@@ -0,0 +1,243 @@
+foreignId('event_location_id')->nullable()->after('organizer_id')->constrained('event_locations')->nullOnDelete();
+ $table->index('event_location_id');
+ });
+
+ Schema::table('event_occurrences', function (Blueprint $table) {
+ $table->foreignId('event_location_id')->nullable()->after('event_id')->constrained('event_locations')->nullOnDelete();
+ $table->index('event_location_id');
+ });
+
+ $this->backfill();
+ }
+
+ public function down(): void
+ {
+ Schema::table('event_occurrences', function (Blueprint $table) {
+ $table->dropForeign(['event_location_id']);
+ $table->dropIndex(['event_location_id']);
+ $table->dropColumn('event_location_id');
+ });
+
+ Schema::table('events', function (Blueprint $table) {
+ $table->dropForeign(['event_location_id']);
+ $table->dropIndex(['event_location_id']);
+ $table->dropColumn('event_location_id');
+ });
+ }
+
+ /**
+ * Public so feature tests can invoke the backfill against pretend
+ * pre-migration data without rerunning the schema changes.
+ */
+ public function backfill(): int
+ {
+ $backfilled = 0;
+
+ DB::table('events')
+ ->whereNull('event_location_id')
+ ->orderBy('id')
+ ->chunkById(200, function ($events) use (&$backfilled) {
+ foreach ($events as $event) {
+ if ($this->backfillEvent($event)) {
+ $backfilled++;
+ }
+ }
+ });
+
+ return $backfilled;
+ }
+
+ private function backfillEvent(stdClass $event): bool
+ {
+ $settings = DB::table('event_settings')
+ ->where('event_id', $event->id)
+ ->first();
+
+ if ($this->isOnlineEvent($settings)) {
+ // Re-purify on the way through: the legacy column was sanitised
+ // when written, but online_event_connection_details is rendered
+ // post-checkout with dangerouslySetInnerHTML, so we don't want
+ // to trust historical data that may predate today's allowlist.
+ $eventLocationId = $this->createOnlineEventLocation(
+ eventId: (int) $event->id,
+ onlineDetails: $this->purify($settings->online_event_connection_details ?? null),
+ );
+ $this->linkEvent((int) $event->id, $eventLocationId);
+
+ return true;
+ }
+
+ $address = $this->extractAddress($settings, $event);
+ if ($address === null) {
+ return false;
+ }
+
+ $locationId = $this->createLocation(
+ accountId: (int) $event->account_id,
+ organizerId: (int) $event->organizer_id,
+ address: $address,
+ );
+ $eventLocationId = $this->createInPersonEventLocation(
+ eventId: (int) $event->id,
+ locationId: $locationId,
+ );
+ $this->linkEvent((int) $event->id, $eventLocationId);
+
+ return true;
+ }
+
+ private function isOnlineEvent(?stdClass $settings): bool
+ {
+ return $settings !== null && (bool) ($settings->is_online_event ?? false);
+ }
+
+ private function extractAddress(?stdClass $settings, stdClass $event): ?array
+ {
+ $candidates = [
+ $settings->location_details ?? null,
+ $event->location_details ?? null,
+ ];
+
+ foreach ($candidates as $raw) {
+ $address = $this->normaliseAddress($raw);
+ if ($address !== null) {
+ return $address;
+ }
+ }
+
+ return null;
+ }
+
+ private function normaliseAddress(mixed $raw): ?array
+ {
+ if ($raw === null) {
+ return null;
+ }
+
+ $decoded = is_string($raw) ? json_decode($raw, true) : $raw;
+ if (! is_array($decoded) || $decoded === []) {
+ return null;
+ }
+
+ foreach (['venue_name', 'address_line_1', 'city', 'state_or_region', 'zip_or_postal_code', 'country'] as $key) {
+ if (! empty($decoded[$key])) {
+ return $decoded;
+ }
+ }
+
+ return null;
+ }
+
+ private function createLocation(int $accountId, int $organizerId, array $address): int
+ {
+ $now = now();
+
+ return DB::table('locations')->insertGetId([
+ 'short_id' => $this->shortId(self::LOCATION_SHORT_ID_PREFIX),
+ 'account_id' => $accountId,
+ 'organizer_id' => $organizerId,
+ 'name' => $address['venue_name'] ?? null,
+ 'structured_address' => json_encode($address, JSON_UNESCAPED_UNICODE),
+ 'latitude' => null,
+ 'longitude' => null,
+ 'provider' => null,
+ 'provider_place_id' => null,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+
+ private function createInPersonEventLocation(int $eventId, int $locationId): int
+ {
+ $now = now();
+
+ return DB::table('event_locations')->insertGetId([
+ 'short_id' => $this->shortId(self::EVENT_LOCATION_SHORT_ID_PREFIX),
+ 'event_id' => $eventId,
+ 'type' => self::TYPE_IN_PERSON,
+ 'location_id' => $locationId,
+ 'online_event_connection_details' => null,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+
+ private function createOnlineEventLocation(int $eventId, ?string $onlineDetails): int
+ {
+ $now = now();
+
+ return DB::table('event_locations')->insertGetId([
+ 'short_id' => $this->shortId(self::EVENT_LOCATION_SHORT_ID_PREFIX),
+ 'event_id' => $eventId,
+ 'type' => self::TYPE_ONLINE,
+ 'location_id' => null,
+ 'online_event_connection_details' => $onlineDetails,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+
+ private function linkEvent(int $eventId, int $eventLocationId): void
+ {
+ DB::table('events')
+ ->where('id', $eventId)
+ ->update(['event_location_id' => $eventLocationId]);
+ }
+
+ private function shortId(string $prefix): string
+ {
+ return sprintf('%s_%s', $prefix, Str::random(self::SHORT_ID_RANDOM_LENGTH));
+ }
+
+ private function purify(?string $html): ?string
+ {
+ if ($html === null) {
+ return null;
+ }
+
+ return $this->purifier()->purify($html);
+ }
+
+ private function purifier(): \HTMLPurifier
+ {
+ return $this->purifier ??= new \HTMLPurifier(\HTMLPurifier_Config::createDefault());
+ }
+};
diff --git a/backend/database/migrations/2026_05_22_000003_add_raw_provider_response_to_locations.php b/backend/database/migrations/2026_05_22_000003_add_raw_provider_response_to_locations.php
new file mode 100644
index 0000000000..511d8552c7
--- /dev/null
+++ b/backend/database/migrations/2026_05_22_000003_add_raw_provider_response_to_locations.php
@@ -0,0 +1,22 @@
+jsonb('raw_provider_response')->nullable()->after('provider_place_id');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('locations', function (Blueprint $table) {
+ $table->dropColumn('raw_provider_response');
+ });
+ }
+};
diff --git a/backend/database/migrations/2026_05_27_000001_drop_hide_getting_started_page_from_event_settings.php b/backend/database/migrations/2026_05_27_000001_drop_hide_getting_started_page_from_event_settings.php
new file mode 100644
index 0000000000..bbceccbcb9
--- /dev/null
+++ b/backend/database/migrations/2026_05_27_000001_drop_hide_getting_started_page_from_event_settings.php
@@ -0,0 +1,22 @@
+dropColumn('hide_getting_started_page');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('event_settings', function (Blueprint $table) {
+ $table->boolean('hide_getting_started_page')->default(false);
+ });
+ }
+};
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..df55702afa 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 = $effectiveVenueName ?? null;
+ $addressString = $effectiveAddressString ?? null;
+ $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..663bf973f9 100644
--- a/backend/routes/api.php
+++ b/backend/routes/api.php
@@ -2,11 +2,35 @@
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\Accounts\UpdateAccountAction;
-use HiEvents\Http\Actions\Accounts\Vat\GetAccountVatSettingAction;
-use HiEvents\Http\Actions\Accounts\Vat\UpsertAccountVatSettingAction;
+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\Organizers\AssignOrganizerConfigurationAction;
+use HiEvents\Http\Actions\Admin\Organizers\UpdateOrganizerConfigurationAction;
+use HiEvents\Http\Actions\Admin\Organizers\UpdateOrganizerVatSettingAction;
+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 +65,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,27 +112,20 @@
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;
+use HiEvents\Http\Actions\Locations\CreateLocationAction;
+use HiEvents\Http\Actions\Locations\DeleteLocationAction;
+use HiEvents\Http\Actions\Locations\GeoAutocompleteAction;
+use HiEvents\Http\Actions\Locations\GeoPlaceDetailsAction;
+use HiEvents\Http\Actions\Locations\GetGeoStatusAction;
+use HiEvents\Http\Actions\Locations\GetLocationsAction;
+use HiEvents\Http\Actions\Locations\UpdateLocationAction;
use HiEvents\Http\Actions\Messages\CancelMessageAction;
use HiEvents\Http\Actions\Messages\GetMessageRecipientsAction;
use HiEvents\Http\Actions\Messages\GetMessagesAction;
@@ -102,12 +149,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,9 +161,13 @@
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\Stripe\CopyStripeConnectAccountAction;
+use HiEvents\Http\Actions\Organizers\Stripe\CreateStripeConnectAccountAction;
+use HiEvents\Http\Actions\Organizers\Stripe\GetStripeConnectAccountsAction;
+use HiEvents\Http\Actions\Organizers\UpdateOrganizerLocationAction;
use HiEvents\Http\Actions\Organizers\UpdateOrganizerStatusAction;
+use HiEvents\Http\Actions\Organizers\Vat\GetOrganizerVatSettingAction;
+use HiEvents\Http\Actions\Organizers\Vat\UpsertOrganizerVatSettingAction;
use HiEvents\Http\Actions\Organizers\Webhooks\CreateOrganizerWebhookAction;
use HiEvents\Http\Actions\Organizers\Webhooks\DeleteOrganizerWebhookAction;
use HiEvents\Http\Actions\Organizers\Webhooks\EditOrganizerWebhookAction;
@@ -154,6 +203,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 +214,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 +229,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 +291,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);
@@ -286,6 +306,7 @@ function (Router $router): void {
$router->get('/organizers/{organizer_id}/orders', GetOrganizerOrdersAction::class);
$router->get('/organizers/{organizer_id}/settings', GetOrganizerSettingsAction::class);
$router->patch('/organizers/{organizer_id}/settings', PartialUpdateOrganizerSettingsAction::class);
+ $router->patch('/organizers/{organizer_id}/location', UpdateOrganizerLocationAction::class);
$router->get('/organizers/{organizer_id}/reports/{report_type}', GetOrganizerReportAction::class);
$router->get('/organizers/{organizer_id}/reports/{report_type}/export', ExportOrganizerReportAction::class);
$router->post('/organizers/{organizer_id}/webhooks', CreateOrganizerWebhookAction::class);
@@ -295,6 +316,27 @@ 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);
+ // Locations - Organizer level
+ $router->get('/organizers/{organizer_id}/locations', GetLocationsAction::class);
+ $router->post('/organizers/{organizer_id}/locations', CreateLocationAction::class);
+ $router->get('/geo/status', GetGeoStatusAction::class);
+ $router->get('/organizers/{organizer_id}/locations/autocomplete', GeoAutocompleteAction::class)
+ ->middleware('throttle:60,1');
+ $router->get('/organizers/{organizer_id}/locations/places/{place_id}', GeoPlaceDetailsAction::class)
+ ->where('place_id', '[A-Za-z0-9_\-]+')
+ ->middleware('throttle:60,1');
+ $router->put('/organizers/{organizer_id}/locations/{location_id}', UpdateLocationAction::class);
+ $router->delete('/organizers/{organizer_id}/locations/{location_id}', DeleteLocationAction::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);
@@ -315,6 +357,7 @@ function (Router $router): void {
$router->get('/events', GetEventsAction::class);
$router->get('/events/{event_id}', GetEventAction::class);
$router->put('/events/{event_id}', UpdateEventAction::class);
+ $router->patch('/events/{event_id}/event-location', \HiEvents\Http\Actions\Events\UpdateEventLocationAction::class);
$router->put('/events/{event_id}/status', UpdateEventStatusAction::class);
$router->delete('/events/{event_id}', DeleteEventAction::class);
$router->get('/events/{event_id}/deletion-status', GetEventDeletionStatusAction::class);
@@ -441,6 +484,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 +513,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 +595,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);
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/Auth/ResetPasswordTest.php b/backend/tests/Feature/Auth/ResetPasswordTest.php
index 643aba4c7e..d957619fd4 100644
--- a/backend/tests/Feature/Auth/ResetPasswordTest.php
+++ b/backend/tests/Feature/Auth/ResetPasswordTest.php
@@ -77,7 +77,6 @@ public function test_reset_password_with_valid_token(): void
// extract the token from the email
$reflection = new ReflectionClass($email);
$tokenProperty = $reflection->getProperty('token');
- $tokenProperty->setAccessible(true);
$token = $tokenProperty->getValue($email);
$response2 = $this->getJson(self::RESET_PASSWORD_ROUTE . '/' . urlencode($token));
@@ -138,7 +137,6 @@ public function test_reset_password_with_old_password(): void
// extract the token from the email
$reflection = new ReflectionClass($email);
$tokenProperty = $reflection->getProperty('token');
- $tokenProperty->setAccessible(true);
$token = $tokenProperty->getValue($email);
$response2 = $this->postJson(self::RESET_PASSWORD_ROUTE . '/' . urlencode($token), [
diff --git a/backend/tests/Feature/Database/Migrations/LinkEventsToEventLocationsBackfillTest.php b/backend/tests/Feature/Database/Migrations/LinkEventsToEventLocationsBackfillTest.php
new file mode 100644
index 0000000000..77d1961173
--- /dev/null
+++ b/backend/tests/Feature/Database/Migrations/LinkEventsToEventLocationsBackfillTest.php
@@ -0,0 +1,223 @@
+withAccount()->create();
+ $this->userId = $user->id;
+ $this->accountId = $user->accounts()->first()->id;
+ $this->organizerId = $this->insertOrganizer();
+ }
+
+ public function test_creates_in_person_event_location_from_event_settings_address(): void
+ {
+ $eventId = $this->insertEvent();
+ $this->insertEventSettings($eventId, locationDetails: [
+ 'venue_name' => 'Settings Hall',
+ 'address_line_1' => '1 Settings Way',
+ 'city' => 'Dublin',
+ 'country' => 'IE',
+ ]);
+
+ $count = $this->migration()->backfill();
+
+ $this->assertSame(1, $count);
+
+ $event = DB::table('events')->where('id', $eventId)->first();
+ $this->assertNotNull($event->event_location_id);
+
+ $eventLocation = DB::table('event_locations')->where('id', $event->event_location_id)->first();
+ $this->assertSame('IN_PERSON', $eventLocation->type);
+ $this->assertNotNull($eventLocation->location_id);
+ $this->assertNull($eventLocation->online_event_connection_details);
+
+ $location = DB::table('locations')->where('id', $eventLocation->location_id)->first();
+ $this->assertSame($this->organizerId, (int) $location->organizer_id);
+ $this->assertSame($this->accountId, (int) $location->account_id);
+ $this->assertSame('Settings Hall', $location->name);
+ $this->assertEquals([
+ 'venue_name' => 'Settings Hall',
+ 'address_line_1' => '1 Settings Way',
+ 'city' => 'Dublin',
+ 'country' => 'IE',
+ ], json_decode($location->structured_address, true));
+ }
+
+ public function test_falls_back_to_events_location_details_when_event_settings_empty(): void
+ {
+ $eventId = $this->insertEvent(locationDetails: [
+ 'venue_name' => 'Event Hall',
+ 'city' => 'Cork',
+ ]);
+ $this->insertEventSettings($eventId, locationDetails: null);
+
+ $this->migration()->backfill();
+
+ $event = DB::table('events')->where('id', $eventId)->first();
+ $eventLocation = DB::table('event_locations')->where('id', $event->event_location_id)->first();
+ $location = DB::table('locations')->where('id', $eventLocation->location_id)->first();
+
+ $this->assertSame('Event Hall', $location->name);
+ }
+
+ public function test_creates_online_event_location_when_is_online_event_true(): void
+ {
+ $eventId = $this->insertEvent();
+ $this->insertEventSettings(
+ $eventId,
+ isOnlineEvent: true,
+ onlineDetails: 'Zoom: https://example.com/abc
',
+ );
+
+ $this->migration()->backfill();
+
+ $event = DB::table('events')->where('id', $eventId)->first();
+ $eventLocation = DB::table('event_locations')->where('id', $event->event_location_id)->first();
+
+ $this->assertSame('ONLINE', $eventLocation->type);
+ $this->assertNull($eventLocation->location_id);
+ $this->assertSame('Zoom: https://example.com/abc
', $eventLocation->online_event_connection_details);
+ }
+
+ public function test_online_event_legacy_html_is_re_purified(): void
+ {
+ $eventId = $this->insertEvent();
+ $this->insertEventSettings(
+ $eventId,
+ isOnlineEvent: true,
+ onlineDetails: 'Zoom: https://example.com/abc
',
+ );
+
+ $this->migration()->backfill();
+
+ $event = DB::table('events')->where('id', $eventId)->first();
+ $eventLocation = DB::table('event_locations')->where('id', $event->event_location_id)->first();
+
+ $this->assertStringNotContainsString('Zoom
',
+ );
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getOrganizerId')->andReturn(3);
+
+ $this->eventRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['id' => 1, 'account_id' => 7])
+ ->andReturn($event);
+
+ $this->ownershipValidator
+ ->shouldReceive('assertOwnedBy')
+ ->once()
+ ->with(null, 3, 7);
+
+ $this->purifier
+ ->shouldReceive('purify')
+ ->once()
+ ->with('Zoom
')
+ ->andReturn('Zoom
');
+
+ $created = Mockery::mock(EventLocationDomainObject::class);
+
+ $this->eventLocationRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with(Mockery::on(function (array $attrs) {
+ return $attrs[EventLocationDomainObjectAbstract::TYPE] === LocationType::ONLINE->name
+ && $attrs[EventLocationDomainObjectAbstract::LOCATION_ID] === null
+ && $attrs[EventLocationDomainObjectAbstract::ONLINE_EVENT_CONNECTION_DETAILS] === 'Zoom
';
+ }))
+ ->andReturn($created);
+
+ $result = $this->upserter->createForEvent(1, 7, $data);
+
+ $this->assertSame($created, $result);
+ }
+
+ public function test_create_for_event_throws_when_event_missing(): void
+ {
+ // Ownership validation needs the event to derive the organizer scope;
+ // if the event lookup misses, surface ResourceNotFoundException.
+ $data = new EventLocationData(
+ type: LocationType::IN_PERSON,
+ location_id: 42,
+ );
+
+ $this->eventRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['id' => 999, 'account_id' => 7])
+ ->andReturn(null);
+
+ $this->ownershipValidator->shouldNotReceive('assertOwnedBy');
+ $this->eventLocationRepository->shouldNotReceive('create');
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->upserter->createForEvent(999, 7, $data);
+ }
+
+ public function test_create_for_event_throws_when_location_not_owned_by_organizer(): void
+ {
+ // Ownership validator bubbles up ResourceNotFoundException for
+ // foreign-organizer or foreign-account locations.
+ $data = new EventLocationData(
+ type: LocationType::IN_PERSON,
+ location_id: 999,
+ );
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getOrganizerId')->andReturn(3);
+
+ $this->eventRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn($event);
+
+ $this->ownershipValidator
+ ->shouldReceive('assertOwnedBy')
+ ->once()
+ ->with(999, 3, 7)
+ ->andThrow(new ResourceNotFoundException(__('Location :id not found', ['id' => 999])));
+
+ $this->eventLocationRepository->shouldNotReceive('create');
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->upserter->createForEvent(1, 7, $data);
+ }
+
+ public function test_update_in_place_updates_existing_row(): void
+ {
+ $data = new EventLocationData(
+ type: LocationType::IN_PERSON,
+ location_id: 42,
+ );
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getOrganizerId')->andReturn(3);
+
+ $this->eventRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['id' => 1, 'account_id' => 7])
+ ->andReturn($event);
+
+ $this->ownershipValidator
+ ->shouldReceive('assertOwnedBy')
+ ->once()
+ ->with(42, 3, 7);
+
+ $this->eventLocationRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventLocationDomainObjectAbstract::ID => 5,
+ EventLocationDomainObjectAbstract::EVENT_ID => 1,
+ ])
+ ->andReturn(Mockery::mock(EventLocationDomainObject::class));
+
+ $updated = Mockery::mock(EventLocationDomainObject::class);
+
+ $this->eventLocationRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(
+ 5,
+ Mockery::on(function (array $attrs) {
+ return $attrs[EventLocationDomainObjectAbstract::TYPE] === LocationType::IN_PERSON->name
+ && $attrs[EventLocationDomainObjectAbstract::LOCATION_ID] === 42
+ && $attrs[EventLocationDomainObjectAbstract::ONLINE_EVENT_CONNECTION_DETAILS] === null
+ && ! array_key_exists(EventLocationDomainObjectAbstract::SHORT_ID, $attrs);
+ }),
+ )
+ ->andReturn($updated);
+
+ $result = $this->upserter->updateInPlace(5, 1, 7, $data);
+
+ $this->assertSame($updated, $result);
+ }
+
+ public function test_update_in_place_throws_when_event_location_id_belongs_to_another_event(): void
+ {
+ $data = new EventLocationData(type: LocationType::IN_PERSON, location_id: null);
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getOrganizerId')->andReturn(3);
+
+ $this->eventRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn($event);
+
+ $this->ownershipValidator->shouldReceive('assertOwnedBy');
+
+ $this->eventLocationRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventLocationDomainObjectAbstract::ID => 99,
+ EventLocationDomainObjectAbstract::EVENT_ID => 1,
+ ])
+ ->andReturn(null);
+
+ $this->eventLocationRepository->shouldNotReceive('updateFromArray');
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->upserter->updateInPlace(99, 1, 7, $data);
+ }
+}
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..5ee56caa5d 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,11 @@ 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()
->with(Mockery::type(Relationship::class))
->andReturnSelf();
@@ -91,7 +99,6 @@ public function testResendAttendeeTicketSuccessfully(): void
$this->eventRepository
->shouldReceive('loadRelation')
- ->twice()
->andReturnSelf();
$this->eventRepository
@@ -134,7 +141,7 @@ public function testResendAttendeeTicketSuccessfully(): void
$this->assertTrue(true);
}
- public function testResendOrderConfirmationSuccessfully(): void
+ public function test_resend_order_confirmation_successfully(): void
{
$orderId = 123;
$eventId = 1;
@@ -157,7 +164,6 @@ public function testResendOrderConfirmationSuccessfully(): void
$this->orderRepository
->shouldReceive('loadRelation')
- ->times(3)
->andReturnSelf();
$this->orderRepository
@@ -169,9 +175,11 @@ 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()
->andReturnSelf();
$this->eventRepository
@@ -213,7 +221,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 +237,11 @@ 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';
- }))
+ ->with(Mockery::type(Relationship::class))
->andReturnSelf();
$this->attendeeRepository
@@ -246,17 +251,6 @@ public function testResendAttendeeTicketLoadsCorrectRelationships(): void
$this->eventRepository
->shouldReceive('loadRelation')
- ->once()
- ->with(Mockery::on(function ($relationship) {
- return $relationship instanceof Relationship
- && $relationship->getDomainObject() === OrganizerDomainObject::class;
- }))
- ->andReturnSelf();
-
- $this->eventRepository
- ->shouldReceive('loadRelation')
- ->once()
- ->with(EventSettingDomainObject::class)
->andReturnSelf();
$this->eventRepository
@@ -283,7 +277,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,18 +292,8 @@ 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,
- AttendeeDomainObject::class,
- InvoiceDomainObject::class,
- ]);
- }))
->andReturnSelf();
$this->orderRepository
@@ -319,7 +303,6 @@ public function testResendOrderConfirmationLoadsCorrectRelationships(): void
$this->eventRepository
->shouldReceive('loadRelation')
- ->twice()
->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/backend/tests/Unit/Services/Infrastructure/Geo/GooglePlacesGeoProviderTest.php b/backend/tests/Unit/Services/Infrastructure/Geo/GooglePlacesGeoProviderTest.php
new file mode 100644
index 0000000000..8c48f832f7
--- /dev/null
+++ b/backend/tests/Unit/Services/Infrastructure/Geo/GooglePlacesGeoProviderTest.php
@@ -0,0 +1,194 @@
+logger = new NullLogger;
+ $this->cache = new CacheRepository(new ArrayStore);
+ }
+
+ private function makeProvider(): GooglePlacesGeoProvider
+ {
+ return new GooglePlacesGeoProvider('test-key', app(HttpClient::class), $this->logger, $this->cache);
+ }
+
+ public function test_autocomplete_maps_response_to_suggestion_dtos(): void
+ {
+ Http::fake([
+ 'places.googleapis.com/v1/places:autocomplete' => Http::response([
+ 'suggestions' => [
+ [
+ 'placePrediction' => [
+ 'placeId' => 'ChIJ123',
+ 'structuredFormat' => [
+ 'mainText' => ['text' => 'Some Venue'],
+ 'secondaryText' => ['text' => 'Dublin, Ireland'],
+ ],
+ ],
+ ],
+ ],
+ ], 200),
+ ]);
+
+ $provider = $this->makeProvider();
+ $results = $provider->autocomplete('some venue', locale: 'en', country: 'IE');
+
+ $this->assertCount(1, $results);
+ $this->assertSame('ChIJ123', $results[0]->provider_place_id);
+ $this->assertSame('Some Venue', $results[0]->primary_text);
+ $this->assertSame('Dublin, Ireland', $results[0]->secondary_text);
+ }
+
+ public function test_autocomplete_returns_empty_on_blank_query(): void
+ {
+ $provider = $this->makeProvider();
+ $this->assertSame([], $provider->autocomplete(' '));
+ }
+
+ public function test_get_place_details_maps_establishment_to_address_dto(): void
+ {
+ Http::fake([
+ 'places.googleapis.com/v1/places/*' => Http::response([
+ 'id' => 'ChIJ123',
+ 'formattedAddress' => '3 Arena, North Wall Quay, Dublin 1, Ireland',
+ 'displayName' => ['text' => '3 Arena'],
+ 'types' => ['establishment', 'point_of_interest'],
+ 'location' => ['latitude' => 53.3478, 'longitude' => -6.2289],
+ 'addressComponents' => [
+ ['types' => ['street_number'], 'shortText' => '3', 'longText' => '3'],
+ ['types' => ['route'], 'shortText' => 'North Wall Quay', 'longText' => 'North Wall Quay'],
+ ['types' => ['locality'], 'shortText' => 'Dublin', 'longText' => 'Dublin'],
+ ['types' => ['administrative_area_level_1'], 'shortText' => 'Dublin 1', 'longText' => 'Dublin 1'],
+ ['types' => ['postal_code'], 'shortText' => 'D01 T0X4', 'longText' => 'D01 T0X4'],
+ ['types' => ['country'], 'shortText' => 'IE', 'longText' => 'Ireland'],
+ ],
+ ], 200),
+ ]);
+
+ $provider = $this->makeProvider();
+ $place = $provider->getPlaceDetails('ChIJ123');
+
+ $this->assertNotNull($place);
+ $this->assertSame('google', $place->provider);
+ $this->assertSame('ChIJ123', $place->provider_place_id);
+ $this->assertSame('3 Arena', $place->address->venue_name);
+ $this->assertSame('3 North Wall Quay', $place->address->address_line_1);
+ $this->assertSame('Dublin', $place->address->city);
+ $this->assertSame('Dublin 1', $place->address->state_or_region);
+ $this->assertSame('D01 T0X4', $place->address->zip_or_postal_code);
+ $this->assertSame('IE', $place->address->country);
+ $this->assertEqualsWithDelta(53.3478, $place->latitude, 0.0001);
+ $this->assertEqualsWithDelta(-6.2289, $place->longitude, 0.0001);
+ }
+
+ public function test_get_place_details_skips_venue_name_for_street_address(): void
+ {
+ Http::fake([
+ 'places.googleapis.com/v1/places/*' => Http::response([
+ 'id' => 'ChIJ456',
+ 'displayName' => ['text' => '123 Main St'],
+ 'types' => ['street_address'],
+ 'addressComponents' => [
+ ['types' => ['street_number'], 'shortText' => '123', 'longText' => '123'],
+ ['types' => ['route'], 'shortText' => 'Main St', 'longText' => 'Main Street'],
+ ['types' => ['locality'], 'shortText' => 'Springfield', 'longText' => 'Springfield'],
+ ['types' => ['country'], 'shortText' => 'US', 'longText' => 'United States'],
+ ],
+ ], 200),
+ ]);
+
+ $provider = $this->makeProvider();
+ $place = $provider->getPlaceDetails('ChIJ456');
+
+ $this->assertNotNull($place);
+ $this->assertNull($place->address->venue_name);
+ $this->assertSame('123 Main St', $place->address->address_line_1);
+ }
+
+ public function test_get_place_details_returns_null_on_404(): void
+ {
+ Http::fake([
+ 'places.googleapis.com/v1/places/*' => Http::response(['error' => 'not found'], 404),
+ ]);
+
+ $provider = $this->makeProvider();
+ $this->assertNull($provider->getPlaceDetails('ChIJ-bad'));
+ }
+
+ public function test_get_place_details_throws_on_5xx(): void
+ {
+ Http::fake([
+ 'places.googleapis.com/v1/places/*' => Http::response(['error' => 'boom'], 503),
+ ]);
+
+ $provider = $this->makeProvider();
+
+ $this->expectException(\HiEvents\Services\Infrastructure\Geo\Exception\GeoProviderException::class);
+ $provider->getPlaceDetails('ChIJ-503');
+ }
+
+ public function test_autocomplete_throws_on_5xx(): void
+ {
+ Http::fake([
+ 'places.googleapis.com/v1/places:autocomplete' => Http::response(['error' => 'boom'], 502),
+ ]);
+
+ $provider = $this->makeProvider();
+
+ $this->expectException(\HiEvents\Services\Infrastructure\Geo\Exception\GeoProviderException::class);
+ $provider->autocomplete('something');
+ }
+
+ public function test_autocomplete_throws_quota_exception_on_429(): void
+ {
+ Http::fake([
+ 'places.googleapis.com/v1/places:autocomplete' => Http::response(['error' => ['status' => 'RESOURCE_EXHAUSTED']], 429),
+ ]);
+
+ $provider = $this->makeProvider();
+
+ $this->expectException(\HiEvents\Services\Infrastructure\Geo\Exception\GeoProviderQuotaExceededException::class);
+ $provider->autocomplete('something');
+ }
+
+ public function test_get_place_details_caches_responses(): void
+ {
+ Http::fake([
+ 'places.googleapis.com/v1/places/*' => Http::response([
+ 'id' => 'ChIJ-cached',
+ 'displayName' => ['text' => 'Cached Place'],
+ 'types' => ['establishment'],
+ 'addressComponents' => [
+ ['types' => ['country'], 'shortText' => 'IE', 'longText' => 'Ireland'],
+ ],
+ ], 200),
+ ]);
+
+ $provider = $this->makeProvider();
+
+ $first = $provider->getPlaceDetails('ChIJ-cached');
+ $second = $provider->getPlaceDetails('ChIJ-cached');
+
+ $this->assertNotNull($first);
+ $this->assertNotNull($second);
+ // One upstream call, not two — the second resolves from cache.
+ Http::assertSentCount(1);
+ }
+}
diff --git a/backend/tests/Unit/Services/Infrastructure/Session/CheckoutSessionManagementServiceTest.php b/backend/tests/Unit/Services/Infrastructure/Session/CheckoutSessionManagementServiceTest.php
index 7071328155..a9db4d0572 100644
--- a/backend/tests/Unit/Services/Infrastructure/Session/CheckoutSessionManagementServiceTest.php
+++ b/backend/tests/Unit/Services/Infrastructure/Session/CheckoutSessionManagementServiceTest.php
@@ -5,19 +5,19 @@
use HiEvents\Services\Infrastructure\Session\CheckoutSessionManagementService;
use Illuminate\Config\Repository;
use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Config;
+use Mockery;
use Tests\TestCase;
class CheckoutSessionManagementServiceTest extends TestCase
{
public function testGetSessionIdWithExistingCookie(): void
{
- $request = $this->createMock(Request::class);
-
- $request->expects($this->once())
- ->method('cookie')
+ $request = Mockery::mock(Request::class);
+ $request->shouldReceive('query')->with('session_identifier')->andReturnNull();
+ $request->shouldReceive('cookie')
+ ->once()
->with('session_identifier')
- ->willReturn('existingSessionId');
+ ->andReturn('existingSessionId');
$configMock = $this->mock(Repository::class);
@@ -28,12 +28,12 @@ public function testGetSessionIdWithExistingCookie(): void
public function testVerifySession(): void
{
- $request = $this->createMock(Request::class);
-
- $request->expects($this->once())
- ->method('cookie')
+ $request = Mockery::mock(Request::class);
+ $request->shouldReceive('query')->with('session_identifier')->andReturnNull();
+ $request->shouldReceive('cookie')
+ ->once()
->with('session_identifier')
- ->willReturn('existingSessionId');
+ ->andReturn('existingSessionId');
$configMock = $this->mock(Repository::class);
@@ -44,12 +44,13 @@ public function testVerifySession(): void
public function testGetSessionCookie(): void
{
- $request = $this->createMock(Request::class);
-
- $request->expects($this->once())
- ->method('cookie')
+ $request = Mockery::mock(Request::class);
+ $request->shouldReceive('query')->with('session_identifier')->andReturnNull();
+ $request->shouldReceive('cookie')
+ ->once()
->with('session_identifier')
- ->willReturn('existingSessionId');
+ ->andReturn('existingSessionId');
+ $request->shouldReceive('getHost')->andReturn('example.com');
$configMock = $this->mock(Repository::class)
->shouldReceive('get')
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/package.json b/frontend/package.json
index 09e15f7f65..d4c33d32a4 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -23,19 +23,19 @@
"@dnd-kit/utilities": "^3.2.1",
"@lingui/macro": "^4.7.0",
"@lingui/react": "^4.7.0",
- "@mantine/carousel": "^8.0.1",
- "@mantine/charts": "^8.0.1",
- "@mantine/colors-generator": "^8.1.0",
- "@mantine/core": "^8.0.1",
- "@mantine/dates": "^8.0.1",
- "@mantine/dropzone": "^8.0.1",
- "@mantine/form": "^8.0.1",
- "@mantine/hooks": "^8.0.1",
- "@mantine/modals": "^8.0.1",
- "@mantine/notifications": "^8.0.1",
- "@mantine/nprogress": "^8.0.1",
- "@mantine/tiptap": "^8.0.1",
- "@react-pdf/renderer": "^3.3.4",
+ "@mantine/carousel": "^9.2.2",
+ "@mantine/charts": "^9.2.2",
+ "@mantine/colors-generator": "^9.2.2",
+ "@mantine/core": "^9.2.2",
+ "@mantine/dates": "^9.2.2",
+ "@mantine/dropzone": "^9.2.2",
+ "@mantine/form": "^9.2.2",
+ "@mantine/hooks": "^9.2.2",
+ "@mantine/modals": "^9.2.2",
+ "@mantine/notifications": "^9.2.2",
+ "@mantine/nprogress": "^9.2.2",
+ "@mantine/tiptap": "^9.2.2",
+ "@react-pdf/renderer": "^4.5.1",
"@react-router/node": "^7.1.5",
"@remix-run/node": "^2.16.8",
"@stripe/react-stripe-js": "^2.1.1",
@@ -43,18 +43,14 @@
"@tabler/icons-react": "^3.35.0",
"@tanstack/react-query": "5.76.1",
"@tanstack/react-table": "^8.21.3",
- "@tiptap/core": "^2.7.0",
- "@tiptap/extension-color": "^2.11.7",
- "@tiptap/extension-image": "^2.11.5",
- "@tiptap/extension-link": "^2.12.0",
- "@tiptap/extension-task-item": "^2.12.0",
- "@tiptap/extension-task-list": "^2.12.0",
- "@tiptap/extension-text-align": "^2.12.0",
- "@tiptap/extension-text-style": "^2.12.0",
- "@tiptap/extension-underline": "^2.12.0",
- "@tiptap/pm": "^2.7.0",
- "@tiptap/react": "^2.12.0",
- "@tiptap/starter-kit": "^2.12.0",
+ "@tiptap/core": "^3.3.0",
+ "@tiptap/extension-image": "^3.3.0",
+ "@tiptap/extension-link": "^3.3.0",
+ "@tiptap/extension-text-align": "^3.3.0",
+ "@tiptap/extension-text-style": "^3.3.0",
+ "@tiptap/pm": "^3.3.0",
+ "@tiptap/react": "^3.3.0",
+ "@tiptap/starter-kit": "^3.3.0",
"axios": "^1.4.0",
"chroma-js": "^3.1.2",
"classnames": "^2.3.2",
@@ -67,13 +63,13 @@
"express": "^4.19.2",
"qr-scanner": "^1.4.2",
"query-string": "^8.1.0",
- "react": "^18.2.0",
- "react-dom": "^18.2.0",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
"react-helmet-async": "^2.0.4",
"react-qr-code": "^2.0.12",
"react-router": "^7.1.5",
"react-router-dom": "^7.1.5",
- "recharts": "2",
+ "recharts": "^3",
"sirv": "^2.0.4"
},
"devDependencies": {
@@ -82,8 +78,8 @@
"@types/express": "^4.17.21",
"@types/lodash": "^4.17.0",
"@types/node": "^20.12.2",
- "@types/react": "^18.0.28",
- "@types/react-dom": "^18.0.11",
+ "@types/react": "^19.2.0",
+ "@types/react-dom": "^19.2.0",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
"@vitejs/plugin-react": "^4.4.1",
@@ -101,5 +97,8 @@
"vite": "^5.4.19",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-copy": "^0.1.6"
+ },
+ "resolutions": {
+ "prosemirror-model": "^1.25.7"
}
}
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/App.tsx b/frontend/src/App.tsx
index 88dbac4bbf..1a5a647e6c 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,5 +1,5 @@
import React, {FC, PropsWithChildren, useCallback, useEffect} from "react";
-import {MantineProvider} from "@mantine/core";
+import {MantineProvider, v8CssVariablesResolver} from "@mantine/core";
import {Notifications} from "@mantine/notifications";
import {i18n} from "@lingui/core";
import {I18nProvider} from "@lingui/react";
@@ -71,6 +71,7 @@ export const App: FC<
}}
/>
@@ -97,7 +99,7 @@ export const App: FC<
{props.children}
-
+
{showGlobalConsentBanner && (
)}
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..bfb98d5b64 100644
--- a/frontend/src/api/event.client.ts
+++ b/frontend/src/api/event.client.ts
@@ -10,6 +10,7 @@ import {
Image,
ImageType,
QueryFilters,
+ UpsertEventLocationPayload,
} from "../types";
import {publicApi} from "./public-client.ts";
import {queryParamsHelper} from "../utilites/queryParamsHelper.ts";
@@ -37,9 +38,17 @@ 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; startDate?: string; endDate?: string} = {},
+ ) => {
+ const params = new URLSearchParams();
+ if (options.occurrenceId) params.set('occurrence_id', String(options.occurrenceId));
+ if (options.dateRange) params.set('date_range', options.dateRange);
+ if (options.startDate) params.set('start_date', options.startDate);
+ if (options.endDate) params.set('end_date', options.endDate);
+ const qs = params.toString();
+ const response = await api.get>(`events/${eventId}/stats${qs ? '?' + qs : ''}`);
return response.data;
},
@@ -85,6 +94,14 @@ export const eventsClient = {
return response.data;
},
+ updateEventLocation: async (
+ eventId: IdParam,
+ payload: { event_location?: UpsertEventLocationPayload | null; clear_event_location?: boolean },
+ ) => {
+ const response = await api.patch>('events/' + eventId + '/event-location', payload);
+ return response.data;
+ },
+
updateEventStatus: async (eventId: IdParam, status: string) => {
const response = await api.put>('events/' + eventId + '/status', {
status
@@ -92,8 +109,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 +125,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/location.client.ts b/frontend/src/api/location.client.ts
new file mode 100644
index 0000000000..5d6623a33c
--- /dev/null
+++ b/frontend/src/api/location.client.ts
@@ -0,0 +1,73 @@
+import {api} from "./client";
+import {
+ GenericDataResponse,
+ GenericPaginatedResponse,
+ GeoPlace,
+ GeoSuggestion,
+ IdParam,
+ Location,
+ QueryFilters,
+} from "../types";
+import {queryParamsHelper} from "../utilites/queryParamsHelper.ts";
+
+export type UpsertLocationPayload = Partial & {
+ structured_address: Location["structured_address"];
+};
+
+export const locationClient = {
+ all: async (organizerId: IdParam, pagination?: QueryFilters) => {
+ const response = await api.get>(
+ "organizers/" + organizerId + "/locations" + queryParamsHelper.buildQueryString(pagination ?? {}),
+ );
+ return response.data;
+ },
+
+ create: async (organizerId: IdParam, payload: UpsertLocationPayload) => {
+ const response = await api.post>(
+ "organizers/" + organizerId + "/locations",
+ payload,
+ );
+ return response.data;
+ },
+
+ update: async (organizerId: IdParam, locationId: IdParam, payload: UpsertLocationPayload) => {
+ const response = await api.put>(
+ "organizers/" + organizerId + "/locations/" + locationId,
+ payload,
+ );
+ return response.data;
+ },
+
+ delete: async (organizerId: IdParam, locationId: IdParam) => {
+ const response = await api.delete(
+ "organizers/" + organizerId + "/locations/" + locationId,
+ );
+ return response.data;
+ },
+
+ geoStatus: async () => {
+ const response = await api.get>("geo/status");
+ return response.data;
+ },
+
+ autocomplete: async (organizerId: IdParam, query: string, opts: {locale?: string; country?: string} = {}) => {
+ const params = new URLSearchParams();
+ params.set("query", query);
+ if (opts.locale) params.set("locale", opts.locale);
+ if (opts.country) params.set("country", opts.country);
+ const response = await api.get<{data: GeoSuggestion[]}>(
+ "organizers/" + organizerId + "/locations/autocomplete?" + params.toString(),
+ );
+ return response.data;
+ },
+
+ placeDetails: async (organizerId: IdParam, placeId: string, opts: {locale?: string} = {}) => {
+ const params = new URLSearchParams();
+ if (opts.locale) params.set("locale", opts.locale);
+ const qs = params.toString();
+ const response = await api.get<{data: GeoPlace}>(
+ "organizers/" + organizerId + "/locations/places/" + encodeURIComponent(placeId) + (qs ? "?" + qs : ""),
+ );
+ 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/organizer.client.ts b/frontend/src/api/organizer.client.ts
index 5a9f88f2f5..f80a8abc5e 100644
--- a/frontend/src/api/organizer.client.ts
+++ b/frontend/src/api/organizer.client.ts
@@ -51,6 +51,13 @@ export const organizerClient = {
return response.data;
},
+ updateLocation: async (organizerId: IdParam, locationId: IdParam | null) => {
+ const response = await api.patch>('organizers/' + organizerId + '/location', {
+ location_id: locationId,
+ });
+ return response.data;
+ },
+
findEventsByOrganizerId: async (organizerId: IdParam, pagination: QueryFilters) => {
const response = await api.get>(
'organizers/' + organizerId + '/events' + queryParamsHelper.buildQueryString(pagination)
@@ -58,8 +65,17 @@ export const organizerClient = {
return response.data;
},
- getOrganizerStats: async (organizerId: IdParam, currencyCode: string) => {
- const response = await api.get>('organizers/' + organizerId + '/stats?currency_code=' + currencyCode);
+ getOrganizerStats: async (
+ organizerId: IdParam,
+ options: {currencyCode: string; startDate?: string; endDate?: string},
+ ) => {
+ const params = new URLSearchParams();
+ params.append('currency_code', options.currencyCode);
+ if (options.startDate) params.append('start_date', options.startDate);
+ if (options.endDate) params.append('end_date', options.endDate);
+ const response = await api.get>(
+ `organizers/${organizerId}/stats?${params.toString()}`,
+ );
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/Accordion/index.tsx b/frontend/src/components/common/Accordion/index.tsx
index 208dd26375..522b757700 100644
--- a/frontend/src/components/common/Accordion/index.tsx
+++ b/frontend/src/components/common/Accordion/index.tsx
@@ -1,11 +1,11 @@
import {Accordion as MantineAccordion, Group, Text} from '@mantine/core';
-import {TablerIconsProps} from '@tabler/icons-react';
+import {Icon} from '@tabler/icons-react';
import classes from './Accordion.module.scss';
import React from "react";
export interface AccordionItem {
value: string;
- icon?: (props: TablerIconsProps) => JSX.Element;
+ icon?: Icon;
title: string;
count?: number;
hidden?: boolean;
diff --git a/frontend/src/components/common/AddEventToCalendarButton/index.tsx b/frontend/src/components/common/AddEventToCalendarButton/index.tsx
index 5148fc5bf9..119c350b12 100644
--- a/frontend/src/components/common/AddEventToCalendarButton/index.tsx
+++ b/frontend/src/components/common/AddEventToCalendarButton/index.tsx
@@ -1,16 +1,17 @@
import {ActionIcon, Tooltip} from '@mantine/core';
import {IconCalendarPlus} from '@tabler/icons-react';
import {t} from "@lingui/macro";
-import {Event} from "../../../types.ts";
+import {Event, EventOccurrence} from "../../../types.ts";
import {CalendarOptionsPopover} from "../CalendarOptionsPopover";
interface AddToCalendarProps {
event: Event;
+ occurrence?: EventOccurrence;
}
-export const AddToEventCalendarButton = ({event}: AddToCalendarProps) => {
+export const AddToEventCalendarButton = ({event, occurrence}: AddToCalendarProps) => {
return (
-
+
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/AddressAutocomplete/index.tsx b/frontend/src/components/common/AddressAutocomplete/index.tsx
new file mode 100644
index 0000000000..9eafc17aee
--- /dev/null
+++ b/frontend/src/components/common/AddressAutocomplete/index.tsx
@@ -0,0 +1,113 @@
+import {t} from "@lingui/macro";
+import {Combobox, Loader, TextInput, useCombobox} from "@mantine/core";
+import {useRef, useState} from "react";
+import {useDebouncedValue} from "@mantine/hooks";
+import {GeoPlace, IdParam} from "../../../types.ts";
+import {useGeoAutocomplete} from "../../../queries/useGeoAutocomplete.ts";
+import {useGeoStatus} from "../../../queries/useGeoStatus.ts";
+import {useResolveGeoPlace} from "../../../mutations/useResolveGeoPlace.ts";
+import {showError} from "../../../utilites/notifications.tsx";
+
+interface AddressAutocompleteProps {
+ organizerId: IdParam;
+ label?: string;
+ placeholder?: string;
+ description?: string;
+ country?: string;
+ locale?: string;
+ onPlaceSelected: (place: GeoPlace) => void;
+}
+
+export const AddressAutocomplete = ({
+ organizerId,
+ label,
+ placeholder,
+ description,
+ country,
+ locale,
+ onPlaceSelected,
+}: AddressAutocompleteProps) => {
+ const combobox = useCombobox();
+ const [value, setValue] = useState("");
+ const [debounced] = useDebouncedValue(value, 250);
+ const justSelectedRef = useRef(false);
+ const geoStatus = useGeoStatus();
+ const geoAvailable = geoStatus.data?.data?.available === true;
+
+ const suggestionsQuery = useGeoAutocomplete(organizerId, debounced, {
+ country,
+ locale,
+ enabled: geoAvailable && debounced.length >= 3 && !justSelectedRef.current,
+ });
+ const resolvePlace = useResolveGeoPlace();
+
+ if (!geoAvailable) {
+ return null;
+ }
+
+ const suggestions = suggestionsQuery.data?.data ?? [];
+
+ const handleSelect = async (placeId: string) => {
+ const match = suggestions.find((s) => s.provider_place_id === placeId);
+ if (!match) return;
+ justSelectedRef.current = true;
+ combobox.closeDropdown();
+ const label = match.secondary_text ? `${match.primary_text}, ${match.secondary_text}` : match.primary_text;
+ setValue(label);
+ try {
+ const response = await resolvePlace.mutateAsync({organizerId, placeId, locale});
+ onPlaceSelected(response.data);
+ } catch (error) {
+ showError(t`Could not retrieve address details`);
+ }
+ };
+
+ const handleChange = (event: React.ChangeEvent) => {
+ const next = event.currentTarget.value;
+ justSelectedRef.current = false;
+ setValue(next);
+ if (next.trim().length >= 3) {
+ combobox.openDropdown();
+ } else {
+ combobox.closeDropdown();
+ }
+ };
+
+ const handleFocus = () => {
+ if (!justSelectedRef.current && value.trim().length >= 3 && suggestions.length > 0) {
+ combobox.openDropdown();
+ }
+ };
+
+ return (
+
+
+ combobox.closeDropdown()}
+ rightSection={suggestionsQuery.isFetching || resolvePlace.isPending ? : null}
+ />
+
+
+
+ {suggestions.length === 0 && (
+ {t`No suggestions`}
+ )}
+ {suggestions.map((s) => (
+
+
+
{s.primary_text}
+ {s.secondary_text &&
{s.secondary_text}
}
+
+
+ ))}
+
+
+
+ );
+};
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/AttendeeList/index.tsx b/frontend/src/components/common/AttendeeList/index.tsx
index 6a8ebc11ba..346b4d675d 100644
--- a/frontend/src/components/common/AttendeeList/index.tsx
+++ b/frontend/src/components/common/AttendeeList/index.tsx
@@ -119,7 +119,7 @@ export const AttendeeList = ({order, products, refetchOrder, questionAnswers = [
{/* Collapsible answers section */}
-
+
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;
+ const eventLocation = resolveEventLocation(event, ticketOccurrence);
+ const venueName = eventLocation?.type === LocationType.InPerson
+ ? (eventLocation.location?.name || eventLocation.location?.structured_address?.venue_name || null)
+ : null;
+ const formattedAddress = eventLocation?.type === LocationType.InPerson && eventLocation.location?.structured_address
+ ? formatAddress(eventLocation.location.structured_address)
+ : '';
+ const locationLine = [venueName, formattedAddress].filter(Boolean).join(', ');
+ const isInPerson = eventLocation?.type === LocationType.InPerson && locationLine.length > 0;
+ const isOnline = eventLocation?.type === LocationType.Online;
const ticketDesignSettings = event?.settings?.ticket_design_settings;
const accentColor = ticketDesignSettings?.accent_color || '#6B46C1';
@@ -74,7 +90,10 @@ export const AttendeeTicket = ({
{t`Date & Time`}
- {prettyDate(event.start_date, event.timezone, true)}
+
+ {ticketOccurrence?.label && (
+ {ticketOccurrence.label}
+ )}
{event?.organizer?.name && (
@@ -86,11 +105,20 @@ export const AttendeeTicket = ({
)}
- {hasVenue && (
+ {isInPerson && (
{t`Location`}
- {formatAddress(event?.settings?.location_details as Address)}
+ {locationLine}
+
+
+ )}
+
+ {isOnline && (
+
+
{t`Location`}
+
+ {t`Online event`}
)}
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..320ac7c5bc
--- /dev/null
+++ b/frontend/src/components/common/Callout/index.tsx
@@ -0,0 +1,66 @@
+import {CSSProperties, 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;
+ style?: CSSProperties;
+}
+
+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,
+ style,
+ }: 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/CheckInOptionsModal.tsx b/frontend/src/components/common/CheckIn/CheckInOptionsModal.tsx
index d89fba3114..0110f41367 100644
--- a/frontend/src/components/common/CheckIn/CheckInOptionsModal.tsx
+++ b/frontend/src/components/common/CheckIn/CheckInOptionsModal.tsx
@@ -1,7 +1,8 @@
-import {Alert, Button, Modal, Stack} from "@mantine/core";
-import {IconAlertCircle, IconCreditCard, IconUserCheck} from "@tabler/icons-react";
+import {Button, Modal, Stack} from "@mantine/core";
+import {IconCreditCard, IconUserCheck} from "@tabler/icons-react";
import {t, Trans} from "@lingui/macro";
import {Attendee} from "../../../types.ts";
+import {Callout} from "../Callout";
interface CheckInOptionsModalProps {
isOpen: boolean;
@@ -28,12 +29,9 @@ export const CheckInOptionsModal = ({
size="md"
>
- }
- variant={'light'}
- title={t`Unpaid Order`}>
+
{t`This attendee has an unpaid order.`}
-
+
}
onClick={() => onCheckIn('check-in')}
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/InsertImageControl/index.tsx b/frontend/src/components/common/Editor/Controls/InsertImageControl/index.tsx
index f6a33ebbf6..7348cd59bb 100644
--- a/frontend/src/components/common/Editor/Controls/InsertImageControl/index.tsx
+++ b/frontend/src/components/common/Editor/Controls/InsertImageControl/index.tsx
@@ -98,7 +98,7 @@ export const InsertImageControl = () => {
}}
title={t`Insert Image`}
>
-
+ setTab(value ?? 'url')} variant="outline">
{t`Paste URL`}
{t`Upload Image`}
diff --git a/frontend/src/components/common/Editor/Controls/InsertLiquidVariableControl.tsx b/frontend/src/components/common/Editor/Controls/InsertLiquidVariableControl.tsx
index 411e23dd42..8a5716d07d 100644
--- a/frontend/src/components/common/Editor/Controls/InsertLiquidVariableControl.tsx
+++ b/frontend/src/components/common/Editor/Controls/InsertLiquidVariableControl.tsx
@@ -15,6 +15,23 @@ 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 LOCATION_VARIABLES: TemplateVariable[] = [
+ {label: t`Location Name`, value: 'event_location.name', description: t`Resolved location or venue name`, category: t`Location`},
+ {label: t`Location Formatted Address`, value: 'event_location.formatted_address', description: t`Full resolved address`, category: t`Location`},
+ {label: t`Location Latitude`, value: 'event_location.latitude', description: t`Latitude of the resolved location`, category: t`Location`},
+ {label: t`Location Longitude`, value: 'event_location.longitude', description: t`Longitude of the resolved location`, category: t`Location`},
+ {label: t`Location Mode`, value: 'event_location.mode', description: t`in_person, online, unset, or mixed`, category: t`Location`},
+ {label: t`Online Connection Details`, value: 'event_location.online_connection_details', description: t`Online event connection details`, category: t`Location`},
+];
+
const TEMPLATE_VARIABLES: Record = {
order_confirmation: [
// Order Information
@@ -39,6 +56,9 @@ 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`},
+ ...LOCATION_VARIABLES,
+
+ ...OCCURRENCE_VARIABLES,
// Organization
{label: t`Organizer Name`, value: 'organizer.name', description: t`Event organizer name`, category: t`Organization`},
@@ -62,7 +82,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 +93,10 @@ 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`},
+ ...LOCATION_VARIABLES,
+
+ ...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 +106,27 @@ 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`},
+
+ ...LOCATION_VARIABLES,
+
+ ...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/Editor/Extensions/ImageResizeExtension/index.tsx b/frontend/src/components/common/Editor/Extensions/ImageResizeExtension/index.tsx
index 9972a9ed75..27b56460f2 100644
--- a/frontend/src/components/common/Editor/Extensions/ImageResizeExtension/index.tsx
+++ b/frontend/src/components/common/Editor/Extensions/ImageResizeExtension/index.tsx
@@ -1,5 +1,5 @@
-import Image from '@tiptap/extension-image';
-import { NodeViewProps } from '@tiptap/react';
+import {Image} from '@tiptap/extension-image';
+import {NodeViewRendererProps} from '@tiptap/core';
/**
* Adapted from https://github.com/bae-sh/tiptap-extension-resize-image/blob/main/lib/imageResize.ts
@@ -22,7 +22,7 @@ export const ImageResize = Image.extend({
},
addNodeView() {
- return ({ node, editor, getPos }: NodeViewProps) => {
+ return ({ node, editor, getPos }: NodeViewRendererProps) => {
const {
view,
options: { editable },
@@ -89,11 +89,13 @@ export const ImageResize = Image.extend({
const dispatchNodeView = () => {
if (typeof getPos === 'function') {
+ const pos = getPos();
+ if (pos === undefined) return;
const newAttrs = {
...node.attrs,
style: `${$img.style.cssText}`,
};
- view.dispatch(view.state.tr.setNodeMarkup(getPos(), null, newAttrs));
+ view.dispatch(view.state.tr.setNodeMarkup(pos, null, newAttrs));
}
};
diff --git a/frontend/src/components/common/Editor/Extensions/LiquidTokenExtension/TokenComponent.tsx b/frontend/src/components/common/Editor/Extensions/LiquidTokenExtension/TokenComponent.tsx
index 43affb9c13..726947c557 100644
--- a/frontend/src/components/common/Editor/Extensions/LiquidTokenExtension/TokenComponent.tsx
+++ b/frontend/src/components/common/Editor/Extensions/LiquidTokenExtension/TokenComponent.tsx
@@ -1,19 +1,9 @@
-import {NodeViewWrapper} from '@tiptap/react';
+import {NodeViewWrapper, ReactNodeViewProps} from '@tiptap/react';
import {Badge, Tooltip} from '@mantine/core';
import {IconCode} from '@tabler/icons-react';
-interface TokenComponentProps {
- node: {
- attrs: {
- tokenName: string;
- tokenDescription: string;
- };
- };
- selected: boolean;
-}
-
-export const TokenComponent = ({node, selected}: TokenComponentProps) => {
- const {tokenName, tokenDescription} = node.attrs;
+export const TokenComponent = ({node, selected}: ReactNodeViewProps) => {
+ const {tokenName, tokenDescription} = node.attrs as { tokenName: string; tokenDescription: string };
const tokenBadge = (
{
+ liquidVariable: {
+ insertLiquidVariable: (variable: string) => ReturnType;
+ };
+ }
+}
+
export const LiquidVariable = Node.create({
name: 'liquidVariable',
diff --git a/frontend/src/components/common/Editor/index.tsx b/frontend/src/components/common/Editor/index.tsx
index e19ee0325f..1cf94d81c0 100644
--- a/frontend/src/components/common/Editor/index.tsx
+++ b/frontend/src/components/common/Editor/index.tsx
@@ -1,11 +1,9 @@
import {Link, RichTextEditor} from "@mantine/tiptap";
import {useEditor} from "@tiptap/react";
import StarterKit from '@tiptap/starter-kit';
-import Underline from '@tiptap/extension-underline';
-import TextAlign from '@tiptap/extension-text-align';
-import Image from '@tiptap/extension-image';
-import TextStyle from '@tiptap/extension-text-style';
-import Color from '@tiptap/extension-color';
+import {TextAlign} from '@tiptap/extension-text-align';
+import {Image} from '@tiptap/extension-image';
+import {Color, TextStyle} from '@tiptap/extension-text-style';
import React, {useEffect, useState} from "react";
import {InputDescription, InputError, InputLabel, MantineFontSize} from "@mantine/core";
import classes from "./Editor.module.scss";
@@ -49,6 +47,7 @@ export const Editor = ({
const editor = useEditor({
extensions: [
StarterKit.configure({
+ link: false,
paragraph: {
HTMLAttributes: {
style: 'margin: 0.5em 0;'
@@ -60,7 +59,6 @@ export const Editor = ({
}
}
}),
- Underline,
Link,
TextAlign.configure({types: ['heading', 'paragraph']}),
Image,
@@ -86,7 +84,7 @@ export const Editor = ({
useEffect(() => {
if (value && editor) {
if (value !== editor.getHTML()) {
- editor.commands.setContent(value, false, {preserveWhitespace: "full"});
+ editor.commands.setContent(value, {emitUpdate: false, parseOptions: {preserveWhitespace: "full"}});
}
const htmlLength = value.length;
diff --git a/frontend/src/components/common/EmailTemplateEditor/EmailTemplateEditor.module.scss b/frontend/src/components/common/EmailTemplateEditor/EmailTemplateEditor.module.scss
index beee7dfb89..a2f93ed0a1 100644
--- a/frontend/src/components/common/EmailTemplateEditor/EmailTemplateEditor.module.scss
+++ b/frontend/src/components/common/EmailTemplateEditor/EmailTemplateEditor.module.scss
@@ -14,7 +14,7 @@
.previewContent {
padding: 1.5rem;
- :global(.mantine-TypographyStylesProvider-root) {
+ :global(.mantine-Typography-root) {
line-height: 1.6;
h1, h2, h3, h4, h5, h6 {
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/EmailTemplateEditor/EmailTemplatePreviewPane.tsx b/frontend/src/components/common/EmailTemplateEditor/EmailTemplatePreviewPane.tsx
index c2f50380ad..4b84230290 100644
--- a/frontend/src/components/common/EmailTemplateEditor/EmailTemplatePreviewPane.tsx
+++ b/frontend/src/components/common/EmailTemplateEditor/EmailTemplatePreviewPane.tsx
@@ -1,4 +1,4 @@
-import {Alert, Divider, LoadingOverlay, Stack, Text, TypographyStylesProvider} from '@mantine/core';
+import {Alert, Divider, LoadingOverlay, Stack, Text, Typography} from '@mantine/core';
import {IconAlertCircle, IconEye} from '@tabler/icons-react';
import {Trans} from '@lingui/macro';
import classes from './EmailTemplateEditor.module.scss';
@@ -42,13 +42,13 @@ export const EmailTemplatePreviewPane = ({
)}
{!error && hasContent && (
-
+
-
+
)}
{!error && !hasContent && !isLoading && (
diff --git a/frontend/src/components/common/EmailTemplateSettings/EmailTemplateSettingsBase.tsx b/frontend/src/components/common/EmailTemplateSettings/EmailTemplateSettingsBase.tsx
index dbc0fb44c7..d8c56bee5c 100644
--- a/frontend/src/components/common/EmailTemplateSettings/EmailTemplateSettingsBase.tsx
+++ b/frontend/src/components/common/EmailTemplateSettings/EmailTemplateSettingsBase.tsx
@@ -1,6 +1,7 @@
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 {ActionIcon, Badge, Button, Group, LoadingOverlay, Modal, Paper, Stack, Text} from '@mantine/core';
+import {IconBrandStripe, IconEdit, IconMail, IconPlus, IconTrash} from '@tabler/icons-react';
+import {Callout} from '../Callout';
import {t, Trans} from '@lingui/macro';
import {useDisclosure} from '@mantine/hooks';
import {EmailTemplateEditor} from '../EmailTemplateEditor';
@@ -11,13 +12,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 +56,7 @@ interface EmailTemplateSettingsBaseProps {
onSaveSuccess?: () => void;
onDeleteSuccess?: () => void;
onError?: (error: any, message: string) => void;
+ eventType?: EventType;
}
export const EmailTemplateSettingsBase = ({
@@ -68,19 +72,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 +209,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) => {
@@ -338,29 +350,32 @@ export const EmailTemplateSettingsBase = ({
/>
{(!isAccountVerified && isAccountFetched) && (
- } variant="light" mb="lg" color="orange">
-
- {t`You need to verify your account email before you can modify email templates.`}
-
-
+
+ {t`You need to verify your account email before you can modify email templates.`}
+
)}
{accountRequiresManualVerification && (
- } variant="light" mb="lg" color="orange" title={t`Connect Stripe to enable email template editing`}>
-
- {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.`}
-
-
-
-
-
+
+ {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`}
+
+
+ )}
+
)}
- } variant="light" mb="lg">
-
- {getAlertMessage()}
-
-
+
+ {getAlertMessage()}
+
@@ -379,6 +394,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..60944cf3ea 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 {
@@ -371,3 +399,259 @@
color: var(--mantine-color-text);
}
}
+
+.eventCardCompact {
+ border: 0.5px solid var(--hi-color-border);
+ border-radius: var(--hi-radius-card);
+ overflow: hidden;
+ background: var(--hi-color-white);
+ transition: background-color 0.12s ease;
+ container-type: inline-size;
+
+ &:hover {
+ background-color: var(--mantine-color-gray-0);
+
+ .compactTitle {
+ color: var(--mantine-color-primary-7);
+ }
+ }
+
+ &.isDraft {
+ .compactImage {
+ opacity: 0.85;
+ }
+ }
+
+ &.isEnded {
+ .compactImage {
+ filter: grayscale(60%);
+ }
+ }
+}
+
+.cardLinkCompact {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 12px;
+ text-decoration: none;
+ color: inherit;
+ min-height: 76px;
+
+ @container (max-width: 460px) {
+ gap: 10px;
+ padding: 10px;
+ }
+}
+
+.compactThumb {
+ position: relative;
+ width: 56px;
+ height: 56px;
+ min-width: 56px;
+ border-radius: var(--hi-radius-sm);
+ overflow: hidden;
+ flex-shrink: 0;
+
+ @container (max-width: 460px) {
+ width: 44px;
+ height: 44px;
+ min-width: 44px;
+ }
+}
+
+.compactImage {
+ position: absolute;
+ inset: 0;
+ background-size: cover;
+ background-position: center;
+}
+
+.compactDateBadge {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.32);
+ color: var(--mantine-color-white);
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
+}
+
+.compactDateDay {
+ font-size: 1rem;
+ font-weight: 700;
+ line-height: 1;
+
+ @container (max-width: 460px) {
+ font-size: 0.8125rem;
+ }
+}
+
+.compactDateMonth {
+ font-size: 0.625rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ margin-top: 2px;
+
+ @container (max-width: 460px) {
+ font-size: 0.5625rem;
+ }
+}
+
+.compactContent {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.compactPrimary {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+}
+
+.compactTitle {
+ font-size: 0.9375rem;
+ font-weight: 500;
+ color: var(--hi-text);
+ transition: color 0.12s ease;
+ @include mixins.ellipsis;
+ letter-spacing: -0.005em;
+}
+
+.compactRecurringIcon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--hi-color-text-muted);
+ flex-shrink: 0;
+}
+
+.compactMeta {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 6px;
+ font-size: 0.75rem;
+ color: var(--hi-color-text-muted);
+ line-height: 1.3;
+}
+
+.compactMetaItem {
+ white-space: nowrap;
+}
+
+.compactLocation {
+ @include mixins.ellipsis;
+ max-width: 160px;
+}
+
+.compactDot {
+ color: var(--hi-color-border);
+}
+
+.compactStatus {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+.compactStatusDot {
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: currentColor;
+ animation: pulse 1.5s ease-in-out infinite;
+}
+
+.compactStatus-success {
+ color: var(--hi-color-success);
+}
+
+.compactStatus-warning {
+ color: var(--hi-color-warning);
+}
+
+.compactStatus-danger {
+ color: var(--hi-color-danger);
+}
+
+.compactStatus-muted {
+ color: var(--hi-color-text-muted);
+}
+
+.compactTickets {
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+.compactTickets-available {
+ color: var(--hi-color-success);
+}
+
+.compactTickets-partial {
+ color: var(--hi-color-warning);
+}
+
+.compactTickets-sold-out {
+ color: var(--hi-color-danger);
+}
+
+.compactStats {
+ display: flex;
+ gap: 16px;
+ flex-shrink: 0;
+
+ @container (max-width: 460px) {
+ gap: 0;
+ }
+}
+
+.compactStat {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ min-width: 64px;
+
+ @container (max-width: 460px) {
+ min-width: 0;
+
+ &:not(:last-child) {
+ display: none;
+ }
+ }
+}
+
+.compactStatValue {
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--hi-text);
+ line-height: 1.2;
+ letter-spacing: -0.01em;
+ white-space: nowrap;
+}
+
+.compactStatLabel {
+ font-size: 0.625rem;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--hi-color-text-muted);
+ margin-top: 2px;
+
+ @container (max-width: 460px) {
+ display: none;
+ }
+}
+
+.compactMenuButton {
+ flex-shrink: 0;
+}
diff --git a/frontend/src/components/common/EventCard/index.tsx b/frontend/src/components/common/EventCard/index.tsx
index 59ecbc963f..f3ff284534 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, LocationType, 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"
@@ -22,6 +23,8 @@ import {formatCurrency} from "../../../utilites/currency.ts";
import {formatNumber} from "../../../utilites/helpers.ts";
import {formatDateWithLocale, relativeDate} from "../../../utilites/dates.ts";
import {Card} from "../Card";
+import {resolveEventLocation} from "../../../utilites/effectiveLocation.ts";
+import {formatAddress} from "../../../utilites/addressUtilities.ts";
const placeholderGradients = [
'linear-gradient(135deg, var(--mantine-color-violet-5) 0%, var(--mantine-color-indigo-5) 100%)',
@@ -36,9 +39,10 @@ const placeholderGradients = [
interface EventCardProps {
event: Event;
+ compact?: boolean;
}
-export function EventCard({event}: EventCardProps) {
+export function EventCard({event, compact = false}: EventCardProps) {
const navigate = useNavigate();
const [isDuplicateModalOpen, duplicateModal] = useDisclosure(false);
const [eventId, setEventId] = useState();
@@ -75,26 +79,45 @@ export function EventCard({event}: EventCardProps) {
const getStatusConfig = () => {
if (event.status === 'ARCHIVED') {
- return {label: t`Archived`, status: 'archived'};
+ return {label: t`Archived`, status: 'archived', tone: 'muted'};
}
if (event.lifecycle_status === 'ENDED') {
- return {label: t`Ended`, status: 'ended'};
+ return {label: t`Ended`, status: 'ended', tone: 'muted'};
}
if (event.status === 'DRAFT') {
- return {label: t`Draft`, status: 'draft'};
+ return {label: t`Draft`, status: 'draft', tone: 'warning'};
}
if (event.lifecycle_status === 'ONGOING') {
- return {label: t`Live`, status: 'live', pulse: true};
+ return {label: t`Live`, status: 'live', tone: 'success', pulse: true};
}
- return {label: t`On Sale`, status: 'onsale'};
+ return {label: t`On Sale`, status: 'onsale', tone: 'success'};
};
const getLocationText = () => {
- if (event.settings?.is_online_event) return t`Online`;
- const location = event.settings?.location_details;
- if (location?.venue_name) return location.venue_name;
- if (location?.city) return location.city;
- return null;
+ const occurrences = event.occurrences ?? [];
+ const resolvedList = occurrences.length > 0
+ ? occurrences.map(o => resolveEventLocation(event, o))
+ : [resolveEventLocation(event, null)];
+
+ const types = new Set();
+ const locationIds = new Set();
+ for (const r of resolvedList) {
+ if (r) {
+ types.add(r.type);
+ if (r.location_id != null) locationIds.add(String(r.location_id));
+ }
+ }
+
+ const first = resolvedList[0];
+ if (!first) return null;
+ if (types.size > 1) return t`Online & in-person`;
+ if (first.type === LocationType.Online) return t`Online`;
+ if (locationIds.size > 1) return t`Multiple locations`;
+ if (first.type !== LocationType.InPerson) return null;
+ const city = first.location?.structured_address?.city;
+ const venueName = first.location?.name || first.location?.structured_address?.venue_name;
+ const formatted = first.location?.structured_address ? formatAddress(first.location.structured_address) : '';
+ return venueName ?? city ?? (formatted ? formatted : null);
};
const getTicketAvailability = () => {
@@ -151,10 +174,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 +184,103 @@ 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);
+
+ if (compact) {
+ return (
+ <>
+
+
+
+
+
+ {dayOfMonth}
+ {monthShort}
+
+
+
+
+
+ {event.title}
+ {isRecurring && (
+
+
+
+ )}
+
+
+
+ {statusConfig.pulse && }
+ {statusConfig.label}
+
+ ·
+
+ {shortDateTime}
+
+ ·
+ {relativeDateStr}
+ {locationText && (
+ <>
+ ·
+
+ {locationText}
+
+ >
+ )}
+ {ticketAvailability && (
+ <>
+ ·
+
+ {ticketAvailability.text}
+
+ >
+ )}
+
+
+
+
+
+ {formatNumber(attendees)}
+ {t`Attendees`}
+
+
+ {formatCurrency(revenue, event?.currency)}
+ {t`Revenue`}
+
+
+
+ e.preventDefault()}>
+
+
+
+ }
+ />
+
+
+
+ {isDuplicateModalOpen && }
+ >
+ );
+ }
return (
<>
@@ -189,13 +305,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/EventDocumentHead/index.tsx b/frontend/src/components/common/EventDocumentHead/index.tsx
index 7bb2bacd07..f47efcfe37 100644
--- a/frontend/src/components/common/EventDocumentHead/index.tsx
+++ b/frontend/src/components/common/EventDocumentHead/index.tsx
@@ -1,8 +1,10 @@
/* eslint-disable lingui/no-unlocalized-strings */
import {Helmet} from "react-helmet-async";
-import {Event} from "../../../types";
+import {Event, LocationType} from "../../../types";
import {eventCoverImageUrl, eventHomepageUrl} from "../../../utilites/urlHelper.ts";
import {utcToTz} from "../../../utilites/dates.ts";
+import {resolveEventLocation} from "../../../utilites/effectiveLocation.ts";
+import {formatAddress} from "../../../utilites/addressUtilities.ts";
interface EventDocumentHeadProps {
event: Event;
@@ -19,26 +21,63 @@ export const EventDocumentHead = ({event}: EventDocumentHeadProps) => {
const startDate = utcToTz(new Date(event.start_date), event.timezone);
const endDate = event.end_date ? utcToTz(new Date(event.end_date), event.timezone) : undefined;
- const address = {
+ const occurrences = event.occurrences ?? [];
+ const resolvedList = occurrences.length > 0
+ ? occurrences.map(o => resolveEventLocation(event, o))
+ : [resolveEventLocation(event, null)];
+ const types = new Set();
+ for (const r of resolvedList) {
+ if (r) types.add(r.type);
+ }
+ const effective = resolvedList[0];
+ const hasMixedModes = types.size > 1;
+ const structuredAddress = effective?.type === LocationType.InPerson ? effective.location?.structured_address : null;
+
+ const address = structuredAddress ? {
"@type": "http://schema.org/PostalAddress",
- streetAddress: eventSettings?.location_details?.address_line_1,
- addressLocality: eventSettings?.location_details?.city,
- addressRegion: eventSettings?.location_details?.state_or_region,
- postalCode: eventSettings?.location_details?.zip_or_postal_code,
- addressCountry: eventSettings?.location_details?.country
- };
+ streetAddress: structuredAddress.address_line_1,
+ addressLocality: structuredAddress.city,
+ addressRegion: structuredAddress.state_or_region,
+ postalCode: structuredAddress.zip_or_postal_code,
+ addressCountry: structuredAddress.country
+ } : null;
+
+ if (address) {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ Object.keys(address).forEach(key => address[key] === undefined && delete address[key]);
+ }
- // Filter out undefined address properties
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
- Object.keys(address).forEach(key => address[key] === undefined && delete address[key]);
+ const latitude = effective?.type === LocationType.InPerson ? effective.location?.latitude : null;
+ const longitude = effective?.type === LocationType.InPerson ? effective.location?.longitude : null;
+ const geo = latitude != null && longitude != null ? {
+ "@type": "http://schema.org/GeoCoordinates",
+ latitude,
+ longitude,
+ } : null;
- const location = eventSettings?.location_details && Object.keys(address).length > 1 ? {
+ const venueName = effective?.type === LocationType.InPerson
+ ? (effective.location?.name || effective.location?.structured_address?.venue_name || null)
+ : null;
+ const placeName = venueName
+ ?? (effective?.type === LocationType.InPerson && structuredAddress ? formatAddress(structuredAddress) : null);
+
+ const includePlace = effective?.type === LocationType.InPerson && address && Object.keys(address).length > 1;
+ const location = includePlace ? {
"@type": "http://schema.org/Place",
- name: event.location_details?.venue_name,
- address
+ name: placeName,
+ address,
+ ...(geo ? {geo} : {}),
} : {};
+ const eventAttendanceMode = hasMixedModes
+ ? "https://schema.org/MixedEventAttendanceMode"
+ : effective?.type === LocationType.Online
+ ? "https://schema.org/OnlineEventAttendanceMode"
+ : effective?.type === LocationType.InPerson
+ ? "https://schema.org/OfflineEventAttendanceMode"
+ : undefined;
+
const schemaOrgJSONLD = {
"@context": "http://schema.org",
"@type": "http://schema.org/Event",
@@ -56,7 +95,7 @@ export const EventDocumentHead = ({event}: EventDocumentHeadProps) => {
},
url,
eventStatus: 'https://schema.org/EventScheduled',
- eventAttendanceMode: event.settings?.is_online_event ? "https://schema.org/OnlineEventAttendanceMode" : "https://schema.org/OfflineEventAttendanceMode",
+ eventAttendanceMode,
currency: event.currency,
offers: products.map(product => ({
"@type": "http://schema.org/Offer",
diff --git a/frontend/src/components/common/InlineOrderSummary/index.tsx b/frontend/src/components/common/InlineOrderSummary/index.tsx
index 5f70770e4c..d105716164 100644
--- a/frontend/src/components/common/InlineOrderSummary/index.tsx
+++ b/frontend/src/components/common/InlineOrderSummary/index.tsx
@@ -3,9 +3,10 @@ import {Collapse, Popover} from "@mantine/core";
import {IconCalendarEvent, IconChevronDown, IconInfoCircle, IconShieldCheck, IconTag} from "@tabler/icons-react";
import {t} from "@lingui/macro";
import classNames from "classnames";
-import {Event, Order} from "../../../types.ts";
+import {Event, LocationType, Order} from "../../../types.ts";
import {formatCurrency} from "../../../utilites/currency.ts";
import {prettyDate} from "../../../utilites/dates.ts";
+import {resolveEventLocation} from "../../../utilites/effectiveLocation.ts";
import classes from './InlineOrderSummary.module.scss';
interface InlineOrderSummaryProps {
@@ -28,9 +29,15 @@ export const InlineOrderSummary = ({
: order.total_gross;
const coverImage = event?.images?.find((image) => image.type === 'EVENT_COVER');
- const location = event?.settings?.location_details?.city ||
- event?.settings?.location_details?.venue_name ||
- null;
+ const orderOccurrence = order.order_items?.[0]?.event_occurrence;
+ const effective = resolveEventLocation(event, orderOccurrence);
+ const venueName = effective?.type === LocationType.InPerson
+ ? (effective.location?.name || effective.location?.structured_address?.venue_name || null)
+ : null;
+ const city = effective?.type === LocationType.InPerson
+ ? effective.location?.structured_address?.city ?? null
+ : null;
+ const location = city || venueName || null;
const totalFee = order.taxes_and_fees_rollup?.fees?.reduce((sum, fee) => sum + fee.value, 0) || 0;
const totalTax = order.taxes_and_fees_rollup?.taxes?.reduce((sum, tax) => sum + tax.value, 0) || 0;
@@ -65,7 +72,7 @@ export const InlineOrderSummary = ({
-
+
@@ -80,8 +87,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/KpiGrid/KpiGrid.module.scss b/frontend/src/components/common/KpiGrid/KpiGrid.module.scss
new file mode 100644
index 0000000000..ab9804c46f
--- /dev/null
+++ b/frontend/src/components/common/KpiGrid/KpiGrid.module.scss
@@ -0,0 +1,100 @@
+@use "../../../styles/mixins.scss";
+
+.grid {
+ background-color: var(--hi-color-border);
+ border: 0.5px solid var(--hi-color-border);
+ border-radius: var(--hi-radius-card);
+ overflow: hidden;
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 0.5px;
+
+ @include mixins.respond-below(lg) {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ @include mixins.respond-below(sm) {
+ grid-template-columns: 1fr;
+ }
+}
+
+.cell {
+ background-color: var(--hi-color-white);
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+}
+
+.label {
+ font-size: 0.6875rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--hi-color-text-muted);
+ font-weight: 500;
+ margin-bottom: 8px;
+}
+
+.middleRow {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 8px;
+}
+
+.value {
+ font-size: 1.25rem;
+ font-weight: 500;
+ letter-spacing: -0.015em;
+ color: var(--hi-text);
+ line-height: 1.2;
+}
+
+.sparkline {
+ width: 56px;
+ height: 20px;
+ flex-shrink: 0;
+}
+
+.sparklineSpacer {
+ width: 56px;
+ height: 20px;
+ flex-shrink: 0;
+}
+
+.delta {
+ margin-top: 8px;
+ font-size: 0.75rem;
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ line-height: 1.2;
+}
+
+.deltaUp {
+ color: var(--hi-color-success);
+}
+
+.deltaDown {
+ color: var(--hi-color-danger);
+}
+
+.deltaFlat {
+ color: var(--hi-color-text-muted);
+}
+
+.deltaNone {
+ color: var(--hi-color-text-muted);
+}
+
+.deltaPlaceholder {
+ display: block;
+ margin-top: 8px;
+ height: 15px;
+}
+
+.skeletonRow {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+}
diff --git a/frontend/src/components/common/KpiGrid/index.tsx b/frontend/src/components/common/KpiGrid/index.tsx
new file mode 100644
index 0000000000..ca0ee92db8
--- /dev/null
+++ b/frontend/src/components/common/KpiGrid/index.tsx
@@ -0,0 +1,100 @@
+import {ReactNode} from "react";
+import {Skeleton} from "@mantine/core";
+import {Sparkline} from "@mantine/charts";
+import {IconArrowDownRight, IconArrowUpRight} from "@tabler/icons-react";
+import {t} from "@lingui/macro";
+import classes from "./KpiGrid.module.scss";
+
+interface KpiGridProps {
+ children: ReactNode;
+ className?: string;
+}
+
+export const KpiGrid = ({children, className = ''}: KpiGridProps) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export interface KpiCellDelta {
+ percent: number;
+ trend: 'up' | 'down' | 'flat';
+}
+
+interface KpiCellProps {
+ label: string;
+ value: string | number;
+ sparkline?: number[];
+ delta?: KpiCellDelta | null;
+ isLoading?: boolean;
+}
+
+const formatPercent = (percent: number, sign: '+' | '-') => {
+ const abs = Math.abs(percent).toFixed(1);
+ return `${sign}${abs}%`;
+};
+
+export const KpiCell = ({label, value, sparkline, delta, isLoading = false}: KpiCellProps) => {
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ let deltaContent: ReactNode;
+ if (delta == null) {
+ deltaContent =
;
+ } else if (delta.trend === 'flat') {
+ deltaContent =
{t`No change`} ;
+ } else if (delta.trend === 'up') {
+ deltaContent = (
+
+
+ {formatPercent(delta.percent, '+')}
+
+ );
+ } else {
+ deltaContent = (
+
+
+ {formatPercent(delta.percent, '-')}
+
+ );
+ }
+
+ return (
+
+
{label}
+
+
{value}
+ {sparkline && sparkline.length > 0 ? (
+
+ ) : (
+
+ )}
+
+ {deltaContent}
+
+ );
+};
+
+export default KpiGrid;
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/OnlineEventDetails/index.tsx b/frontend/src/components/common/OnlineEventDetails/index.tsx
index b428316cc4..0daba5041e 100644
--- a/frontend/src/components/common/OnlineEventDetails/index.tsx
+++ b/frontend/src/components/common/OnlineEventDetails/index.tsx
@@ -1,17 +1,28 @@
import {t} from "@lingui/macro";
import {Card} from "../Card";
-import {EventSettings} from "../../../types.ts";
+import {Event, EventOccurrence, LocationType} from "../../../types.ts";
+import {resolveEventLocation} from "../../../utilites/effectiveLocation.ts";
-export const OnlineEventDetails = (props: { eventSettings: EventSettings }) => {
- return <>
- {(props.eventSettings.is_online_event && props.eventSettings.online_event_connection_details) && (
-
-
{t`Online Event Details`}
-
-
-
-
- )}
- >;
+interface OnlineEventDetailsProps {
+ event?: Event | null;
+ occurrence?: EventOccurrence | null;
}
+
+export const OnlineEventDetails = (props: OnlineEventDetailsProps) => {
+ if (!props.event) return null;
+ const eventLocation = resolveEventLocation(props.event, props.occurrence ?? null);
+
+ if (eventLocation?.type !== LocationType.Online || !eventLocation.online_event_connection_details) {
+ return null;
+ }
+ const details = eventLocation.online_event_connection_details;
+
+ return (
+
+
{t`Online Event Details`}
+
+
+
+
+ );
+};
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/OrganizerDocumentHead/index.tsx b/frontend/src/components/common/OrganizerDocumentHead/index.tsx
index fee5250e81..0630934f7d 100644
--- a/frontend/src/components/common/OrganizerDocumentHead/index.tsx
+++ b/frontend/src/components/common/OrganizerDocumentHead/index.tsx
@@ -17,13 +17,14 @@ export const OrganizerDocumentHead = ({organizer}: OrganizerDocumentHeadProps) =
const image = coverImage || logoImage;
const url = organizerHomepageUrl(organizer);
- const address = organizerSettings?.location_details ? {
+ const structuredAddress = organizer.location?.structured_address;
+ const address = structuredAddress ? {
"@type": "http://schema.org/PostalAddress",
- streetAddress: organizerSettings.location_details.address_line_1,
- addressLocality: organizerSettings.location_details.city,
- addressRegion: organizerSettings.location_details.state_or_region,
- postalCode: organizerSettings.location_details.zip_or_postal_code,
- addressCountry: organizerSettings.location_details.country
+ streetAddress: structuredAddress.address_line_1,
+ addressLocality: structuredAddress.city,
+ addressRegion: structuredAddress.state_or_region,
+ postalCode: structuredAddress.zip_or_postal_code,
+ addressCountry: structuredAddress.country
} : undefined;
// Filter out undefined address properties
@@ -39,7 +40,7 @@ export const OrganizerDocumentHead = ({organizer}: OrganizerDocumentHeadProps) =
const location = address && Object.keys(address).length > 1 ? {
"@type": "http://schema.org/Place",
- name: organizerSettings?.location_details?.venue_name,
+ name: organizer.location?.name ?? structuredAddress?.venue_name,
address
} : undefined;
diff --git a/frontend/src/components/common/PeriodSelector/PeriodSelector.module.scss b/frontend/src/components/common/PeriodSelector/PeriodSelector.module.scss
new file mode 100644
index 0000000000..ad49a489a9
--- /dev/null
+++ b/frontend/src/components/common/PeriodSelector/PeriodSelector.module.scss
@@ -0,0 +1,34 @@
+.trigger {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ background-color: var(--hi-color-white);
+ border: 0.5px solid var(--hi-color-border);
+ border-radius: 6px;
+ font-size: 0.8125rem;
+ font-weight: 500;
+ color: var(--hi-text);
+ cursor: pointer;
+ line-height: 1.2;
+
+ &:hover {
+ background-color: var(--mantine-color-gray-0);
+ }
+}
+
+.chevron {
+ opacity: 0.7;
+}
+
+.selected {
+ background-color: var(--mantine-color-secondary-0);
+}
+
+.sectionLabel {
+ font-size: 0.6875rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--hi-color-text-muted);
+ font-weight: 500;
+}
diff --git a/frontend/src/components/common/PeriodSelector/index.tsx b/frontend/src/components/common/PeriodSelector/index.tsx
new file mode 100644
index 0000000000..e843d130e9
--- /dev/null
+++ b/frontend/src/components/common/PeriodSelector/index.tsx
@@ -0,0 +1,141 @@
+import {useEffect, useState} from "react";
+import {Menu, UnstyledButton} from "@mantine/core";
+import {IconChevronDown} from "@tabler/icons-react";
+import {t} from "@lingui/macro";
+import classes from "./PeriodSelector.module.scss";
+import {Event} from "../../../types.ts";
+
+export type PeriodPreset =
+ | 'today'
+ | 'last_7_days'
+ | 'last_30_days'
+ | 'last_90_days'
+ | 'year_to_date'
+ | 'event_first_week'
+ | 'event_first_month'
+ | 'event_first_quarter'
+ | 'event_full';
+
+interface PeriodSelectorProps {
+ value: PeriodPreset;
+ onChange: (preset: PeriodPreset) => void;
+ storageKey?: string;
+ className?: string;
+ event?: Event;
+}
+
+const getLabel = (preset: PeriodPreset): string => {
+ switch (preset) {
+ case 'today':
+ return t`Today`;
+ case 'last_7_days':
+ return t`Last 7 days`;
+ case 'last_30_days':
+ return t`Last 30 days`;
+ case 'last_90_days':
+ return t`Last 90 days`;
+ case 'year_to_date':
+ return t`Year to date`;
+ case 'event_first_week':
+ return t`First 7 days`;
+ case 'event_first_month':
+ return t`First 30 days`;
+ case 'event_first_quarter':
+ return t`First 90 days`;
+ case 'event_full':
+ return t`Full event`;
+ }
+};
+
+const ROLLING_PRESETS: PeriodPreset[] = [
+ 'today',
+ 'last_7_days',
+ 'last_30_days',
+ 'last_90_days',
+ 'year_to_date',
+];
+
+const EVENT_PRESETS: PeriodPreset[] = [
+ 'event_first_week',
+ 'event_first_month',
+ 'event_first_quarter',
+ 'event_full',
+];
+
+const isValidPreset = (raw: string | null, allowed: PeriodPreset[]): raw is PeriodPreset => {
+ return raw !== null && (allowed as string[]).includes(raw);
+};
+
+export const PeriodSelector = ({value, onChange, storageKey, className = '', event}: PeriodSelectorProps) => {
+ const [hydrated, setHydrated] = useState(false);
+ const allowed: PeriodPreset[] = event ? [...ROLLING_PRESETS, ...EVENT_PRESETS] : ROLLING_PRESETS;
+
+ useEffect(() => {
+ if (hydrated || !storageKey) {
+ if (!storageKey) {
+ setHydrated(true);
+ }
+ return;
+ }
+ try {
+ const stored = window.localStorage.getItem(storageKey);
+ if (isValidPreset(stored, allowed) && stored !== value) {
+ onChange(stored);
+ }
+ } catch {
+ // ignore
+ }
+ setHydrated(true);
+ }, [hydrated, storageKey, value, onChange, allowed]);
+
+ const handleSelect = (preset: PeriodPreset) => {
+ onChange(preset);
+ if (storageKey && typeof window !== 'undefined') {
+ try {
+ window.localStorage.setItem(storageKey, preset);
+ } catch {
+ // ignore
+ }
+ }
+ };
+
+ return (
+
+
+
+ {getLabel(value)}
+
+
+
+
+ {event && {t`Recent activity`} }
+ {ROLLING_PRESETS.map((preset) => (
+ handleSelect(preset)}
+ className={value === preset ? classes.selected : ''}
+ >
+ {getLabel(preset)}
+
+ ))}
+ {event && (
+ <>
+
+ {t`Event lifetime`}
+ {EVENT_PRESETS.map((preset) => (
+ handleSelect(preset)}
+ className={value === preset ? classes.selected : ''}
+ >
+ {getLabel(preset)}
+
+ ))}
+ >
+ )}
+
+
+ );
+};
+
+export default PeriodSelector;
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/QuestionsTable/index.tsx b/frontend/src/components/common/QuestionsTable/index.tsx
index 28310f5ea9..539039232c 100644
--- a/frontend/src/components/common/QuestionsTable/index.tsx
+++ b/frontend/src/components/common/QuestionsTable/index.tsx
@@ -287,7 +287,7 @@ const LivePreview = ({
{isOpen ? : }
-
+
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 (
{
- return (
-
-
-
{number}
-
{description}
-
-
-
- );
+const sparkFrom = (stats: EventStats | undefined, key: keyof EventStats['daily_stats'][number]): number[] => {
+ if (!stats?.daily_stats) return [];
+ return stats.daily_stats.map((d) => Number(d[key] ?? 0));
};
-export const StatBoxes = () => {
+export const StatBoxes = ({occurrenceId, dateRange, event}: StatBoxesProps = {}) => {
const {eventId} = useParams();
- const eventStatsQuery = useGetEventStats(eventId);
const eventQuery = useGetEvent(eventId);
- const event = eventQuery?.data;
- const {data: eventStats} = eventStatsQuery;
+ const currency = event?.currency ?? eventQuery?.data?.currency;
+ const resolvedEvent = event ?? eventQuery?.data;
+
+ const preset: PeriodPreset = dateRange ?? 'last_30_days';
+ const compareToPrevious = !isEventRelativePreset(preset);
+
+ const currentRange = useMemo(
+ () => periodPresetToDateRange(preset, resolvedEvent),
+ [preset, resolvedEvent],
+ );
+ const previousRange = useMemo(
+ () => (compareToPrevious ? previousPeriodRange(currentRange) : null),
+ [compareToPrevious, currentRange],
+ );
+
+ const currentStatsQuery = useGetEventStats(eventId, {
+ occurrenceId,
+ startDate: currentRange.startDate,
+ endDate: currentRange.endDate,
+ });
+ const previousStatsQuery = useGetEventStats(eventId, {
+ occurrenceId,
+ startDate: previousRange?.startDate,
+ endDate: previousRange?.endDate,
+ enabled: compareToPrevious,
+ });
+
+ const isLoading = currentStatsQuery.isLoading || (compareToPrevious && previousStatsQuery.isLoading);
+ const current = currentStatsQuery.data;
+ const previous = compareToPrevious ? previousStatsQuery.data : undefined;
- const data = [
+ const delta = (key: keyof EventStats) => {
+ if (!compareToPrevious) return null;
+ return computeDelta(current?.[key] as number | undefined, previous?.[key] as number | undefined);
+ };
+
+ const cells = [
{
- number: formatNumber(eventStats?.total_attendees_registered as number),
- description: t`Attendees`,
- icon: ,
- backgroundColor: '#E6677E'
+ key: 'attendees',
+ label: t`Attendees`,
+ value: formatNumber(current?.total_attendees_registered ?? 0),
+ sparkline: sparkFrom(current, 'attendees_registered'),
+ delta: delta('total_attendees_registered'),
},
{
- number: formatNumber(eventStats?.total_products_sold as number),
- description: t`Products sold`,
- icon: ,
- backgroundColor: '#4B7BE5'
+ key: 'products_sold',
+ label: t`Products sold`,
+ value: formatNumber(current?.total_products_sold ?? 0),
+ sparkline: sparkFrom(current, 'products_sold'),
+ delta: delta('total_products_sold'),
},
{
- number: formatCurrency(eventStats?.total_refunded as number || 0, event?.currency),
- description: t`Refunded`,
- icon: ,
- backgroundColor: '#49A6B7'
+ key: 'refunded',
+ label: t`Refunded`,
+ value: formatCurrency(current?.total_refunded ?? 0, currency),
+ sparkline: sparkFrom(current, 'total_refunded'),
+ delta: delta('total_refunded'),
},
{
- number: formatCurrency(eventStats?.total_gross_sales || 0, event?.currency),
- description: t`Gross sales`,
- icon: ,
- backgroundColor: '#7C63E6'
+ key: 'gross_sales',
+ label: t`Gross sales`,
+ value: formatCurrency(current?.total_gross_sales ?? 0, currency),
+ sparkline: sparkFrom(current, 'total_sales_gross'),
+ delta: delta('total_gross_sales'),
},
{
- number: formatNumber(eventStats?.total_views as number),
- description: t`Page views`,
- icon: ,
- backgroundColor: '#63B3A1'
+ key: 'page_views',
+ label: t`Page views`,
+ value: formatNumber(current?.total_views ?? 0),
+ sparkline: undefined as number[] | undefined,
+ delta: delta('total_views'),
},
{
- number: formatNumber(eventStats?.total_orders as number),
- description: t`Completed orders`,
- icon: ,
- backgroundColor: '#E67D49'
- }
+ key: 'orders',
+ label: t`Completed orders`,
+ value: formatNumber(current?.total_orders ?? 0),
+ sparkline: sparkFrom(current, 'orders_created'),
+ delta: delta('total_orders'),
+ },
];
+ const visibleCells = occurrenceId ? cells.filter((cell) => cell.key !== 'page_views') : cells;
+
return (
-
- {data.map((stat) => (
-
+ {visibleCells.map((cell) => (
+
))}
-
+
);
};
diff --git a/frontend/src/components/common/StripeConnectButton/index.tsx b/frontend/src/components/common/StripeConnectButton/index.tsx
deleted file mode 100644
index 77ee3f0c8f..0000000000
--- a/frontend/src/components/common/StripeConnectButton/index.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { Button } from '@mantine/core';
-import { IconBrandStripe } from '@tabler/icons-react';
-import { t } from '@lingui/macro';
-import { useCreateOrGetStripeConnectDetails } from '../../../queries/useCreateOrGetStripeConnectDetails';
-import { useGetAccount } from '../../../queries/useGetAccount';
-import { showSuccess } from '../../../utilites/notifications';
-import { isHiEvents } from '../../../utilites/helpers';
-
-interface StripeConnectButtonProps {
- buttonText?: string;
- buttonIcon?: React.ReactNode;
- variant?: string;
- size?: string;
- fullWidth?: boolean;
- className?: string;
- platform?: string;
-}
-
-export const StripeConnectButton: React.FC = ({
- 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..5eec6652d7 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..052774f88a 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,
@@ -23,7 +31,6 @@ import {
IconEye,
IconFlame,
IconHeartDollar,
- IconInfoCircle,
IconPlus,
IconReceipt,
IconShirt,
@@ -32,6 +39,7 @@ import {
IconTrash,
IconTrashOff,
} from "@tabler/icons-react";
+import {Callout} from "../../common/Callout";
import {useDisclosure} from "@mantine/hooks";
import {NavLink, useParams} from "react-router";
import {useEffect} from "react";
@@ -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 || [];
@@ -224,9 +233,9 @@ export const ProductForm = ({form, product}: ProductFormProps) => {
return (
<>
{Number(product?.quantity_sold) > 0 && (
- } mb={20} color={'blue'}>
+
{t`You cannot change the product type as there are attendees associated with this product.`}
-
+
)}
{
)}
{form.values.type === ProductPriceType.Tiered && (
- }>
+
Tiered products allow you to offer multiple price options for the same product.
This is perfect for early bird products, or offering different price
options for different groups of people.
-
+
)}
{
/>
{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 && (
+
+ 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 .
+
+ )}
-
+
@@ -421,12 +449,12 @@ export const ProductForm = ({form, product}: ProductFormProps) => {
{(form.values.type === ProductPriceType.Free && !!form.values.tax_and_fee_ids?.length) && (
-
+
{t`You have taxes and fees added to a Free Product. Would you like to remove them?`}
{t`Yes, remove them`}
-
+
)}
@@ -450,6 +478,13 @@ export const ProductForm = ({form, product}: ProductFormProps) => {
{t`Sale Period`}
}>
+ {isRecurring && (
+
+ 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..a65977ea5a 100644
--- a/frontend/src/components/layouts/AppLayout/types.ts
+++ b/frontend/src/components/layouts/AppLayout/types.ts
@@ -1,13 +1,14 @@
-import React, { ReactNode } from 'react';
-import { TablerIconsProps } from '@tabler/icons-react';
+import { ReactNode } from 'react';
+import { Icon } from '@tabler/icons-react';
export interface NavItem {
link?: string;
label: string;
- icon?: React.ComponentType;
+ icon?: Icon;
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..9497f6d619 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}
+
+
+ ))}
@@ -109,7 +159,7 @@ const FeaturePanel = () => {
const AuthLayout = () => {
const me = useGetMe();
const clickCountRef = useRef(0);
- const clickTimerRef = useRef
>();
+ const clickTimerRef = useRef | undefined>(undefined);
const handleLogoClick = useCallback(() => {
clickCountRef.current += 1;
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..6deba621f0
--- /dev/null
+++ b/frontend/src/components/layouts/CheckIn/BottomNav.tsx
@@ -0,0 +1,38 @@
+import {ReactNode} from "react";
+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: ReactNode }[] = [
+ {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/CheckoutThemeProvider.tsx b/frontend/src/components/layouts/Checkout/CheckoutThemeProvider.tsx
index 6279a5dd0a..0a59c32acf 100644
--- a/frontend/src/components/layouts/Checkout/CheckoutThemeProvider.tsx
+++ b/frontend/src/components/layouts/Checkout/CheckoutThemeProvider.tsx
@@ -1,4 +1,4 @@
-import {MantineProvider, MantineThemeOverride, CSSVariablesResolver, MantineColorsTuple, ButtonProps, CheckboxProps, MantineTheme} from "@mantine/core";
+import {MantineProvider, MantineThemeOverride, CSSVariablesResolver, MantineColorsTuple, ButtonProps, CheckboxProps, MantineTheme, v8CssVariablesResolver} from "@mantine/core";
import {PropsWithChildren, useMemo} from "react";
import {getContrastColor, hexToRgb} from "../../../utilites/themeUtils";
@@ -132,7 +132,8 @@ function createCheckoutTheme(accentColor: string, mode: 'light' | 'dark'): Manti
* Only accent color is customizable.
*/
function createCSSVariablesResolver(accentColor: string, mode: 'light' | 'dark'): CSSVariablesResolver {
- return () => {
+ return (theme) => {
+ const v8 = v8CssVariablesResolver(theme);
const palette = mode === 'light' ? LIGHT_PALETTE : DARK_PALETTE;
const accentContrast = getContrastColor(accentColor);
const rgb = hexToRgb(accentColor);
@@ -147,27 +148,22 @@ function createCSSVariablesResolver(accentColor: string, mode: 'light' | 'dark')
return {
variables: {
- // Accent colors (customizable)
+ ...v8.variables,
'--checkout-accent': accentColor,
'--checkout-accent-contrast': accentContrast,
'--checkout-accent-soft': accentSoft,
'--checkout-accent-muted': accentMuted,
-
- // Fixed palette colors (not customizable - ensures readability)
'--checkout-background': palette.background,
'--checkout-surface': palette.surface,
'--checkout-text-primary': palette.textPrimary,
'--checkout-text-secondary': palette.textSecondary,
'--checkout-text-tertiary': palette.textTertiary,
'--checkout-border': palette.border,
-
- // Override global --hi-text (set to accent in global.scss) and
- // Mantine's default text color to use fixed palette instead
'--hi-text': palette.textPrimary,
'--mantine-color-text': palette.textPrimary,
},
- light: {},
- dark: {},
+ light: v8.light,
+ dark: v8.dark,
};
};
}
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..a5dc1f4672 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,
@@ -16,7 +17,6 @@ import {
IconSend,
IconSettings,
IconShare,
- IconStar,
IconTicket,
IconTrendingUp,
IconUserQuestion,
@@ -29,6 +29,7 @@ import {t} from "@lingui/macro";
import {useGetEvent} from "../../../queries/useGetEvent";
import {useGetEventSettings} from "../../../queries/useGetEventSettings";
import {useGetEventStats} from "../../../queries/useGetEventStats";
+import {useGeoStatus} from "../../../queries/useGeoStatus.ts";
import Truncate from "../../common/Truncate";
import {BreadcrumbItem, NavItem} from "../AppLayout/types.ts";
import AppLayout from "../AppLayout";
@@ -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();
@@ -59,12 +63,19 @@ const EventLayout = () => {
const statusToggleMutation = useUpdateEventStatus();
const {data: event, isFetched: isEventFetched} = useGetEvent(eventId);
- const {data: eventSettings, isFetched: isEventSettingsFetched} = useGetEventSettings(eventId);
+ const {isFetched: isEventSettingsFetched} = useGetEventSettings(eventId);
const {data: eventStats} = useGetEventStats(eventId);
const {data: me} = useGetMe();
const resendEmailConfirmationMutation = useResendEmailConfirmation();
const [emailConfirmationResent, setEmailConfirmationResent] = useState(false);
+ useGeoStatus();
+
+ 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({
@@ -85,12 +96,6 @@ const EventLayout = () => {
// 1. OVERVIEW
{label: t`Overview`},
- {
- link: 'getting-started',
- label: t`Getting Started`,
- icon: IconStar,
- showWhen: () => !eventSettings?.hide_getting_started_page
- },
{link: 'dashboard', label: t`Dashboard`, icon: IconDashboard},
{
link: 'reports',
@@ -101,6 +106,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 +130,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 +159,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..1a9a8d328b 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, LocationType, OrganizerStatus} from "../../../types.ts";
import {EventNotAvailable} from "./EventNotAvailable";
import {
IconArrowUpRight,
IconCalendar,
IconCalendarOff,
IconCalendarPlus,
+ IconCalendarRepeat,
IconExternalLink,
IconMail,
IconMapPin,
@@ -27,9 +28,9 @@ import {socialMediaConfig} from "../../../constants/socialMediaConfig";
import {
formatAddress,
getGoogleMapsUrl,
- getShortLocationDisplay,
- isAddressSet
+ getShortLocationDisplay
} from "../../../utilites/addressUtilities.ts";
+import {resolveEventLocation} from "../../../utilites/effectiveLocation.ts";
import {StatusToggle} from "../../common/StatusToggle";
import {getConfig} from "../../../utilites/config.ts";
import {computeThemeVariables, validateThemeSettings} from "../../../utilites/themeUtils.ts";
@@ -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);
@@ -134,11 +136,39 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => {
const organizer = event.organizer!;
const organizerSocials = organizer?.settings?.social_media_handles;
const organizerLogo = imageUrl('ORGANIZER_LOGO', organizer?.images);
- const organizerLocation = organizer?.settings?.location_details;
+ const organizerLocation = organizer?.location?.structured_address;
const websiteUrl = organizer?.website;
- const locationDetails = event.settings?.location_details;
- const isOnlineEvent = event.settings?.is_online_event;
- const hasLocation = isAddressSet(locationDetails) && !isOnlineEvent;
+ const occurrences = event.occurrences ?? [];
+ const resolvedList = occurrences.length > 0
+ ? occurrences.map(o => resolveEventLocation(event, o))
+ : [resolveEventLocation(event, null)];
+ const types = new Set();
+ const locationIds = new Set();
+ for (const r of resolvedList) {
+ if (r) {
+ types.add(r.type);
+ if (r.location_id != null) locationIds.add(String(r.location_id));
+ }
+ }
+ const effective = resolvedList[0];
+ const hasMixedModes = types.size > 1;
+ const hasMultipleLocations = locationIds.size > 1;
+ const isOnlineEvent = effective?.type === LocationType.Online;
+ const venueName = effective?.type === LocationType.InPerson
+ ? (effective.location?.name || effective.location?.structured_address?.venue_name || null)
+ : null;
+ const formattedAddress = effective?.type === LocationType.InPerson && effective.location?.structured_address
+ ? formatAddress(effective.location.structured_address)
+ : '';
+ const locationDetails = effective?.type === LocationType.InPerson
+ ? effective.location?.structured_address ?? null
+ : null;
+ const hasLocation = !hasMixedModes
+ && !hasMultipleLocations
+ && effective?.type === LocationType.InPerson
+ && Boolean(locationDetails);
+ const multipleLocationsLabel = hasMultipleLocations ? t`Multiple locations` : null;
+ const mixedModesLabel = hasMixedModes ? t`Online & in-person — see schedule` : null;
const socialLinks = organizerSocials ? Object.entries(organizerSocials)
.filter(([platform, handle]) => handle && socialMediaConfig[platform as keyof typeof socialMediaConfig])
@@ -171,7 +201,15 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => {
const statusBadge = getStatusBadge();
- const mapUrl = event.settings?.maps_url || (locationDetails ? getGoogleMapsUrl(locationDetails) : null);
+ const mapUrl = (() => {
+ if (effective?.type !== LocationType.InPerson || !effective.location) return null;
+ const {latitude, longitude, structured_address} = effective.location;
+ if (latitude != null && longitude != null) {
+ return `https://www.google.com/maps?q=${latitude},${longitude}`;
+ }
+ if (structured_address) return getGoogleMapsUrl(structured_address) || null;
+ return null;
+ })();
return (
<>
@@ -304,8 +342,8 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => {
@@ -332,25 +370,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 */}
@@ -368,19 +436,50 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => {
)}
- {/* Location */}
- {hasLocation && locationDetails && (
+ {/* Mixed modes */}
+ {mixedModesLabel && (
-
- {locationDetails.venue_name}
-
+
{mixedModesLabel}
+
+
+ )}
+
+ {/* Multiple locations */}
+ {multipleLocationsLabel && (
+
+
+
+
+
+
{multipleLocationsLabel}
- {formatAddress(locationDetails)}
+ {t`See schedule`}
+
+
+ )}
+
+ {/* Location */}
+ {hasLocation && (
+