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/.git-blame-ignore-revs b/.git-blame-ignore-revs
new file mode 100644
index 0000000000..f82f5eec0a
--- /dev/null
+++ b/.git-blame-ignore-revs
@@ -0,0 +1,12 @@
+# Revisions listed here are ignored by `git blame` (bulk reformatting, etc.).
+#
+# Enable locally with:
+# git config blame.ignoreRevsFile .git-blame-ignore-revs
+#
+# GitHub applies this file automatically.
+
+# style: apply Pint formatting across backend
+b145fed4d0b0f82bf29f48145672f04bca6b5170
+
+# style: remove explanatory comments across backend and frontend
+00ba8bbb388d05e7d6498a25b049a47661d84861
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/e2e.yml b/.github/workflows/e2e.yml
new file mode 100644
index 0000000000..014e169507
--- /dev/null
+++ b/.github/workflows/e2e.yml
@@ -0,0 +1,118 @@
+name: E2E Tests
+
+on:
+ push:
+ branches: [main, develop]
+ paths:
+ - 'backend/**'
+ - 'frontend/**'
+ - 'e2e/**'
+ - 'docker/e2e/**'
+ - '.github/workflows/e2e.yml'
+ pull_request:
+ types: [opened, synchronize, reopened, labeled]
+ paths:
+ - 'backend/**'
+ - 'frontend/**'
+ - 'e2e/**'
+ - 'docker/e2e/**'
+ - '.github/workflows/e2e.yml'
+ schedule:
+ - cron: '0 3 * * *'
+
+concurrency:
+ group: e2e-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ plan:
+ runs-on: ubuntu-latest
+ outputs:
+ matrix: ${{ steps.set.outputs.matrix }}
+ steps:
+ - id: set
+ env:
+ FULL: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'full-e2e') }}
+ run: |
+ if [ "$FULL" = "true" ]; then
+ echo 'matrix={"include":[{"name":"full-1","pw_args":"--shard=1/2"},{"name":"full-2","pw_args":"--shard=2/2"}]}' >> "$GITHUB_OUTPUT"
+ else
+ echo 'matrix={"include":[{"name":"smoke","pw_args":"--grep @smoke"}]}' >> "$GITHUB_OUTPUT"
+ fi
+
+ e2e:
+ needs: plan
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+
+ strategy:
+ fail-fast: false
+ matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
+
+ env:
+ COMPOSE_FILE: docker/e2e/docker-compose.e2e.yml
+ E2E_IMAGE_TAG: ci
+ STRIPE_PUBLIC_KEY: ${{ secrets.STRIPE_TEST_PUBLIC_KEY }}
+ STRIPE_SECRET_KEY: ${{ secrets.STRIPE_TEST_SECRET_KEY }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build backend image
+ uses: docker/build-push-action@v6
+ with:
+ context: ./backend
+ file: ./backend/Dockerfile
+ load: true
+ tags: hievents-e2e-backend:ci
+ cache-from: type=gha,scope=e2e-backend
+ cache-to: type=gha,scope=e2e-backend,mode=max
+
+ - name: Build frontend image
+ uses: docker/build-push-action@v6
+ with:
+ context: ./frontend
+ file: ./frontend/Dockerfile.ssr
+ load: true
+ tags: hievents-e2e-frontend:ci
+ cache-from: type=gha,scope=e2e-frontend
+ cache-to: type=gha,scope=e2e-frontend,mode=max
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: e2e/package-lock.json
+
+ - name: Cache Playwright browsers
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/ms-playwright
+ key: ${{ runner.os }}-playwright-${{ hashFiles('e2e/package-lock.json') }}
+
+ - name: Run E2E suite
+ run: ./e2e/run-e2e.sh -- ${{ matrix.pw_args }}
+
+ - name: Collect stack logs on failure
+ if: failure()
+ run: docker compose -f "$COMPOSE_FILE" logs --no-color > e2e/compose-logs.txt
+
+ - name: Upload report and artifacts
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-report-${{ matrix.name }}
+ path: |
+ e2e/playwright-report/
+ e2e/test-results/
+ e2e/compose-logs.txt
+ retention-days: 14
+
+ - name: Tear down the stack
+ if: always()
+ run: docker compose -f "$COMPOSE_FILE" down -v
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000000..5473efb0a4
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,108 @@
+name: Backend Tests
+
+on:
+ push:
+ branches: [main, develop, 'v[0-9]+.[0-9]+.[0-9]+-*']
+ 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 ed1c6a0eb4..7141894ff6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -43,8 +43,35 @@ cd docker/development
./start-dev.sh --certs=signed # Signed certs with mkcert
```
+### API smoke-testing (after backend changes)
+
+Unit tests miss wiring bugs — exercise changed endpoints against the dev stack. Base URL `https://localhost:8443/api` (self-signed — `curl -sk`). Endpoints are defined in `backend/routes/api.php`.
+
+```bash
+# Verified SUPERADMIN account + organizer + LIVE single/recurring events + products
+# (paid has waitlist) + promo + affiliate; prints ids and a Bearer token:
+docker compose -f docker-compose.dev.yml exec backend php artisan dev:bootstrap
+
+# Manual token (`token` field → `Authorization: Bearer `):
+curl -sk -X POST https://localhost:8443/api/auth/login -H "Content-Type: application/json" -d '{"email":"","password":""}'
+```
+
+Gotchas:
+- Product upsert requires `product_type` (TICKET/GENERAL), `type` (FREE/PAID/DONATION/TIERED) and `product_category_id`
+- Promo create requires `applicable_product_ids: []` (empty = all products)
+- Public order complete requires `email_confirmation` on the order and on each product entry
+- Publishing needs `accounts.account_verified_at`; `/admin/*` needs role `SUPERADMIN` (`dev:bootstrap` handles both)
+- Emails land in Mailpit at `http://localhost:8025` (`/api/v1/search?query=to:`)
+
## 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 +85,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
@@ -80,6 +108,12 @@ cd docker/development
#### Repository Pattern
- Favour existing repository methods over creating bespoke ones. E.g., use `findFirstWhere(['event_id' => $eventId])` instead of creating `findByEventId`
+- Bespoke repository methods must wrap their query in `runQuery()` — it is the single point that resets `$this->model`/`$this->eagerLoads` after each call
+- `increment()`/`decrement()` are `findOrFail`-based and throw for soft-deleted rows; use the where-based `incrementEach()`/`decrementEach()` when the target row may have been deleted (e.g. a promo code deleted after orders used it)
+
+#### Mail & side effects
+- `BaseMail` is queued **and** `afterCommit()` — a mail sent inside a DB transaction that rolls back is silently discarded. Chain `->beforeCommit()` on the mailable when the send must survive a deliberate rollback (e.g. refund-and-reject webhook paths)
+- Promo usage, `products.sales_volume` and affiliate sales counters increment only when an order **completes** — any decrement must be gated on `isOrderCompleted()` (or equivalent) to stay symmetric
#### Database & Migrations
- **DO** use auto-incrementing integer IDs (`$table->id()`), not UUIDs
@@ -93,6 +127,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
@@ -112,6 +149,21 @@ cd docker/development
- Use Mantine UI components for UI elements
- Prefer SCSS modules over Mantine layout components for layout styling
+#### E2E Tests
+- There is a Playwright E2E suite in `e2e/` (see `e2e/README.md`). It runs the real stack (Laravel + SSR frontend + Postgres + Redis + Mailpit) in Docker.
+- **To test uncommitted changes, run specs against the dev stack** — the hermetic e2e stack bakes source into images and `docker compose up` never rebuilds them. From `e2e/`:
+ ```bash
+ E2E_BASE_URL=https://localhost:8443 MAILPIT_URL=http://localhost:8025 E2E_SAAS_MODE=true npx playwright test
+ ```
+ `E2E_SAAS_MODE=true` is required (the dev stack requires email verification; the fixture only confirms via Mailpit in SaaS mode), a queue worker must be running to deliver the verification emails, and superadmin-dependent specs need a one-time `php artisan dev:bootstrap --email=superadmin@e2e.test --password='SuperAdminPass123!'`. See "Against the running dev stack" in `e2e/README.md`.
+- **When you add or meaningfully change a user-facing flow, add or update an E2E spec for it where practical.** Follow the existing pattern: arrange data via the API/`factory`, drive only the flow under test through the UI with a thin page object, and assert on real page content (the created/edited item appears), not just a URL change. Tag fast, load-bearing checks with `@smoke`.
+- Not everything needs E2E — reserve it for real user journeys (create/edit/complete flows). Pure logic belongs in backend unit/feature tests instead.
+
+#### Test IDs (E2E)
+- Add a `data-testid` to interactive elements the E2E suite needs to drive — primarily **buttons** (open-modal triggers, submit/save), **menu items**, and **custom widgets with no accessible label** (e.g. `CustomSelect`, which takes a `dataTestId` prop that lands on its target and options). This is not required for every element: text inputs with a unique `` are found by role/label instead, so don't add IDs there.
+- Convention: kebab-case `-`, e.g. `promo-code-create-button`, `webhook-submit-button`, `product-edit-menu-item`. For `CustomSelect`, options are auto-derived as `-option-`.
+- Only add IDs for elements a test actually interacts with; don't blanket-annotate new UI.
+
#### Error Handling
- **DON'T** use `showNotification` from `@mantine/notifications`
- **DO** use `showSuccess`, `showError` from `frontend/src/utilites/notifications.tsx`
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/VERSION b/VERSION
index 4beb19d049..68e06170e2 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.11.0-beta
+2.0.0-alpha.1
\ No newline at end of file
diff --git a/backend/.env.example b/backend/.env.example
index d1db304f71..81e46d1d1d 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -66,5 +66,9 @@ AWS_USE_PATH_STYLE_ENDPOINT=true
JWT_SECRET=2hoccgHb9r1fqW1lU16C6khSHVa7O0eai6FxkWK95UtQ0LqNDTO5mq1RzDwcq18I
JWT_ALGO=HS256
+# Google Places address autocomplete. Geo features are disabled until an API key is set.
+GEO_PROVIDER=google
+GOOGLE_MAPS_API_KEY=
+
# Only required for SAAS mode and if your're charging fees
# OPEN_EXCHANGE_RATES_APP_ID=
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
index e69de29bb2..68e06170e2 100644
--- a/backend/VERSION
+++ b/backend/VERSION
@@ -0,0 +1 @@
+2.0.0-alpha.1
\ No newline at end of file
diff --git a/backend/app/Console/Commands/AssignSuperAdminCommand.php b/backend/app/Console/Commands/AssignSuperAdminCommand.php
index 0ee14671b7..73efbfc9dd 100644
--- a/backend/app/Console/Commands/AssignSuperAdminCommand.php
+++ b/backend/app/Console/Commands/AssignSuperAdminCommand.php
@@ -16,11 +16,10 @@ class AssignSuperAdminCommand extends Command
protected $description = 'Assign SUPERADMIN role to a user. WARNING: This grants complete system access.';
public function __construct(
- private readonly UserRepositoryInterface $userRepository,
+ private readonly UserRepositoryInterface $userRepository,
private readonly AccountUserRepositoryInterface $accountUserRepository,
- private readonly LoggerInterface $logger,
- )
- {
+ private readonly LoggerInterface $logger,
+ ) {
parent::__construct();
}
@@ -32,23 +31,26 @@ public function handle(): int
$this->warn('⚠️ SUPERADMIN users have unrestricted access to all accounts and data.');
$this->newLine();
- if (!$this->confirm('Are you sure you want to proceed?', false)) {
+ if (! $this->confirm('Are you sure you want to proceed?', false)) {
$this->info('Operation cancelled.');
+
return self::FAILURE;
}
try {
- $user = $this->userRepository->findById((int)$userId);
+ $user = $this->userRepository->findById((int) $userId);
} catch (Exception $exception) {
- $this->error("Error finding user with ID: $userId" . " Message: " . $exception->getMessage());
+ $this->error("Error finding user with ID: $userId".' Message: '.$exception->getMessage());
+
return self::FAILURE;
}
$this->info("Found user: {$user->getFullName()} ({$user->getEmail()})");
$this->newLine();
- if (!$this->confirm('Confirm assigning SUPERADMIN role to this user?', false)) {
+ if (! $this->confirm('Confirm assigning SUPERADMIN role to this user?', false)) {
$this->info('Operation cancelled.');
+
return self::FAILURE;
}
@@ -58,6 +60,7 @@ public function handle(): int
if ($accountUsers->isEmpty()) {
$this->error('User is not associated with any accounts.');
+
return self::FAILURE;
}
@@ -65,6 +68,7 @@ public function handle(): int
foreach ($accountUsers as $accountUser) {
if ($accountUser->getRole() === Role::SUPERADMIN->name) {
$this->comment("User already has SUPERADMIN role for account ID: {$accountUser->getAccountId()}");
+
continue;
}
diff --git a/backend/app/Console/Commands/BackfillImageMetadataCommand.php b/backend/app/Console/Commands/BackfillImageMetadataCommand.php
index a15a0842a0..a802206485 100644
--- a/backend/app/Console/Commands/BackfillImageMetadataCommand.php
+++ b/backend/app/Console/Commands/BackfillImageMetadataCommand.php
@@ -22,26 +22,28 @@ class BackfillImageMetadataCommand extends Command
protected $description = 'Backfill image metadata (dimensions, average colour, LQIP) for existing images';
private const LQIP_MAX_DIMENSION = 16;
+
private const LQIP_QUALITY = 60;
public function __construct(
private readonly FilesystemManager $filesystemManager,
- private readonly LoggerInterface $logger,
+ private readonly LoggerInterface $logger,
) {
parent::__construct();
}
public function handle(): int
{
- if (!$this->isImagickAvailable()) {
+ if (! $this->isImagickAvailable()) {
$this->error('Imagick extension is not available. Please install it first.');
+
return self::FAILURE;
}
$this->info('Starting image metadata backfill...');
- $limit = (int)$this->option('limit');
- $batchSize = (int)$this->option('batch-size');
+ $limit = (int) $this->option('limit');
+ $batchSize = (int) $this->option('batch-size');
$dryRun = $this->option('dry-run');
$force = $this->option('force');
@@ -52,7 +54,7 @@ public function handle(): int
$query = Image::query()
->whereNull('deleted_at');
- if (!$force) {
+ if (! $force) {
$query->where(function ($q) {
$q->whereNull(ImageDomainObjectAbstract::WIDTH)
->orWhereNull(ImageDomainObjectAbstract::HEIGHT)
@@ -65,6 +67,7 @@ public function handle(): int
if ($totalCount === 0) {
$this->info('No images found that need metadata backfill.');
+
return self::SUCCESS;
}
@@ -139,21 +142,24 @@ private function processImage(Image $image, bool $dryRun): string
$disk = $image->disk;
$path = $image->path;
- if (!$disk || !$path) {
+ if (! $disk || ! $path) {
$this->logger->warning("Image #{$image->id} has no disk or path");
+
return 'skipped';
}
$filesystem = $this->filesystemManager->disk($disk);
- if (!$filesystem->exists($path)) {
+ if (! $filesystem->exists($path)) {
$this->logger->warning("Image file not found for image #{$image->id}: {$path}");
+
return 'skipped';
}
if ($dryRun) {
$this->newLine();
$this->line("Would process: Image #{$image->id}, Path: {$path}");
+
return 'success';
}
@@ -224,10 +230,10 @@ private function generateLqip(Imagick $imagick): string
if ($width > $height) {
$newWidth = self::LQIP_MAX_DIMENSION;
- $newHeight = (int)round($height * (self::LQIP_MAX_DIMENSION / $width));
+ $newHeight = (int) round($height * (self::LQIP_MAX_DIMENSION / $width));
} else {
$newHeight = self::LQIP_MAX_DIMENSION;
- $newWidth = (int)round($width * (self::LQIP_MAX_DIMENSION / $height));
+ $newWidth = (int) round($width * (self::LQIP_MAX_DIMENSION / $height));
}
$newWidth = max(1, $newWidth);
@@ -242,7 +248,7 @@ private function generateLqip(Imagick $imagick): string
$clone->clear();
$clone->destroy();
- return 'data:image/webp;base64,' . base64_encode($blob);
+ return 'data:image/webp;base64,'.base64_encode($blob);
}
private function isImagickAvailable(): bool
diff --git a/backend/app/Console/Commands/BackfillPlatformFeesCommand.php b/backend/app/Console/Commands/BackfillPlatformFeesCommand.php
index d6c448d6ab..7628e61afa 100644
--- a/backend/app/Console/Commands/BackfillPlatformFeesCommand.php
+++ b/backend/app/Console/Commands/BackfillPlatformFeesCommand.php
@@ -27,8 +27,7 @@ public function __construct(
private readonly OrderPaymentPlatformFeeRepositoryInterface $orderPaymentPlatformFeeRepository,
private readonly StripePaymentPlatformFeeExtractionService $platformFeeExtractionService,
private readonly StripeClientFactory $stripeClientFactory,
- )
- {
+ ) {
parent::__construct();
}
@@ -37,7 +36,7 @@ public function handle(): int
$this->info('Starting platform fees backfill...');
$payoutId = $this->option('payout-id');
- $limit = (int)$this->option('limit');
+ $limit = (int) $this->option('limit');
$dryRun = $this->option('dry-run');
if ($dryRun) {
@@ -62,12 +61,12 @@ public function handle(): int
/** @var StripePaymentDomainObject $payment */
// Must have charge_id and payout_id
- if (!$payment->getChargeId() || !$payment->getPayoutId()) {
+ if (! $payment->getChargeId() || ! $payment->getPayoutId()) {
return false;
}
$order = $payment->getOrder();
- if (!$order) {
+ if (! $order) {
return false;
}
@@ -81,6 +80,7 @@ public function handle(): int
if ($stripePayments->isEmpty()) {
$this->info('No stripe payments found that need platform fee backfill.');
+
return self::SUCCESS;
}
@@ -97,16 +97,17 @@ public function handle(): int
/** @var StripePaymentDomainObject $stripePayment */
$order = $stripePayment->getOrder();
- if (!$order) {
+ if (! $order) {
$this->newLine();
$this->warn("Order not found for stripe_payment ID: {$stripePayment->getId()}");
$skippedCount++;
$progressBar->advance();
+
continue;
}
try {
- if (!$dryRun) {
+ if (! $dryRun) {
// Fetch charge from Stripe with expanded balance_transaction
$stripeClient = $this->stripeClientFactory->createForPlatform(
$stripePayment->getStripePlatformEnum()
diff --git a/backend/app/Console/Commands/BootstrapDevDataCommand.php b/backend/app/Console/Commands/BootstrapDevDataCommand.php
new file mode 100644
index 0000000000..2e68bb8286
--- /dev/null
+++ b/backend/app/Console/Commands/BootstrapDevDataCommand.php
@@ -0,0 +1,211 @@
+@dev.test)}
+ {--password=BootstrapPass123! : Account password}
+ {--force : Skip the non-production environment check}';
+
+ protected $description = 'Create a verified superadmin account with organizer, single + recurring events, products, promo code and affiliate, then print the ids and a ready-to-use Bearer token for API testing.';
+
+ public function handle(
+ CreateAccountHandler $createAccountHandler,
+ CreateOrganizerHandler $createOrganizerHandler,
+ CreateEventHandler $createEventHandler,
+ CreateProductHandler $createProductHandler,
+ CreatePromoCodeHandler $createPromoCodeHandler,
+ CreateAffiliateHandler $createAffiliateHandler,
+ GenerateOccurrencesFromRuleHandler $generateOccurrencesHandler,
+ LoginService $loginService,
+ ): int {
+ if (app()->environment('production') && ! $this->option('force')) {
+ $this->error('Refusing to run in production. Pass --force to override.');
+
+ return self::FAILURE;
+ }
+
+ $email = $this->option('email') ?: 'agent+'.now()->format('YmdHis').'@dev.test';
+ $password = $this->option('password');
+
+ $account = $createAccountHandler->handle(new CreateAccountDTO(
+ email: $email,
+ password: $password,
+ first_name: 'Agent',
+ locale: 'en',
+ last_name: 'Bootstrap',
+ timezone: 'UTC',
+ currency_code: 'USD',
+ ));
+
+ $user = User::where('email', $email)->firstOrFail();
+
+ DB::table('accounts')->where('id', $account->getId())->update(['account_verified_at' => now()]);
+ DB::table('account_users')->where('user_id', $user->id)->update(['role' => 'SUPERADMIN']);
+
+ auth()->login($user);
+
+ $organizer = $createOrganizerHandler->handle(new CreateOrganizerDTO(
+ name: 'Bootstrap Organizer',
+ email: $email,
+ account_id: $account->getId(),
+ timezone: 'UTC',
+ currency: 'USD',
+ ));
+
+ $singleEvent = $createEventHandler->handle(new CreateEventDTO(
+ title: 'Bootstrap Single Event',
+ organizer_id: $organizer->getId(),
+ account_id: $account->getId(),
+ user_id: $user->id,
+ start_date: now()->addDays(30)->setTime(19, 0)->toDateTimeString(),
+ end_date: now()->addDays(30)->setTime(22, 0)->toDateTimeString(),
+ timezone: 'UTC',
+ currency: 'USD',
+ category: EventCategory::MUSIC,
+ status: EventStatus::LIVE->name,
+ ));
+
+ $recurringEvent = $createEventHandler->handle(new CreateEventDTO(
+ title: 'Bootstrap Recurring Event',
+ organizer_id: $organizer->getId(),
+ account_id: $account->getId(),
+ user_id: $user->id,
+ timezone: 'UTC',
+ currency: 'USD',
+ category: EventCategory::MUSIC,
+ status: EventStatus::LIVE->name,
+ type: EventType::RECURRING,
+ ));
+
+ $occurrences = $generateOccurrencesHandler->handle(new GenerateOccurrencesDTO(
+ event_id: $recurringEvent->getId(),
+ recurrence_rule: [
+ 'range' => ['type' => 'count', 'count' => 4, 'start' => now()->addDays(7)->toDateString()],
+ 'interval' => 1,
+ 'frequency' => 'weekly',
+ 'days_of_week' => ['monday'],
+ 'times_of_day' => ['19:00'],
+ ],
+ ));
+
+ $freeProduct = $this->createProduct($createProductHandler, $account->getId(), $singleEvent, 'Free Ticket', ProductPriceType::FREE, 0.0);
+ $paidProduct = $this->createProduct($createProductHandler, $account->getId(), $singleEvent, 'Paid Ticket', ProductPriceType::PAID, 25.0, waitlistEnabled: true);
+ $recurringProduct = $this->createProduct($createProductHandler, $account->getId(), $recurringEvent, 'Recurring Free Ticket', ProductPriceType::FREE, 0.0);
+
+ $promoCode = $createPromoCodeHandler->handle($singleEvent->getId(), new UpsertPromoCodeDTO(
+ code: 'bootstrap10',
+ event_id: $singleEvent->getId(),
+ applicable_product_ids: [],
+ discount_type: PromoCodeDiscountTypeEnum::PERCENTAGE,
+ discount: 10.0,
+ expiry_date: null,
+ max_allowed_usages: null,
+ discount_applies_to: PromoCodeDiscountAppliesToEnum::EACH_PRODUCT,
+ ));
+
+ $affiliate = $createAffiliateHandler->handle($singleEvent->getId(), $account->getId(), new UpsertAffiliateDTO(
+ name: 'Bootstrap Affiliate',
+ code: 'BOOTAFF',
+ email: $email,
+ ));
+
+ $token = null;
+ try {
+ $token = $loginService->authenticate($email, $password, null)->token;
+ } catch (Throwable $e) {
+ $this->warn('Token mint failed ('.$e->getMessage().') — use the login curl below.');
+ }
+
+ $singleOccurrenceId = DB::table('event_occurrences')->where('event_id', $singleEvent->getId())->value('id');
+
+ $this->table(['Key', 'Value'], [
+ ['email', $email],
+ ['password', $password],
+ ['account_id', $account->getId()],
+ ['user_id', $user->id],
+ ['organizer_id', $organizer->getId()],
+ ['single_event_id (LIVE)', $singleEvent->getId()],
+ ['single_occurrence_id', $singleOccurrenceId],
+ ['free_product_id / price_id', $freeProduct['product_id'].' / '.$freeProduct['price_id']],
+ ['paid_product_id / price_id (waitlist on)', $paidProduct['product_id'].' / '.$paidProduct['price_id']],
+ ['recurring_event_id (LIVE)', $recurringEvent->getId()],
+ ['recurring_occurrence_ids', $occurrences->map(fn ($o) => $o->getId())->implode(', ')],
+ ['recurring_product_id / price_id', $recurringProduct['product_id'].' / '.$recurringProduct['price_id']],
+ ['promo_code', $promoCode->getCode()],
+ ['affiliate_code', $affiliate->getCode()],
+ ]);
+
+ if ($token) {
+ $this->info('Bearer token:');
+ $this->line($token);
+ }
+
+ $this->newLine();
+ $this->info('Examples:');
+ $this->line('TOKEN=$(curl -sk -X POST https://localhost:8443/api/auth/login -H "Content-Type: application/json" -d \'{"email":"'.$email.'","password":"'.$password.'"}\' | python3 -c "import sys,json; print(json.load(sys.stdin)[\'token\'])")');
+ $this->line('curl -sk https://localhost:8443/api/events/'.$singleEvent->getId().' -H "Authorization: Bearer $TOKEN"');
+ $this->line('curl -sk https://localhost:8443/api/public/organizers/'.$organizer->getId().'/events?eventsStatus=upcoming');
+
+ return self::SUCCESS;
+ }
+
+ private function createProduct(
+ CreateProductHandler $handler,
+ int $accountId,
+ EventDomainObject $event,
+ string $title,
+ ProductPriceType $priceType,
+ float $price,
+ bool $waitlistEnabled = false,
+ ): array {
+ $categoryId = DB::table('product_categories')->where('event_id', $event->getId())->value('id');
+
+ $product = $handler->handle(new UpsertProductDTO(
+ account_id: $accountId,
+ event_id: $event->getId(),
+ product_category_id: $categoryId,
+ title: $title,
+ type: $priceType,
+ product_type: ProductType::TICKET,
+ prices: collect([new ProductPriceDTO(price: $price)]),
+ waitlist_enabled: $waitlistEnabled,
+ ));
+
+ return [
+ 'product_id' => $product->getId(),
+ 'price_id' => DB::table('product_prices')->where('product_id', $product->getId())->value('id'),
+ ];
+ }
+}
diff --git a/backend/app/Console/Commands/SeedDevDashboardDataCommand.php b/backend/app/Console/Commands/SeedDevDashboardDataCommand.php
new file mode 100644
index 0000000000..8093d51019
--- /dev/null
+++ b/backend/app/Console/Commands/SeedDevDashboardDataCommand.php
@@ -0,0 +1,268 @@
+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/AddressDTO.php b/backend/app/DataTransferObjects/AddressDTO.php
index 18442ba172..7ba8b996e0 100644
--- a/backend/app/DataTransferObjects/AddressDTO.php
+++ b/backend/app/DataTransferObjects/AddressDTO.php
@@ -1,10 +1,10 @@
getName();
- if (!array_key_exists($propertyName, $data)) {
+ if (! array_key_exists($propertyName, $data)) {
continue;
}
@@ -84,7 +74,7 @@ private static function hydrateObjectsFromProperties(array $data): array
if (is_array($data[$propertyName])) {
$data[$propertyName] = collect($data[$propertyName])
- ->map(fn($item) => $classType::fromArray((array)$item));
+ ->map(fn ($item) => $classType::fromArray((array) $item));
}
}
}
@@ -111,7 +101,7 @@ private static function handleDtoProperties(ReflectionClass $reflection, array $
foreach ($reflection->getProperties(ReflectionProperty::IS_PUBLIC) as $property) {
$propertyName = $property->getName();
- if (!isset($data[$propertyName]) || !is_array($data[$propertyName])) {
+ if (! isset($data[$propertyName]) || ! is_array($data[$propertyName])) {
continue;
}
@@ -140,26 +130,26 @@ private static function handleEnumProperties(ReflectionClass $reflection, array
$constructorParams = $constructor ? $constructor->getParameters() : [];
collect($reflection->getProperties())
- ->each(function (ReflectionProperty $property) use ($constructorParams, $reflection, &$data) {
+ ->each(function (ReflectionProperty $property) use ($constructorParams, &$data) {
$type = $property->getType();
$enumName = method_exists($type, 'getName') ? $type?->getName() : null;
$propertyName = $property->getName();
- if (!$enumName) {
+ if (! $enumName) {
return;
}
$isEnum = enum_exists($property->getType()?->getName()) && method_exists($enumName, 'fromName');
- if (!$isEnum) {
+ if (! $isEnum) {
return;
}
- $isMissing = !isset($data[$propertyName]);
+ $isMissing = ! isset($data[$propertyName]);
$constructorParam = collect($constructorParams)->firstWhere('name', $propertyName);
$hasDefaultValue = $constructorParam && $constructorParam->isDefaultValueAvailable();
- if ($isMissing && !$hasDefaultValue) {
+ if ($isMissing && ! $hasDefaultValue) {
throw new RuntimeException(
sprintf('Missing property [%s] in class [%s]', $property->getName(), static::class)
);
@@ -167,6 +157,7 @@ private static function handleEnumProperties(ReflectionClass $reflection, array
if ($isMissing && $hasDefaultValue) {
$data[$propertyName] = $constructorParam->getDefaultValue();
+
return;
}
@@ -176,6 +167,7 @@ private static function handleEnumProperties(ReflectionClass $reflection, array
if (($data[$property->getName()] === null || $data[$property->getName()] === '') && $type->allowsNull()) {
$data[$property->getName()] = null;
+
return;
}
diff --git a/backend/app/DataTransferObjects/BaseDataObject.php b/backend/app/DataTransferObjects/BaseDataObject.php
index 974762c200..47c9f81f2a 100644
--- a/backend/app/DataTransferObjects/BaseDataObject.php
+++ b/backend/app/DataTransferObjects/BaseDataObject.php
@@ -13,7 +13,7 @@ abstract class BaseDataObject extends Data
*/
public function wasProvided(string $propertyName): bool
{
- return property_exists($this, $propertyName) && !($this->{$propertyName} instanceof Optional);
+ return property_exists($this, $propertyName) && ! ($this->{$propertyName} instanceof Optional);
}
/**
diff --git a/backend/app/DataTransferObjects/ErrorBagDTO.php b/backend/app/DataTransferObjects/ErrorBagDTO.php
index 0dfac120a9..39fc8c546f 100644
--- a/backend/app/DataTransferObjects/ErrorBagDTO.php
+++ b/backend/app/DataTransferObjects/ErrorBagDTO.php
@@ -9,9 +9,7 @@ public function __construct(
* @var array
*/
public array $errors = [],
- )
- {
- }
+ ) {}
public function addError(string $key, string $message): void
{
diff --git a/backend/app/DataTransferObjects/UpdateAdminAccountVatSettingDTO.php b/backend/app/DataTransferObjects/UpdateAdminOrganizerVatSettingDTO.php
similarity index 77%
rename from backend/app/DataTransferObjects/UpdateAdminAccountVatSettingDTO.php
rename to backend/app/DataTransferObjects/UpdateAdminOrganizerVatSettingDTO.php
index e7103610b0..65bd5252d8 100644
--- a/backend/app/DataTransferObjects/UpdateAdminAccountVatSettingDTO.php
+++ b/backend/app/DataTransferObjects/UpdateAdminOrganizerVatSettingDTO.php
@@ -2,17 +2,15 @@
namespace HiEvents\DataTransferObjects;
-class UpdateAdminAccountVatSettingDTO extends BaseDataObject
+class UpdateAdminOrganizerVatSettingDTO extends BaseDataObject
{
public function __construct(
- public readonly int $accountId,
+ public readonly int $organizerId,
public readonly bool $vatRegistered,
public readonly ?string $vatNumber = 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 53%
rename from backend/app/DataTransferObjects/UpdateAccountConfigurationDTO.php
rename to backend/app/DataTransferObjects/UpdateOrganizerConfigurationDTO.php
index 490085c8fa..1f9abe98c2 100644
--- a/backend/app/DataTransferObjects/UpdateAccountConfigurationDTO.php
+++ b/backend/app/DataTransferObjects/UpdateOrganizerConfigurationDTO.php
@@ -2,12 +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/AbstractDomainObject.php b/backend/app/DomainObjects/AbstractDomainObject.php
index 9f36e80f75..c13ff9bfd9 100644
--- a/backend/app/DomainObjects/AbstractDomainObject.php
+++ b/backend/app/DomainObjects/AbstractDomainObject.php
@@ -29,7 +29,7 @@ public static function hydrateFromModel(Model $model): DomainObjectInterface
public static function hydrateFromArray(array $array): DomainObjectInterface
{
- $domainObject = new static();
+ $domainObject = new static;
foreach ($array as $key => $value) {
$domainObject->{$key} = $value;
}
diff --git a/backend/app/DomainObjects/AccountAttributionDomainObject.php b/backend/app/DomainObjects/AccountAttributionDomainObject.php
index dd4abfca93..052c49f973 100644
--- a/backend/app/DomainObjects/AccountAttributionDomainObject.php
+++ b/backend/app/DomainObjects/AccountAttributionDomainObject.php
@@ -2,6 +2,4 @@
namespace HiEvents\DomainObjects;
-class AccountAttributionDomainObject extends Generated\AccountAttributionDomainObjectAbstract
-{
-}
+class AccountAttributionDomainObject extends Generated\AccountAttributionDomainObjectAbstract {}
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/AccountMessagingTierDomainObject.php b/backend/app/DomainObjects/AccountMessagingTierDomainObject.php
index 9f01a2584a..6b26f105a9 100644
--- a/backend/app/DomainObjects/AccountMessagingTierDomainObject.php
+++ b/backend/app/DomainObjects/AccountMessagingTierDomainObject.php
@@ -2,6 +2,4 @@
namespace HiEvents\DomainObjects;
-class AccountMessagingTierDomainObject extends Generated\AccountMessagingTierDomainObjectAbstract
-{
-}
+class AccountMessagingTierDomainObject extends Generated\AccountMessagingTierDomainObjectAbstract {}
diff --git a/backend/app/DomainObjects/AccountStripePlatformDomainObject.php b/backend/app/DomainObjects/AccountStripePlatformDomainObject.php
index 20b0a0ecf8..51e1f160ce 100644
--- a/backend/app/DomainObjects/AccountStripePlatformDomainObject.php
+++ b/backend/app/DomainObjects/AccountStripePlatformDomainObject.php
@@ -2,6 +2,4 @@
namespace HiEvents\DomainObjects;
-class AccountStripePlatformDomainObject extends Generated\AccountStripePlatformDomainObjectAbstract
-{
-}
+class AccountStripePlatformDomainObject extends Generated\AccountStripePlatformDomainObjectAbstract {}
diff --git a/backend/app/DomainObjects/AccountVatSettingDomainObject.php b/backend/app/DomainObjects/AccountVatSettingDomainObject.php
index 9b36d5184c..d56971a9ba 100644
--- a/backend/app/DomainObjects/AccountVatSettingDomainObject.php
+++ b/backend/app/DomainObjects/AccountVatSettingDomainObject.php
@@ -2,6 +2,4 @@
namespace HiEvents\DomainObjects;
-class AccountVatSettingDomainObject extends Generated\AccountVatSettingDomainObjectAbstract
-{
-}
+class AccountVatSettingDomainObject extends Generated\AccountVatSettingDomainObjectAbstract {}
diff --git a/backend/app/DomainObjects/ApplicationFeeDomainObject.php b/backend/app/DomainObjects/ApplicationFeeDomainObject.php
index 3c374434c4..0ac16746a4 100644
--- a/backend/app/DomainObjects/ApplicationFeeDomainObject.php
+++ b/backend/app/DomainObjects/ApplicationFeeDomainObject.php
@@ -2,6 +2,4 @@
namespace HiEvents\DomainObjects;
-class ApplicationFeeDomainObject extends Generated\ApplicationFeeDomainObjectAbstract
-{
-}
+class ApplicationFeeDomainObject extends Generated\ApplicationFeeDomainObjectAbstract {}
diff --git a/backend/app/DomainObjects/AttendeeCheckInDomainObject.php b/backend/app/DomainObjects/AttendeeCheckInDomainObject.php
index f85b44f1ef..68bdcd865b 100644
--- a/backend/app/DomainObjects/AttendeeCheckInDomainObject.php
+++ b/backend/app/DomainObjects/AttendeeCheckInDomainObject.php
@@ -16,12 +16,14 @@ public function getAttendee(): ?AttendeeDomainObject
public function setAttendee(AttendeeDomainObject $attendee): self
{
$this->attendee = $attendee;
+
return $this;
}
public function setCheckInList(?CheckInListDomainObject $checkInList): AttendeeCheckInDomainObject
{
$this->checkInList = $checkInList;
+
return $this;
}
diff --git a/backend/app/DomainObjects/AttendeeDomainObject.php b/backend/app/DomainObjects/AttendeeDomainObject.php
index e6396e0f20..8c69522427 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;
@@ -32,7 +34,7 @@ public static function getAllowedSorts(): AllowedSorts
{
return new AllowedSorts(
[
- self::TICKET_NAME_SORT_KEY => [
+ self::TICKET_NAME_SORT_KEY => [
'asc' => __('Ticket Name A-Z'),
'desc' => __('Ticket Name Z-A'),
],
@@ -71,6 +73,7 @@ public static function getAllowedFilterFields(): array
self::STATUS,
self::PRODUCT_ID,
self::PRODUCT_PRICE_ID,
+ self::EVENT_OCCURRENCE_ID,
];
}
@@ -86,7 +89,7 @@ public function setOrder(?OrderDomainObject $order): void
public function getFullName(): string
{
- return $this->first_name . ' ' . $this->last_name;
+ return $this->first_name.' '.$this->last_name;
}
public function getProduct(): ?ProductDomainObject
@@ -104,6 +107,7 @@ public function setProduct(?ProductDomainObject $product): self
public function setQuestionAndAnswerViews(?Collection $questionAndAnswerViews): AttendeeDomainObject
{
$this->questionAndAnswerViews = $questionAndAnswerViews;
+
return $this;
}
@@ -115,13 +119,12 @@ public function getQuestionAndAnswerViews(): ?Collection
public function setCheckIn(?AttendeeCheckInDomainObject $checkIn): AttendeeDomainObject
{
$this->checkIn = $checkIn;
+
return $this;
}
/**
* Only use in the context when a single check-in is expected (e.g., when loading a list of attendees for a specific check-in list).
- *
- * @return AttendeeCheckInDomainObject|null
*/
public function getCheckIn(): ?AttendeeCheckInDomainObject
{
@@ -131,6 +134,7 @@ public function getCheckIn(): ?AttendeeCheckInDomainObject
public function setCheckIns(?Collection $checkIns): AttendeeDomainObject
{
$this->checkIns = $checkIns;
+
return $this;
}
@@ -138,4 +142,16 @@ 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/AttributeDomainObject.php b/backend/app/DomainObjects/AttributeDomainObject.php
index d5787061ea..c2469e0573 100644
--- a/backend/app/DomainObjects/AttributeDomainObject.php
+++ b/backend/app/DomainObjects/AttributeDomainObject.php
@@ -2,6 +2,4 @@
namespace HiEvents\DomainObjects;
-class AttributeDomainObject extends Generated\AttributeDomainObjectAbstract
-{
-}
+class AttributeDomainObject extends Generated\AttributeDomainObjectAbstract {}
diff --git a/backend/app/DomainObjects/CapacityAssignmentDomainObject.php b/backend/app/DomainObjects/CapacityAssignmentDomainObject.php
index 642fadae10..6dcd47d574 100644
--- a/backend/app/DomainObjects/CapacityAssignmentDomainObject.php
+++ b/backend/app/DomainObjects/CapacityAssignmentDomainObject.php
@@ -51,7 +51,7 @@ public static function getAllowedSorts(): AllowedSorts
public function getPercentageUsed(): float
{
- if (!$this->getCapacity()) {
+ if (! $this->getCapacity()) {
return 0;
}
diff --git a/backend/app/DomainObjects/CheckInListDomainObject.php b/backend/app/DomainObjects/CheckInListDomainObject.php
index ae55f3bbcf..70cfccaf21 100644
--- a/backend/app/DomainObjects/CheckInListDomainObject.php
+++ b/backend/app/DomainObjects/CheckInListDomainObject.php
@@ -13,6 +13,8 @@ class CheckInListDomainObject extends Generated\CheckInListDomainObjectAbstract
private ?EventDomainObject $event = null;
+ private ?EventOccurrenceDomainObject $eventOccurrence = null;
+
private ?int $checkedInCount = null;
private ?int $totalAttendeesCount = null;
@@ -77,6 +79,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 @@
- $theme->getThemeData(),
+ static fn (self $theme) => $theme->getThemeData(),
self::cases()
);
}
diff --git a/backend/app/DomainObjects/Enums/EmailTemplateEngine.php b/backend/app/DomainObjects/Enums/EmailTemplateEngine.php
index c6d91d8b57..fef50a7ad0 100644
--- a/backend/app/DomainObjects/Enums/EmailTemplateEngine.php
+++ b/backend/app/DomainObjects/Enums/EmailTemplateEngine.php
@@ -16,4 +16,4 @@ public function label(): string
self::BLADE => __('Blade'),
};
}
-}
\ No newline at end of file
+}
diff --git a/backend/app/DomainObjects/Enums/EmailTemplateType.php b/backend/app/DomainObjects/Enums/EmailTemplateType.php
index 8b11aac906..d8b5590843 100644
--- a/backend/app/DomainObjects/Enums/EmailTemplateType.php
+++ b/backend/app/DomainObjects/Enums/EmailTemplateType.php
@@ -8,12 +8,14 @@ enum EmailTemplateType: string
case ORDER_CONFIRMATION = 'order_confirmation';
case ATTENDEE_TICKET = 'attendee_ticket';
+ case OCCURRENCE_CANCELLATION = 'occurrence_cancellation';
public function label(): string
{
return match ($this) {
self::ORDER_CONFIRMATION => __('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'),
};
}
-}
\ No newline at end of file
+
+ public function ctaUrlToken(): string
+ {
+ return match ($this) {
+ self::ORDER_CONFIRMATION => 'order.url',
+ self::ATTENDEE_TICKET => 'ticket.url',
+ self::OCCURRENCE_CANCELLATION => 'event.url',
+ };
+ }
+}
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 @@
+name);
+ throw new InvalidArgumentException('Invalid image type: '.$this->name);
}
}
diff --git a/backend/app/DomainObjects/Enums/LocationType.php b/backend/app/DomainObjects/Enums/LocationType.php
new file mode 100644
index 0000000000..8001c30d17
--- /dev/null
+++ b/backend/app/DomainObjects/Enums/LocationType.php
@@ -0,0 +1,11 @@
+value;
}
-
+
public static function getAllValues(): array
{
return array_column(self::cases(), 'value');
}
-}
\ No newline at end of file
+}
diff --git a/backend/app/DomainObjects/EventDailyStatisticDomainObject.php b/backend/app/DomainObjects/EventDailyStatisticDomainObject.php
index e6b9be8d90..ab442f9b73 100644
--- a/backend/app/DomainObjects/EventDailyStatisticDomainObject.php
+++ b/backend/app/DomainObjects/EventDailyStatisticDomainObject.php
@@ -2,6 +2,4 @@
namespace HiEvents\DomainObjects;
-class EventDailyStatisticDomainObject extends Generated\EventDailyStatisticDomainObjectAbstract
-{
-}
+class EventDailyStatisticDomainObject extends Generated\EventDailyStatisticDomainObjectAbstract {}
diff --git a/backend/app/DomainObjects/EventDomainObject.php b/backend/app/DomainObjects/EventDomainObject.php
index 8f6c6640c7..10bc05188a 100644
--- a/backend/app/DomainObjects/EventDomainObject.php
+++ b/backend/app/DomainObjects/EventDomainObject.php
@@ -2,17 +2,18 @@
namespace HiEvents\DomainObjects;
-use Carbon\Carbon;
+use HiEvents\DomainObjects\Enums\EventType;
use HiEvents\DomainObjects\Interfaces\IsFilterable;
use HiEvents\DomainObjects\Interfaces\IsSortable;
use HiEvents\DomainObjects\SortingAndFiltering\AllowedSorts;
use HiEvents\DomainObjects\Status\EventLifecycleStatus;
+use HiEvents\DomainObjects\Status\EventOccurrenceStatus;
use HiEvents\Helper\StringHelper;
use HiEvents\Helper\Url;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
-class EventDomainObject extends Generated\EventDomainObjectAbstract implements IsSortable, IsFilterable
+class EventDomainObject extends Generated\EventDomainObjectAbstract implements IsFilterable, IsSortable
{
private ?Collection $products = null;
@@ -32,6 +33,8 @@ class EventDomainObject extends Generated\EventDomainObjectAbstract implements I
private ?Collection $affiliates = null;
+ private ?Collection $eventOccurrences = null;
+
private ?EventSettingDomainObject $settings = null;
private ?OrganizerDomainObject $organizer = null;
@@ -40,12 +43,20 @@ class EventDomainObject extends Generated\EventDomainObjectAbstract implements I
private ?AccountDomainObject $account = null;
+ private ?EventLocationDomainObject $eventLocation = null;
+
+ private bool $upcomingOccurrencesSoldOut = false;
+
+ private ?string $nextOccurrenceStartDate = null;
+
+ private ?string $lastOccurrenceStartDate = null;
+
+ private ?string $occurrencesMonth = null;
+
public static function getAllowedFilterFields(): array
{
return [
self::TITLE,
- self::START_DATE,
- self::END_DATE,
self::CREATED_AT,
self::UPDATED_AT,
self::STATUS,
@@ -57,14 +68,6 @@ public static function getAllowedSorts(): AllowedSorts
{
return new AllowedSorts(
[
- self::START_DATE => [
- '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 +82,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 +105,7 @@ public function getProducts(): ?Collection
public function setQuestions(?Collection $questions): EventDomainObject
{
$this->questions = $questions;
+
return $this;
}
@@ -118,6 +122,7 @@ public function getSlug(): string
public function setImages(?Collection $images): EventDomainObject
{
$this->images = $images;
+
return $this;
}
@@ -134,6 +139,7 @@ public function getEventSettings(): ?EventSettingDomainObject
public function setEventSettings(?EventSettingDomainObject $settings): EventDomainObject
{
$this->settings = $settings;
+
return $this;
}
@@ -157,6 +163,7 @@ public function getAccount(): ?AccountDomainObject
public function setAccount(?AccountDomainObject $account): self
{
$this->account = $account;
+
return $this;
}
@@ -178,58 +185,147 @@ public function getDescriptionPreview(): string
return StringHelper::previewFromHtml($this->getDescription());
}
- public function isEventInPast(): bool
+ public function setEventOccurrences(?Collection $eventOccurrences): self
{
- if ($this->getEndDate() === null) {
- return false;
- }
- $endDate = Carbon::parse($this->getEndDate());
- $endDate->setTimezone($this->getTimezone());
+ $this->eventOccurrences = $eventOccurrences;
- return $endDate->isPast();
+ return $this;
}
- public function isEventInFuture(): bool
+ public function getEventOccurrences(): ?Collection
{
- if ($this->getStartDate() === null) {
- return false;
+ return $this->eventOccurrences;
+ }
+
+ public function setUpcomingOccurrencesSoldOut(bool $upcomingOccurrencesSoldOut): self
+ {
+ $this->upcomingOccurrencesSoldOut = $upcomingOccurrencesSoldOut;
+
+ return $this;
+ }
+
+ public function getUpcomingOccurrencesSoldOut(): bool
+ {
+ return $this->upcomingOccurrencesSoldOut;
+ }
+
+ public function getStartDate(): ?string
+ {
+ if ($this->eventOccurrences === null || $this->eventOccurrences->isEmpty()) {
+ return null;
}
- $startDate = Carbon::parse($this->getStartDate());
- $startDate->setTimezone($this->getTimezone());
- return $startDate->isFuture();
+ return $this->eventOccurrences->min(
+ fn (EventOccurrenceDomainObject $o) => $o->getStartDate()
+ );
}
- public function isEventOngoing(): bool
+ public function getEndDate(): ?string
{
- $startDate = Carbon::parse($this->getStartDate());
- $startDate->setTimezone($this->getTimezone());
+ if ($this->eventOccurrences === null || $this->eventOccurrences->isEmpty()) {
+ return null;
+ }
- if ($this->getEndDate() === null) {
- return $startDate->isPast();
+ $withEndDates = $this->eventOccurrences->filter(
+ fn (EventOccurrenceDomainObject $o) => $o->getEndDate() !== null
+ );
+
+ if ($withEndDates->isEmpty()) {
+ return $this->eventOccurrences->max(
+ fn (EventOccurrenceDomainObject $o) => $o->getStartDate()
+ );
}
- $endDate = Carbon::parse($this->getEndDate());
- $endDate->setTimezone($this->getTimezone());
+ return $withEndDates->max(
+ fn (EventOccurrenceDomainObject $o) => $o->getEndDate()
+ );
+ }
+
+ public function setNextOccurrenceStartDate(?string $nextOccurrenceStartDate): self
+ {
+ $this->nextOccurrenceStartDate = $nextOccurrenceStartDate;
- return $startDate->isPast() && $endDate->isFuture();
+ return $this;
}
- public function getLifecycleStatus(): string
+ public function setLastOccurrenceStartDate(?string $lastOccurrenceStartDate): self
+ {
+ $this->lastOccurrenceStartDate = $lastOccurrenceStartDate;
+
+ return $this;
+ }
+
+ public function getLastOccurrenceStartDate(): ?string
+ {
+ return $this->lastOccurrenceStartDate;
+ }
+
+ public function setOccurrencesMonth(?string $occurrencesMonth): self
{
- if ($this->isEventInPast()) {
- return EventLifecycleStatus::ENDED->name;
+ $this->occurrencesMonth = $occurrencesMonth;
+
+ return $this;
+ }
+
+ public function getOccurrencesMonth(): ?string
+ {
+ return $this->occurrencesMonth;
+ }
+
+ public function getNextOccurrenceStartDate(): ?string
+ {
+ if ($this->nextOccurrenceStartDate !== null) {
+ return $this->nextOccurrenceStartDate;
}
- if ($this->isEventInFuture()) {
- return EventLifecycleStatus::UPCOMING->name;
+ if ($this->eventOccurrences === null || $this->eventOccurrences->isEmpty()) {
+ return null;
}
+ $nextOccurrence = $this->eventOccurrences
+ ->filter(fn (EventOccurrenceDomainObject $o) => $o->getStatus() === EventOccurrenceStatus::ACTIVE->name)
+ ->filter(fn (EventOccurrenceDomainObject $o) => ! $o->isPast())
+ ->sortBy(fn (EventOccurrenceDomainObject $o) => $o->getStartDate())
+ ->first();
+
+ return $nextOccurrence?->getStartDate();
+ }
+
+ public function isEventOngoing(): bool
+ {
+ if ($this->eventOccurrences === null || $this->eventOccurrences->isEmpty()) {
+ return false;
+ }
+
+ return $this->eventOccurrences->contains(
+ fn (EventOccurrenceDomainObject $o) => $o->getStatus() === EventOccurrenceStatus::ACTIVE->name
+ && ! $o->isFuture()
+ && ! $o->isPast()
+ );
+ }
+
+ public function getLifecycleStatus(): string
+ {
if ($this->isEventOngoing()) {
return EventLifecycleStatus::ONGOING->name;
}
- return EventLifecycleStatus::ENDED->name;
+ if ($this->eventOccurrences === null || $this->eventOccurrences->isEmpty()) {
+ return EventLifecycleStatus::UPCOMING->name;
+ }
+
+ $hasOccurrenceStillToCome = $this->eventOccurrences->contains(
+ fn (EventOccurrenceDomainObject $o) => ! $o->isPast()
+ );
+
+ return $hasOccurrenceStillToCome
+ ? EventLifecycleStatus::UPCOMING->name
+ : EventLifecycleStatus::ENDED->name;
+ }
+
+ public function isRecurring(): bool
+ {
+ return $this->getType() === EventType::RECURRING->name;
}
public function getPromoCodes(): ?Collection
@@ -276,12 +372,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 +396,7 @@ public function getWebhooks(): ?Collection
public function setWebhooks(?Collection $webhooks): EventDomainObject
{
$this->webhooks = $webhooks;
+
return $this;
}
@@ -309,6 +408,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/EventLogDomainObject.php b/backend/app/DomainObjects/EventLogDomainObject.php
index 6a8796c638..f9b470535f 100644
--- a/backend/app/DomainObjects/EventLogDomainObject.php
+++ b/backend/app/DomainObjects/EventLogDomainObject.php
@@ -2,6 +2,4 @@
namespace HiEvents\DomainObjects;
-class EventLogDomainObject extends Generated\EventLogDomainObjectAbstract
-{
-}
+class EventLogDomainObject extends Generated\EventLogDomainObjectAbstract {}
diff --git a/backend/app/DomainObjects/EventOccurrenceDailyStatisticDomainObject.php b/backend/app/DomainObjects/EventOccurrenceDailyStatisticDomainObject.php
new file mode 100644
index 0000000000..f46eaec225
--- /dev/null
+++ b/backend/app/DomainObjects/EventOccurrenceDailyStatisticDomainObject.php
@@ -0,0 +1,5 @@
+ [
+ '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 getStatus(): string
+ {
+ if ($this->isSoldOut()) {
+ return EventOccurrenceStatus::SOLD_OUT->name;
+ }
+
+ return parent::getStatus();
+ }
+
+ public function isActive(): bool
+ {
+ return $this->getStatus() === EventOccurrenceStatus::ACTIVE->name;
+ }
+
+ public function isCancelled(): bool
+ {
+ return parent::getStatus() === EventOccurrenceStatus::CANCELLED->name;
+ }
+
+ public function isSoldOut(): bool
+ {
+ return parent::getStatus() === EventOccurrenceStatus::ACTIVE->name
+ && $this->getCapacity() !== null
+ && $this->getUsedCapacity() >= $this->getCapacity();
+ }
+
+ 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 shouldShowAvailableCapacity(bool $eventLevelDefault): bool
+ {
+ return $this->getShowAvailableCapacity() ?? $eventLevelDefault;
+ }
+
+ 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..b3a9ac3495
--- /dev/null
+++ b/backend/app/DomainObjects/EventOccurrenceStatisticDomainObject.php
@@ -0,0 +1,5 @@
+
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/EventStatisticDomainObject.php b/backend/app/DomainObjects/EventStatisticDomainObject.php
index 0b7a8c28f7..1061728d0e 100644
--- a/backend/app/DomainObjects/EventStatisticDomainObject.php
+++ b/backend/app/DomainObjects/EventStatisticDomainObject.php
@@ -2,6 +2,4 @@
namespace HiEvents\DomainObjects;
-class EventStatisticDomainObject extends Generated\EventStatisticDomainObjectAbstract
-{
-}
+class EventStatisticDomainObject extends Generated\EventStatisticDomainObjectAbstract {}
diff --git a/backend/app/DomainObjects/FailedJobDomainObject.php b/backend/app/DomainObjects/FailedJobDomainObject.php
index af10f6a320..861b96693c 100644
--- a/backend/app/DomainObjects/FailedJobDomainObject.php
+++ b/backend/app/DomainObjects/FailedJobDomainObject.php
@@ -2,6 +2,4 @@
namespace HiEvents\DomainObjects;
-class FailedJobDomainObject extends Generated\FailedJobDomainObjectAbstract
-{
-}
+class FailedJobDomainObject extends Generated\FailedJobDomainObjectAbstract {}
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..984d752462 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 = false;
+ protected bool $public_show_question_answers = false;
+ protected bool $public_show_order_details = false;
+ 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..0a062859a2 100644
--- a/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php
+++ b/backend/app/DomainObjects/Generated/EventDomainObjectAbstract.php
@@ -14,6 +14,7 @@ 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';
@@ -30,11 +31,14 @@ abstract class EventDomainObjectAbstract extends \HiEvents\DomainObjects\Abstrac
final public const SHORT_ID = 'short_id';
final public const TICKET_QUANTITY_AVAILABLE = 'ticket_quantity_available';
final public const CATEGORY = 'category';
+ final public const TYPE = 'type';
+ final public const RECURRENCE_RULE = 'recurrence_rule';
protected int $id;
protected int $account_id;
protected int $user_id;
protected ?int $organizer_id = null;
+ protected ?int $event_location_id = null;
protected string $title;
protected ?string $start_date = null;
protected ?string $end_date = null;
@@ -51,6 +55,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,6 +65,7 @@ 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,
@@ -75,6 +82,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,6 +131,17 @@ public function getOrganizerId(): ?int
return $this->organizer_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 setTitle(string $title): self
{
$this->title = $title;
@@ -297,4 +317,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..516b57707f
--- /dev/null
+++ b/backend/app/DomainObjects/Generated/EventOccurrenceDomainObjectAbstract.php
@@ -0,0 +1,244 @@
+ $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,
+ 'show_available_capacity' => $this->show_available_capacity ?? null,
+ 'cancelled_attendees_count' => $this->cancelled_attendees_count ?? 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;
+ }
+
+ public function setShowAvailableCapacity(?bool $show_available_capacity): self
+ {
+ $this->show_available_capacity = $show_available_capacity;
+ return $this;
+ }
+
+ public function getShowAvailableCapacity(): ?bool
+ {
+ return $this->show_available_capacity;
+ }
+
+ public function setCancelledAttendeesCount(?int $cancelled_attendees_count): self
+ {
+ $this->cancelled_attendees_count = $cancelled_attendees_count;
+ return $this;
+ }
+
+ public function getCancelledAttendeesCount(): ?int
+ {
+ return $this->cancelled_attendees_count;
+ }
+}
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 c571b2c67d..44ee7d4597 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';
@@ -61,13 +60,15 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\
final public const TICKET_DESIGN_SETTINGS = 'ticket_design_settings';
final public const ATTENDEE_DETAILS_COLLECTION_METHOD = 'attendee_details_collection_method';
final public const SHOW_MARKETING_OPT_IN = 'show_marketing_opt_in';
- final public const ALLOW_COPY_DETAILS_TO_ALL_ATTENDEES = 'allow_copy_details_to_all_attendees';
final public const HOMEPAGE_THEME_SETTINGS = 'homepage_theme_settings';
final public const PASS_PLATFORM_FEE_TO_BUYER = 'pass_platform_fee_to_buyer';
final public const ALLOW_ATTENDEE_SELF_EDIT = 'allow_attendee_self_edit';
final public const WAITLIST_ENABLED = 'waitlist_enabled';
final public const WAITLIST_AUTO_PROCESS = 'waitlist_auto_process';
final public const WAITLIST_OFFER_TIMEOUT_MINUTES = 'waitlist_offer_timeout_minutes';
+ final public const ALLOW_COPY_DETAILS_TO_ALL_ATTENDEES = 'allow_copy_details_to_all_attendees';
+ final public const SHOW_AVAILABLE_OCCURRENCE_CAPACITY = 'show_available_occurrence_capacity';
+ final public const HIDE_SOLD_OUT_OCCURRENCES = 'hide_sold_out_occurrences';
protected int $id;
protected int $event_id;
@@ -100,7 +101,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';
@@ -120,13 +120,15 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\
protected array|string|null $ticket_design_settings = null;
protected string $attendee_details_collection_method = 'PER_TICKET';
protected bool $show_marketing_opt_in = true;
- protected bool $allow_copy_details_to_all_attendees = true;
protected array|string|null $homepage_theme_settings = null;
protected bool $pass_platform_fee_to_buyer = false;
protected bool $allow_attendee_self_edit = true;
protected bool $waitlist_enabled = false;
protected bool $waitlist_auto_process = false;
protected ?int $waitlist_offer_timeout_minutes = null;
+ protected bool $allow_copy_details_to_all_attendees = true;
+ protected bool $show_available_occurrence_capacity = false;
+ protected bool $hide_sold_out_occurrences = false;
public function toArray(): array
{
@@ -162,7 +164,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,
@@ -182,13 +183,15 @@ public function toArray(): array
'ticket_design_settings' => $this->ticket_design_settings ?? null,
'attendee_details_collection_method' => $this->attendee_details_collection_method ?? null,
'show_marketing_opt_in' => $this->show_marketing_opt_in ?? null,
- 'allow_copy_details_to_all_attendees' => $this->allow_copy_details_to_all_attendees ?? null,
'homepage_theme_settings' => $this->homepage_theme_settings ?? null,
'pass_platform_fee_to_buyer' => $this->pass_platform_fee_to_buyer ?? null,
'allow_attendee_self_edit' => $this->allow_attendee_self_edit ?? null,
'waitlist_enabled' => $this->waitlist_enabled ?? null,
'waitlist_auto_process' => $this->waitlist_auto_process ?? null,
'waitlist_offer_timeout_minutes' => $this->waitlist_offer_timeout_minutes ?? null,
+ 'allow_copy_details_to_all_attendees' => $this->allow_copy_details_to_all_attendees ?? null,
+ 'show_available_occurrence_capacity' => $this->show_available_occurrence_capacity ?? null,
+ 'hide_sold_out_occurrences' => $this->hide_sold_out_occurrences ?? null,
];
}
@@ -533,17 +536,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;
@@ -754,17 +746,6 @@ public function getShowMarketingOptIn(): bool
return $this->show_marketing_opt_in;
}
- public function setAllowCopyDetailsToAllAttendees(bool $allow_copy_details_to_all_attendees): self
- {
- $this->allow_copy_details_to_all_attendees = $allow_copy_details_to_all_attendees;
- return $this;
- }
-
- public function getAllowCopyDetailsToAllAttendees(): bool
- {
- return $this->allow_copy_details_to_all_attendees;
- }
-
public function setHomepageThemeSettings(array|string|null $homepage_theme_settings): self
{
$this->homepage_theme_settings = $homepage_theme_settings;
@@ -830,4 +811,37 @@ public function getWaitlistOfferTimeoutMinutes(): ?int
{
return $this->waitlist_offer_timeout_minutes;
}
+
+ public function setAllowCopyDetailsToAllAttendees(bool $allow_copy_details_to_all_attendees): self
+ {
+ $this->allow_copy_details_to_all_attendees = $allow_copy_details_to_all_attendees;
+ return $this;
+ }
+
+ public function getAllowCopyDetailsToAllAttendees(): bool
+ {
+ return $this->allow_copy_details_to_all_attendees;
+ }
+
+ public function setShowAvailableOccurrenceCapacity(bool $show_available_occurrence_capacity): self
+ {
+ $this->show_available_occurrence_capacity = $show_available_occurrence_capacity;
+ return $this;
+ }
+
+ public function getShowAvailableOccurrenceCapacity(): bool
+ {
+ return $this->show_available_occurrence_capacity;
+ }
+
+ public function setHideSoldOutOccurrences(bool $hide_sold_out_occurrences): self
+ {
+ $this->hide_sold_out_occurrences = $hide_sold_out_occurrences;
+ return $this;
+ }
+
+ public function getHideSoldOutOccurrences(): bool
+ {
+ return $this->hide_sold_out_occurrences;
+ }
}
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/PromoCodeDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/PromoCodeDomainObjectAbstract.php
index c1e7174e18..20c34cb9d7 100644
--- a/backend/app/DomainObjects/Generated/PromoCodeDomainObjectAbstract.php
+++ b/backend/app/DomainObjects/Generated/PromoCodeDomainObjectAbstract.php
@@ -23,6 +23,7 @@ abstract class PromoCodeDomainObjectAbstract extends \HiEvents\DomainObjects\Abs
final public const CREATED_AT = 'created_at';
final public const UPDATED_AT = 'updated_at';
final public const DELETED_AT = 'deleted_at';
+ final public const DISCOUNT_APPLIES_TO = 'discount_applies_to';
protected int $id;
protected int $event_id;
@@ -37,6 +38,7 @@ abstract class PromoCodeDomainObjectAbstract extends \HiEvents\DomainObjects\Abs
protected string $created_at;
protected ?string $updated_at = null;
protected ?string $deleted_at = null;
+ protected string $discount_applies_to = 'EACH_PRODUCT';
public function toArray(): array
{
@@ -54,6 +56,7 @@ public function toArray(): array
'created_at' => $this->created_at ?? null,
'updated_at' => $this->updated_at ?? null,
'deleted_at' => $this->deleted_at ?? null,
+ 'discount_applies_to' => $this->discount_applies_to ?? null,
];
}
@@ -199,4 +202,15 @@ public function getDeletedAt(): ?string
{
return $this->deleted_at;
}
+
+ public function setDiscountAppliesTo(string $discount_applies_to): self
+ {
+ $this->discount_applies_to = $discount_applies_to;
+ return $this;
+ }
+
+ public function getDiscountAppliesTo(): string
+ {
+ return $this->discount_applies_to;
+ }
}
diff --git a/backend/app/DomainObjects/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/ImageDomainObject.php b/backend/app/DomainObjects/ImageDomainObject.php
index 238a09acfe..a0e89fa0b9 100644
--- a/backend/app/DomainObjects/ImageDomainObject.php
+++ b/backend/app/DomainObjects/ImageDomainObject.php
@@ -2,6 +2,4 @@
namespace HiEvents\DomainObjects;
-class ImageDomainObject extends Generated\ImageDomainObjectAbstract
-{
-}
+class ImageDomainObject extends Generated\ImageDomainObjectAbstract {}
diff --git a/backend/app/DomainObjects/Interfaces/IsSortable.php b/backend/app/DomainObjects/Interfaces/IsSortable.php
index 7ea5a2385e..8695800e8e 100644
--- a/backend/app/DomainObjects/Interfaces/IsSortable.php
+++ b/backend/app/DomainObjects/Interfaces/IsSortable.php
@@ -8,20 +8,13 @@ interface IsSortable
{
/**
* The default sort column
- *
- * @return string
*/
public static function getDefaultSort(): string;
/**
* The default sort order - asc or desc
- *
- * @return string
*/
public static function getDefaultSortDirection(): string;
- /**
- * @return AllowedSorts
- */
public static function getAllowedSorts(): AllowedSorts;
}
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/OrderApplicationFeeDomainObject.php b/backend/app/DomainObjects/OrderApplicationFeeDomainObject.php
index 1c3330d689..69216f3615 100644
--- a/backend/app/DomainObjects/OrderApplicationFeeDomainObject.php
+++ b/backend/app/DomainObjects/OrderApplicationFeeDomainObject.php
@@ -2,6 +2,4 @@
namespace HiEvents\DomainObjects;
-class OrderApplicationFeeDomainObject extends Generated\OrderApplicationFeeDomainObjectAbstract
-{
-}
+class OrderApplicationFeeDomainObject extends Generated\OrderApplicationFeeDomainObjectAbstract {}
diff --git a/backend/app/DomainObjects/OrderAttributeDomainObject.php b/backend/app/DomainObjects/OrderAttributeDomainObject.php
index bf9cda57e4..157dab426b 100644
--- a/backend/app/DomainObjects/OrderAttributeDomainObject.php
+++ b/backend/app/DomainObjects/OrderAttributeDomainObject.php
@@ -2,6 +2,4 @@
namespace HiEvents\DomainObjects;
-class OrderAttributeDomainObject extends Generated\OrderAttributeDomainObjectAbstract
-{
-}
+class OrderAttributeDomainObject extends Generated\OrderAttributeDomainObjectAbstract {}
diff --git a/backend/app/DomainObjects/OrderAuditLogDomainObject.php b/backend/app/DomainObjects/OrderAuditLogDomainObject.php
index 511fabdfde..b9dd223a32 100644
--- a/backend/app/DomainObjects/OrderAuditLogDomainObject.php
+++ b/backend/app/DomainObjects/OrderAuditLogDomainObject.php
@@ -2,6 +2,4 @@
namespace HiEvents\DomainObjects;
-class OrderAuditLogDomainObject extends Generated\OrderAuditLogDomainObjectAbstract
-{
-}
+class OrderAuditLogDomainObject extends Generated\OrderAuditLogDomainObjectAbstract {}
diff --git a/backend/app/DomainObjects/OrderDiscountCodeDomainObject.php b/backend/app/DomainObjects/OrderDiscountCodeDomainObject.php
index 45101a2256..72adfa67cd 100644
--- a/backend/app/DomainObjects/OrderDiscountCodeDomainObject.php
+++ b/backend/app/DomainObjects/OrderDiscountCodeDomainObject.php
@@ -2,6 +2,4 @@
namespace HiEvents\DomainObjects;
-class OrderDiscountCodeDomainObject extends Generated\OrderDiscountCodeDomainObjectAbstract
-{
-}
+class OrderDiscountCodeDomainObject extends Generated\OrderDiscountCodeDomainObjectAbstract {}
diff --git a/backend/app/DomainObjects/OrderDomainObject.php b/backend/app/DomainObjects/OrderDomainObject.php
index b86ab7fbfe..611167b072 100644
--- a/backend/app/DomainObjects/OrderDomainObject.php
+++ b/backend/app/DomainObjects/OrderDomainObject.php
@@ -92,13 +92,13 @@ public static function getDefaultSortDirection(): string
public function getFullName(): string
{
- return $this->getFirstName() . ' ' . $this->getLastName();
+ return $this->getFirstName().' '.$this->getLastName();
}
public function getProductOrderItems(): Collection
{
if ($this->getOrderItems() === null) {
- return new Collection();
+ return new Collection;
}
return $this->getOrderItems()->filter(static function (OrderItemDomainObject $orderItem) {
@@ -109,7 +109,7 @@ public function getProductOrderItems(): Collection
public function getTicketOrderItems(): Collection
{
if ($this->getOrderItems() === null) {
- return new Collection();
+ return new Collection;
}
return $this->getOrderItems()->filter(static function (OrderItemDomainObject $orderItem) {
@@ -120,6 +120,7 @@ public function getTicketOrderItems(): Collection
public function setOrderItems(?Collection $orderItems): OrderDomainObject
{
$this->orderItems = $orderItems;
+
return $this;
}
@@ -134,6 +135,7 @@ public function getOrderItems(): ?Collection
public function setAttendees(?Collection $attendees): OrderDomainObject
{
$this->attendees = $attendees;
+
return $this;
}
@@ -144,7 +146,7 @@ public function getAttendees(): ?Collection
public function isPaymentRequired(): bool
{
- return (int)ceil($this->getTotalGross()) > 0;
+ return (int) ceil($this->getTotalGross()) > 0;
}
public function isOrderAwaitingOfflinePayment(): bool
@@ -180,6 +182,7 @@ public function isOrderFailed(): bool
public function setStripePayment(?StripePaymentDomainObject $stripePayment): OrderDomainObject
{
$this->stripePayment = $stripePayment;
+
return $this;
}
@@ -190,7 +193,7 @@ public function isPartiallyRefunded(): bool
public function isFullyRefunded(): bool
{
- return !$this->isFreeOrder() && ($this->getTotalRefunded() >= $this->getTotalGross());
+ return ! $this->isFreeOrder() && ($this->getTotalRefunded() >= $this->getTotalGross());
}
public function getHumanReadableStatus(): string
@@ -215,7 +218,7 @@ public function getHasFees(): bool
public function getLatestInvoice(): ?InvoiceDomainObject
{
- return $this->getInvoices()?->sortByDesc(fn(InvoiceDomainObject $invoice) => $invoice->getId())->first();
+ return $this->getInvoices()?->sortByDesc(fn (InvoiceDomainObject $invoice) => $invoice->getId())->first();
}
public function getStripePayment(): ?StripePaymentDomainObject
@@ -231,6 +234,7 @@ public function isFreeOrder(): bool
public function setQuestionAndAnswerViews(?Collection $questionAndAnswerViews): OrderDomainObject
{
$this->questionAndAnswerViews = $questionAndAnswerViews;
+
return $this;
}
@@ -240,7 +244,7 @@ public function getTotalQuantity(): int
throw new RuntimeException('Cannot calculate total quantity, order items are null');
}
- return $this->getOrderItems()->sum(fn(OrderItemDomainObject $item) => $item->getQuantity());
+ return $this->getOrderItems()->sum(fn (OrderItemDomainObject $item) => $item->getQuantity());
}
public function getQuestionAndAnswerViews(): ?Collection
@@ -251,6 +255,7 @@ public function getQuestionAndAnswerViews(): ?Collection
public function setEvent(?EventDomainObject $event): OrderDomainObject
{
$this->event = $event;
+
return $this;
}
@@ -262,6 +267,7 @@ public function getEvent(): ?EventDomainObject
public function setInvoices(?Collection $invoices): OrderDomainObject
{
$this->invoices = $invoices;
+
return $this;
}
@@ -273,6 +279,7 @@ public function getInvoices(): ?Collection
public function setSessionIdentifier(?string $sessionIdentifier): OrderDomainObject
{
$this->sessionIdentifier = $sessionIdentifier;
+
return $this;
}
@@ -283,7 +290,7 @@ public function getSessionIdentifier(): ?string
public function isRefundable(): bool
{
- return !$this->isFreeOrder()
+ return ! $this->isFreeOrder()
&& $this->getStatus() !== OrderPaymentStatus::AWAITING_OFFLINE_PAYMENT->name
&& $this->getPaymentProvider() === PaymentProviders::STRIPE->name
&& $this->getRefundStatus() !== OrderRefundStatus::REFUNDED->name;
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/OrderPaymentPlatformFeeDomainObject.php b/backend/app/DomainObjects/OrderPaymentPlatformFeeDomainObject.php
index 9c2c591bfd..323f185431 100644
--- a/backend/app/DomainObjects/OrderPaymentPlatformFeeDomainObject.php
+++ b/backend/app/DomainObjects/OrderPaymentPlatformFeeDomainObject.php
@@ -2,6 +2,4 @@
namespace HiEvents\DomainObjects;
-class OrderPaymentPlatformFeeDomainObject extends Generated\OrderPaymentPlatformFeeDomainObjectAbstract
-{
-}
+class OrderPaymentPlatformFeeDomainObject extends Generated\OrderPaymentPlatformFeeDomainObjectAbstract {}
diff --git a/backend/app/DomainObjects/OrderRefundDomainObject.php b/backend/app/DomainObjects/OrderRefundDomainObject.php
index 3d0a655d25..b07efd49a3 100644
--- a/backend/app/DomainObjects/OrderRefundDomainObject.php
+++ b/backend/app/DomainObjects/OrderRefundDomainObject.php
@@ -2,6 +2,4 @@
namespace HiEvents\DomainObjects;
-class OrderRefundDomainObject extends Generated\OrderRefundDomainObjectAbstract
-{
-}
+class OrderRefundDomainObject extends Generated\OrderRefundDomainObjectAbstract {}
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..1e10111588 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..af56d7eb6c
--- /dev/null
+++ b/backend/app/DomainObjects/OrganizerStripePlatformDomainObject.php
@@ -0,0 +1,5 @@
+taxAndFees = $taxes;
+
return $this;
}
public function getTaxRates(): ?Collection
{
- return $this->getTaxAndFees()?->filter(fn(TaxAndFeesDomainObject $taxAndFee) => $taxAndFee->isTax());
+ return $this->getTaxAndFees()?->filter(fn (TaxAndFeesDomainObject $taxAndFee) => $taxAndFee->isTax());
}
public function getTaxAndFees(): ?Collection
@@ -75,34 +76,34 @@ public function getTaxAndFees(): ?Collection
public function getFees(): ?Collection
{
- return $this->getTaxAndFees()?->filter(fn(TaxAndFeesDomainObject $taxAndFee) => $taxAndFee->isFee());
+ return $this->getTaxAndFees()?->filter(fn (TaxAndFeesDomainObject $taxAndFee) => $taxAndFee->isFee());
}
public function isSoldOut(): bool
{
- if (!$this->getProductPrices() || $this->getProductPrices()->isEmpty()) {
+ if (! $this->getProductPrices() || $this->getProductPrices()->isEmpty()) {
return true;
}
- return $this->getProductPrices()->every(fn(ProductPriceDomainObject $price) => $price->isSoldOut());
+ return $this->getProductPrices()->every(fn (ProductPriceDomainObject $price) => $price->isSoldOut());
}
public function getQuantityAvailable(): int
{
- $availableCount = $this->getProductPrices()->sum(fn(ProductPriceDomainObject $price) => $price->getQuantityAvailable());
+ $availableCount = $this->getProductPrices()->sum(fn (ProductPriceDomainObject $price) => $price->getQuantityAvailable());
if ($this->quantityAvailable !== null) {
return min($availableCount, $this->quantityAvailable);
}
- if (!$this->getProductPrices() || $this->getProductPrices()->isEmpty()) {
+ if (! $this->getProductPrices() || $this->getProductPrices()->isEmpty()) {
return 0;
}
// This is to address a case where prices have an unlimited quantity available and the user has
// enabled show_quantity_remaining.
if ($this->getShowQuantityRemaining()
- && $this->getProductPrices()->first(fn(ProductPriceDomainObject $price) => $price->getQuantityAvailable() === null)) {
+ && $this->getProductPrices()->first(fn (ProductPriceDomainObject $price) => $price->getQuantityAvailable() === null)) {
return Constants::INFINITE;
}
@@ -118,16 +119,14 @@ public function setQuantityAvailable(int $quantityAvailable): ProductDomainObjec
public function isBeforeSaleStartDate(): bool
{
- return (!is_null($this->getSaleStartDate())
- && (new Carbon($this->getSaleStartDate()))->isFuture()
- );
+ return ! is_null($this->getSaleStartDate())
+ && (new Carbon($this->getSaleStartDate()))->isFuture();
}
public function isAfterSaleEndDate(): bool
{
- return (!is_null($this->getSaleEndDate())
- && (new Carbon($this->getSaleEndDate()))->isPast()
- );
+ return ! is_null($this->getSaleEndDate())
+ && (new Carbon($this->getSaleEndDate()))->isPast();
}
public function isAvailable(): bool
@@ -137,10 +136,10 @@ public function isAvailable(): bool
return false;
}
- return !$this->isSoldOut()
- && !$this->isBeforeSaleStartDate()
- && !$this->isAfterSaleEndDate()
- && !$this->getIsHidden();
+ return ! $this->isSoldOut()
+ && ! $this->isBeforeSaleStartDate()
+ && ! $this->isAfterSaleEndDate()
+ && ! $this->getIsHidden();
}
/**
@@ -160,8 +159,6 @@ public function setProductPrices(?Collection $prices): self
/**
* All product types except TIERED have a single price, so we can just return the first price.
- *
- * @return float|null
*/
public function getPrice(): ?float
{
@@ -174,7 +171,7 @@ public function getPrice(): ?float
public function getPriceById(int $priceId): ?ProductPriceDomainObject
{
- return $this->getProductPrices()?->first(fn(ProductPriceDomainObject $price) => $price->getId() === $priceId);
+ return $this->getProductPrices()?->first(fn (ProductPriceDomainObject $price) => $price->getId() === $priceId);
}
public function isTieredType(): bool
@@ -200,7 +197,7 @@ public function isFreeType(): bool
public function getInitialQuantityAvailable(): ?int
{
if ($this->getType() === ProductPriceType::TIERED->name) {
- return $this->getProductPrices()?->sum(fn(ProductPriceDomainObject $price) => $price->getInitialQuantityAvailable());
+ return $this->getProductPrices()?->sum(fn (ProductPriceDomainObject $price) => $price->getInitialQuantityAvailable());
}
return $this->getProductPrices()?->first()?->getInitialQuantityAvailable();
@@ -208,7 +205,7 @@ public function getInitialQuantityAvailable(): ?int
public function getQuantitySold(): int
{
- return $this->getProductPrices()?->sum(fn(ProductPriceDomainObject $price) => $price->getQuantitySold()) ?? 0;
+ return $this->getProductPrices()?->sum(fn (ProductPriceDomainObject $price) => $price->getQuantitySold()) ?? 0;
}
public function setOffSaleReason(?string $offSaleReason): ProductDomainObject
diff --git a/backend/app/DomainObjects/ProductOccurrenceVisibilityDomainObject.php b/backend/app/DomainObjects/ProductOccurrenceVisibilityDomainObject.php
new file mode 100644
index 0000000000..c4e2da7634
--- /dev/null
+++ b/backend/app/DomainObjects/ProductOccurrenceVisibilityDomainObject.php
@@ -0,0 +1,5 @@
+getSaleStartDate())
- && (new Carbon($this->getSaleStartDate()))->isFuture()
- );
+ return ! is_null($this->getSaleStartDate())
+ && (new Carbon($this->getSaleStartDate()))->isFuture();
}
public function isAfterSaleEndDate(): bool
{
- return (!is_null($this->getSaleEndDate())
- && (new Carbon($this->getSaleEndDate()))->isPast()
- );
+ return ! is_null($this->getSaleEndDate())
+ && (new Carbon($this->getSaleEndDate()))->isPast();
}
public function isSoldOut(): bool
@@ -86,7 +84,7 @@ public function isSoldOut(): bool
return true;
}
- if ($this->getInitialQuantityAvailable() === null) {
+ if ($this->getInitialQuantityAvailable() === null) {
return false;
}
@@ -101,6 +99,7 @@ public function isAvailable(): ?bool
public function setIsAvailable(?bool $isAvailable): ProductPriceDomainObject
{
$this->isAvailable = $isAvailable;
+
return $this;
}
@@ -124,6 +123,7 @@ public function isFree(): bool
public function setProduct(?ProductDomainObject $product): self
{
$this->product = $product;
+
return $this;
}
diff --git a/backend/app/DomainObjects/ProductPriceOccurrenceOverrideDomainObject.php b/backend/app/DomainObjects/ProductPriceOccurrenceOverrideDomainObject.php
new file mode 100644
index 0000000000..6de044ab30
--- /dev/null
+++ b/backend/app/DomainObjects/ProductPriceOccurrenceOverrideDomainObject.php
@@ -0,0 +1,5 @@
+getApplicableProductIds()) {
+ if (! $this->getApplicableProductIds()) {
return true;
}
@@ -81,4 +81,10 @@ public function isNoDiscountCode(): bool
{
return $this->getDiscountType() === PromoCodeDiscountTypeEnum::NONE->name;
}
+
+ public function isOrderLevelDiscount(): bool
+ {
+ return $this->isFixedDiscount()
+ && $this->getDiscountAppliesTo() === PromoCodeDiscountAppliesToEnum::ORDER->name;
+ }
}
diff --git a/backend/app/DomainObjects/QuestionAndAnswerViewDomainObject.php b/backend/app/DomainObjects/QuestionAndAnswerViewDomainObject.php
index cabe5bdf0c..479549a2b9 100644
--- a/backend/app/DomainObjects/QuestionAndAnswerViewDomainObject.php
+++ b/backend/app/DomainObjects/QuestionAndAnswerViewDomainObject.php
@@ -8,29 +8,51 @@
class QuestionAndAnswerViewDomainObject extends AbstractDomainObject
{
final public const SINGULAR_NAME = 'question_and_answer_view';
+
final public const PLURAL_NAME = 'question_and_answer_views';
private ?int $product_id;
+
private ?string $product_title;
+
private int $question_id;
+
private ?int $order_id;
+
private ?string $order_first_name;
+
private ?string $order_last_name;
+
private ?string $order_email;
+
private ?string $order_public_id;
+
private string $title;
+
private bool $question_required;
+
private ?string $question_description = null;
+
private ?int $attendee_id = null;
+
private ?string $attendee_public_id = null;
+
private ?string $first_name = null;
+
private ?string $last_name = null;
+
private ?string $attendee_email = null;
+
private array|string $answer;
+
private string $belongs_to;
+
private string $question_type;
+
private int $event_id;
+
private int $question_answer_id;
+
private ?array $question_options = null;
private ?AttendeeDomainObject $attendee = null;
@@ -45,6 +67,7 @@ public function getQuestionId(): int
public function setQuestionId(int $question_id): QuestionAndAnswerViewDomainObject
{
$this->question_id = $question_id;
+
return $this;
}
@@ -56,6 +79,7 @@ public function getOrderId(): ?int
public function setOrderId(?int $order_id): QuestionAndAnswerViewDomainObject
{
$this->order_id = $order_id;
+
return $this;
}
@@ -67,6 +91,7 @@ public function getTitle(): string
public function setTitle(string $title): QuestionAndAnswerViewDomainObject
{
$this->title = $title;
+
return $this;
}
@@ -78,6 +103,7 @@ public function getLastName(): ?string
public function setLastName(?string $last_name): QuestionAndAnswerViewDomainObject
{
$this->last_name = $last_name;
+
return $this;
}
@@ -89,6 +115,7 @@ public function getAnswer(): string|array
public function setAnswer(array|string $answer): QuestionAndAnswerViewDomainObject
{
$this->answer = $answer;
+
return $this;
}
@@ -100,6 +127,7 @@ public function getBelongsTo(): string
public function setBelongsTo(string $belongs_to): QuestionAndAnswerViewDomainObject
{
$this->belongs_to = $belongs_to;
+
return $this;
}
@@ -111,6 +139,7 @@ public function getAttendeeId(): ?int
public function setAttendeeId(?int $attendee_id): QuestionAndAnswerViewDomainObject
{
$this->attendee_id = $attendee_id;
+
return $this;
}
@@ -122,6 +151,7 @@ public function getQuestionType(): string
public function setQuestionType(string $question_type): QuestionAndAnswerViewDomainObject
{
$this->question_type = $question_type;
+
return $this;
}
@@ -133,6 +163,7 @@ public function getFirstName(): ?string
public function setFirstName(?string $first_name): QuestionAndAnswerViewDomainObject
{
$this->first_name = $first_name;
+
return $this;
}
@@ -144,6 +175,7 @@ public function getEventId(): int
public function setEventId(int $event_id): QuestionAndAnswerViewDomainObject
{
$this->event_id = $event_id;
+
return $this;
}
@@ -155,6 +187,7 @@ public function getProductId(): ?int
public function setProductId(?int $product_id): QuestionAndAnswerViewDomainObject
{
$this->product_id = $product_id;
+
return $this;
}
@@ -166,6 +199,7 @@ public function getProductTitle(): ?string
public function setProductTitle(?string $product_title): QuestionAndAnswerViewDomainObject
{
$this->product_title = $product_title;
+
return $this;
}
diff --git a/backend/app/DomainObjects/QuestionDomainObject.php b/backend/app/DomainObjects/QuestionDomainObject.php
index 7d84e3a255..71fc22911c 100644
--- a/backend/app/DomainObjects/QuestionDomainObject.php
+++ b/backend/app/DomainObjects/QuestionDomainObject.php
@@ -12,6 +12,7 @@ class QuestionDomainObject extends Generated\QuestionDomainObjectAbstract
public function setProducts(?Collection $products): QuestionDomainObject
{
$this->products = $products;
+
return $this;
}
@@ -37,16 +38,17 @@ public function setOptions(array|string|null $options): self
}
$this->options = $options;
+
return $this;
}
public function isAnswerValid(mixed $answer): bool
{
- if (!isset($answer)) {
+ if (! isset($answer)) {
return false;
}
- if (!$this->isPreDefinedChoice()) {
+ if (! $this->isPreDefinedChoice()) {
return true;
}
@@ -54,6 +56,6 @@ public function isAnswerValid(mixed $answer): bool
return in_array($answer, $this->getOptions(), true);
}
- return array_diff((array)$answer, $this->getOptions()) === [];
+ return array_diff((array) $answer, $this->getOptions()) === [];
}
}
diff --git a/backend/app/DomainObjects/QuestionOptionDomainObject.php b/backend/app/DomainObjects/QuestionOptionDomainObject.php
index 4a41e15474..2a79597e27 100644
--- a/backend/app/DomainObjects/QuestionOptionDomainObject.php
+++ b/backend/app/DomainObjects/QuestionOptionDomainObject.php
@@ -2,6 +2,4 @@
namespace HiEvents\DomainObjects;
-class QuestionOptionDomainObject extends Generated\QuestionOptionDomainObjectAbstract
-{
-}
+class QuestionOptionDomainObject extends Generated\QuestionOptionDomainObjectAbstract {}
diff --git a/backend/app/DomainObjects/RazorpayOrderDomainObject.php b/backend/app/DomainObjects/RazorpayOrderDomainObject.php
index 5d2df336d7..1b98930cb7 100644
--- a/backend/app/DomainObjects/RazorpayOrderDomainObject.php
+++ b/backend/app/DomainObjects/RazorpayOrderDomainObject.php
@@ -2,6 +2,4 @@
namespace HiEvents\DomainObjects;
-class RazorpayOrderDomainObject extends Generated\RazorpayOrderDomainObjectAbstract
-{
-}
+class RazorpayOrderDomainObject extends Generated\RazorpayOrderDomainObjectAbstract {}
diff --git a/backend/app/DomainObjects/RoleDomainObject.php b/backend/app/DomainObjects/RoleDomainObject.php
index 9ff7ffaf42..e2f9759c1e 100644
--- a/backend/app/DomainObjects/RoleDomainObject.php
+++ b/backend/app/DomainObjects/RoleDomainObject.php
@@ -2,6 +2,4 @@
namespace HiEvents\DomainObjects;
-class RoleDomainObject extends Generated\RoleDomainObjectAbstract
-{
-}
+class RoleDomainObject extends Generated\RoleDomainObjectAbstract {}
diff --git a/backend/app/DomainObjects/SortingAndFiltering/AllowedSort.php b/backend/app/DomainObjects/SortingAndFiltering/AllowedSort.php
index 992dfff0d4..ee31f96c41 100644
--- a/backend/app/DomainObjects/SortingAndFiltering/AllowedSort.php
+++ b/backend/app/DomainObjects/SortingAndFiltering/AllowedSort.php
@@ -8,7 +8,5 @@ public function __construct(
public string $key,
public ?string $ascDescription,
public ?string $descDescription,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/DomainObjects/SortingAndFiltering/AllowedSorts.php b/backend/app/DomainObjects/SortingAndFiltering/AllowedSorts.php
index bdd82ae250..6b39b06431 100644
--- a/backend/app/DomainObjects/SortingAndFiltering/AllowedSorts.php
+++ b/backend/app/DomainObjects/SortingAndFiltering/AllowedSorts.php
@@ -11,9 +11,9 @@ class AllowedSorts
public function __construct(array $allowedSorts)
{
- $this->allowedSorts = new Collection();
+ $this->allowedSorts = new Collection;
foreach ($allowedSorts as $key => $directions) {
- if (!isset($directions['asc']) && !isset($directions['desc'])) {
+ if (! isset($directions['asc']) && ! isset($directions['desc'])) {
throw new InvalidArgumentException(
sprintf('AllowedSorts for "%s" must contain at least an asc description or a desc description', $key)
);
@@ -35,11 +35,11 @@ public function toArray(): array
return $this->allowedSorts->mapWithKeys(function (AllowedSort $sort) {
$sortOptions = [];
- if (!is_null($sort->ascDescription)) {
+ if (! is_null($sort->ascDescription)) {
$sortOptions['asc'] = $sort->ascDescription;
}
- if (!is_null($sort->descDescription)) {
+ if (! is_null($sort->descDescription)) {
$sortOptions['desc'] = $sort->descDescription;
}
diff --git a/backend/app/DomainObjects/Status/EventOccurrenceStatus.php b/backend/app/DomainObjects/Status/EventOccurrenceStatus.php
new file mode 100644
index 0000000000..af7ac53b55
--- /dev/null
+++ b/backend/app/DomainObjects/Status/EventOccurrenceStatus.php
@@ -0,0 +1,14 @@
+order = $order;
+
return $this;
}
@@ -24,7 +25,7 @@ public function setOrder(?OrderDomainObject $order): self
*/
public function getStripePlatformEnum(): ?StripePlatform
{
- return $this->getStripePlatform()
+ return $this->getStripePlatform()
? StripePlatform::fromString($this->getStripePlatform())
: null;
}
diff --git a/backend/app/DomainObjects/StripePayoutDomainObject.php b/backend/app/DomainObjects/StripePayoutDomainObject.php
index 1b552d945d..2981ed3452 100644
--- a/backend/app/DomainObjects/StripePayoutDomainObject.php
+++ b/backend/app/DomainObjects/StripePayoutDomainObject.php
@@ -8,4 +8,3 @@ class StripePayoutDomainObject extends StripePayoutDomainObjectAbstract
{
// Custom accessors/helpers can go here if needed in future
}
-
diff --git a/backend/app/DomainObjects/TicketLookupTokenDomainObject.php b/backend/app/DomainObjects/TicketLookupTokenDomainObject.php
index 647b8f425b..266d60660e 100644
--- a/backend/app/DomainObjects/TicketLookupTokenDomainObject.php
+++ b/backend/app/DomainObjects/TicketLookupTokenDomainObject.php
@@ -2,6 +2,4 @@
namespace HiEvents\DomainObjects;
-class TicketLookupTokenDomainObject extends Generated\TicketLookupTokenDomainObjectAbstract
-{
-}
+class TicketLookupTokenDomainObject extends Generated\TicketLookupTokenDomainObjectAbstract {}
diff --git a/backend/app/DomainObjects/TimezoneDomainObject.php b/backend/app/DomainObjects/TimezoneDomainObject.php
index 60a0e66cfd..2dd47f566d 100644
--- a/backend/app/DomainObjects/TimezoneDomainObject.php
+++ b/backend/app/DomainObjects/TimezoneDomainObject.php
@@ -2,6 +2,4 @@
namespace HiEvents\DomainObjects;
-class TimezoneDomainObject extends Generated\TimezoneDomainObjectAbstract
-{
-}
+class TimezoneDomainObject extends Generated\TimezoneDomainObjectAbstract {}
diff --git a/backend/app/DomainObjects/UserDomainObject.php b/backend/app/DomainObjects/UserDomainObject.php
index f3931eb06e..5fe9ae52f9 100644
--- a/backend/app/DomainObjects/UserDomainObject.php
+++ b/backend/app/DomainObjects/UserDomainObject.php
@@ -12,7 +12,7 @@ class UserDomainObject extends Generated\UserDomainObjectAbstract
public function getFullName(): string
{
- return $this->getFirstName() . ' ' . $this->getLastName();
+ return $this->getFirstName().' '.$this->getLastName();
}
public function setPendingEmail(?string $pending_email): Generated\UserDomainObjectAbstract
diff --git a/backend/app/DomainObjects/WaitlistEntryDomainObject.php b/backend/app/DomainObjects/WaitlistEntryDomainObject.php
index 2c2e79a1b7..6514537bc8 100644
--- a/backend/app/DomainObjects/WaitlistEntryDomainObject.php
+++ b/backend/app/DomainObjects/WaitlistEntryDomainObject.php
@@ -7,11 +7,14 @@
use HiEvents\DomainObjects\Interfaces\IsSortable;
use HiEvents\DomainObjects\SortingAndFiltering\AllowedSorts;
-class WaitlistEntryDomainObject extends WaitlistEntryDomainObjectAbstract implements IsSortable, IsFilterable
+class WaitlistEntryDomainObject extends WaitlistEntryDomainObjectAbstract implements IsFilterable, IsSortable
{
public ?OrderDomainObject $order = null;
+
public ?ProductPriceDomainObject $productPrice = null;
+ public ?EventOccurrenceDomainObject $eventOccurrence = null;
+
public static function getDefaultSort(): string
{
return static::POSITION;
@@ -28,6 +31,7 @@ public static function getAllowedFilterFields(): array
self::STATUS,
self::PRODUCT_PRICE_ID,
self::EMAIL,
+ self::EVENT_OCCURRENCE_ID,
];
}
@@ -54,6 +58,7 @@ public static function getAllowedSorts(): AllowedSorts
public function setOrder(?OrderDomainObject $order): self
{
$this->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/DomainObjects/WebhookDomainObject.php b/backend/app/DomainObjects/WebhookDomainObject.php
index fca8e94458..abd5d523a5 100644
--- a/backend/app/DomainObjects/WebhookDomainObject.php
+++ b/backend/app/DomainObjects/WebhookDomainObject.php
@@ -11,6 +11,7 @@ class WebhookDomainObject extends Generated\WebhookDomainObjectAbstract
public function setLogs(Collection $logs): static
{
$this->logs = $logs;
+
return $this;
}
diff --git a/backend/app/DomainObjects/WebhookLogDomainObject.php b/backend/app/DomainObjects/WebhookLogDomainObject.php
index 2a83d3b31e..495201bd55 100644
--- a/backend/app/DomainObjects/WebhookLogDomainObject.php
+++ b/backend/app/DomainObjects/WebhookLogDomainObject.php
@@ -2,6 +2,4 @@
namespace HiEvents\DomainObjects;
-class WebhookLogDomainObject extends Generated\WebhookLogDomainObjectAbstract
-{
-}
+class WebhookLogDomainObject extends Generated\WebhookLogDomainObjectAbstract {}
diff --git a/backend/app/Events/CapacityChangedEvent.php b/backend/app/Events/CapacityChangedEvent.php
index f0894827a0..2d73829545 100644
--- a/backend/app/Events/CapacityChangedEvent.php
+++ b/backend/app/Events/CapacityChangedEvent.php
@@ -7,12 +7,11 @@
readonly class CapacityChangedEvent
{
public function __construct(
- public int $eventId,
+ public int $eventId,
public CapacityChangeDirection $direction,
- public ?int $productId = null,
- public ?int $productPriceId = null,
- public ?int $newCapacity = null,
- )
- {
- }
+ public ?int $productId = null,
+ public ?int $productPriceId = null,
+ public ?int $newCapacity = null,
+ public ?int $eventOccurrenceId = null,
+ ) {}
}
diff --git a/backend/app/Events/EventUpdateEvent.php b/backend/app/Events/EventUpdateEvent.php
index 77f5d7ab7a..3631252185 100644
--- a/backend/app/Events/EventUpdateEvent.php
+++ b/backend/app/Events/EventUpdateEvent.php
@@ -11,7 +11,5 @@ class EventUpdateEvent
public function __construct(
private readonly EventDomainObject $event,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Events/OccurrenceCancelledEvent.php b/backend/app/Events/OccurrenceCancelledEvent.php
new file mode 100644
index 0000000000..09d9372a0f
--- /dev/null
+++ b/backend/app/Events/OccurrenceCancelledEvent.php
@@ -0,0 +1,16 @@
+setUser([
'id' => $user->id,
'email' => $user->email,
- 'username' => trim($user->first_name . ' ' . $user->last_name),
+ 'username' => trim($user->first_name.' '.$user->last_name),
'ip_address' => $ip,
]);
@@ -73,9 +76,9 @@ public function report(Throwable $e)
/**
* Render an exception into an HTTP response.
*
- * @param \Illuminate\Http\Request $request
- * @param Throwable $exception
- * @return \Illuminate\Http\JsonResponse|\Illuminate\Http\Response
+ * @param Request $request
+ * @return JsonResponse|Response
+ *
* @throws Throwable
*/
public function render($request, Throwable $exception)
diff --git a/backend/app/Exceptions/InvalidEmailTemplateException.php b/backend/app/Exceptions/InvalidEmailTemplateException.php
index f26f1f0da5..ee199f8656 100644
--- a/backend/app/Exceptions/InvalidEmailTemplateException.php
+++ b/backend/app/Exceptions/InvalidEmailTemplateException.php
@@ -4,7 +4,4 @@
use Exception;
-class InvalidEmailTemplateException extends Exception
-{
-
-}
\ No newline at end of file
+class InvalidEmailTemplateException extends Exception {}
diff --git a/backend/app/Exceptions/InvalidOccurrenceDatesException.php b/backend/app/Exceptions/InvalidOccurrenceDatesException.php
new file mode 100644
index 0000000000..7c4694e59c
--- /dev/null
+++ b/backend/app/Exceptions/InvalidOccurrenceDatesException.php
@@ -0,0 +1,7 @@
+affiliates = $affiliates;
+
return $this;
}
- public function collection(): AnonymousResourceCollection
+ public function collection(): Collection
{
- return AffiliateResource::collection($this->affiliates);
+ return collect($this->affiliates->items());
}
public function headings(): array
@@ -44,8 +44,7 @@ public function headings(): array
}
/**
- * @param AffiliateDomainObject $affiliate
- * @return array
+ * @param AffiliateDomainObject $affiliate
*/
public function map($affiliate): array
{
@@ -68,4 +67,4 @@ public function styles(Worksheet $sheet): array
1 => ['font' => ['bold' => true]],
];
}
-}
\ No newline at end of file
+}
diff --git a/backend/app/Exports/AnswerExportSheets/AttendeeAnswersSheet.php b/backend/app/Exports/AnswerExportSheets/AttendeeAnswersSheet.php
index c813e0fde0..e5e931c33c 100644
--- a/backend/app/Exports/AnswerExportSheets/AttendeeAnswersSheet.php
+++ b/backend/app/Exports/AnswerExportSheets/AttendeeAnswersSheet.php
@@ -17,21 +17,12 @@
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
-class AttendeeAnswersSheet implements
- FromCollection,
- WithHeadings,
- WithMapping,
- WithStyles,
- WithTitle,
- WithColumnWidths,
- ShouldAutoSize
+class AttendeeAnswersSheet implements FromCollection, WithHeadings, WithMapping, WithStyles, WithTitle, WithColumnWidths, ShouldAutoSize
{
public function __construct(
- private readonly Collection $answers,
+ private readonly Collection $answers,
private readonly QuestionAnswerFormatter $questionAnswerFormatter,
- )
- {
- }
+ ) {}
public function collection(): Collection
{
@@ -53,7 +44,7 @@ public function headings(): array
}
/**
- * @param QuestionAndAnswerViewDomainObject $row
+ * @param QuestionAndAnswerViewDomainObject $row
*/
public function map($row): array
{
@@ -64,7 +55,7 @@ public function map($row): array
);
$linkText = __('View Order');
- $hyperlink = '=HYPERLINK("' . $orderUrl . '","' . $linkText . '")';
+ $hyperlink = '=HYPERLINK("'.$orderUrl.'","'.$linkText.'")';
return [
$row->getTitle(),
@@ -74,7 +65,7 @@ public function map($row): array
),
$row->getOrderPublicId() ?? '',
$row->getOrderEmail() ?? '',
- trim($row->getFirstName() . ' ' . $row->getLastName()),
+ trim($row->getFirstName().' '.$row->getLastName()),
$row->getAttendeeEmail() ?? '',
$row->getProductTitle() ?? '',
$hyperlink,
@@ -90,7 +81,7 @@ public function styles(Worksheet $sheet): array
$highestRow = $sheet->getHighestRow();
if ($highestRow > 1) {
- $sheet->getStyle('H2:H' . $highestRow)->applyFromArray([
+ $sheet->getStyle('H2:H'.$highestRow)->applyFromArray([
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_CENTER,
],
@@ -120,9 +111,6 @@ public function columnWidths(): array
];
}
- /**
- * @return string
- */
public function title(): string
{
return __('Attendee Answers');
diff --git a/backend/app/Exports/AnswerExportSheets/OrderAnswersSheet.php b/backend/app/Exports/AnswerExportSheets/OrderAnswersSheet.php
index a4a87ad331..b5eb69e2d0 100644
--- a/backend/app/Exports/AnswerExportSheets/OrderAnswersSheet.php
+++ b/backend/app/Exports/AnswerExportSheets/OrderAnswersSheet.php
@@ -17,21 +17,12 @@
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
-class OrderAnswersSheet implements
- FromCollection,
- WithHeadings,
- WithMapping,
- WithStyles,
- WithTitle,
- WithColumnWidths,
- ShouldAutoSize
+class OrderAnswersSheet implements FromCollection, WithHeadings, WithMapping, WithStyles, WithTitle, WithColumnWidths, ShouldAutoSize
{
public function __construct(
- private readonly Collection $answers,
+ private readonly Collection $answers,
private readonly QuestionAnswerFormatter $questionAnswerFormatter,
- )
- {
- }
+ ) {}
public function collection(): Collection
{
@@ -51,7 +42,7 @@ public function headings(): array
}
/**
- * @param QuestionAndAnswerViewDomainObject $row
+ * @param QuestionAndAnswerViewDomainObject $row
*/
public function map($row): array
{
@@ -62,7 +53,7 @@ public function map($row): array
);
$linkText = __('View Order');
- $hyperlink = '=HYPERLINK("' . $orderUrl . '","' . $linkText . '")';
+ $hyperlink = '=HYPERLINK("'.$orderUrl.'","'.$linkText.'")';
return [
$row->getTitle(),
@@ -71,7 +62,7 @@ public function map($row): array
QuestionTypeEnum::fromName($row->getQuestionType())
),
$row->getOrderPublicId() ?? '',
- trim($row->getOrderFirstName() . ' ' . $row->getOrderLastName()),
+ trim($row->getOrderFirstName().' '.$row->getOrderLastName()),
$row->getOrderEmail() ?? '',
$hyperlink,
];
@@ -87,7 +78,7 @@ public function styles(Worksheet $sheet): array
// Style the URL column cells but exclude the header row
if ($highestRow > 1) {
- $sheet->getStyle('F2:F' . $highestRow)->applyFromArray([
+ $sheet->getStyle('F2:F'.$highestRow)->applyFromArray([
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_CENTER,
],
diff --git a/backend/app/Exports/AnswerExportSheets/ProductAnswersSheet.php b/backend/app/Exports/AnswerExportSheets/ProductAnswersSheet.php
index e0882fbc20..6f495560ff 100644
--- a/backend/app/Exports/AnswerExportSheets/ProductAnswersSheet.php
+++ b/backend/app/Exports/AnswerExportSheets/ProductAnswersSheet.php
@@ -17,21 +17,12 @@
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
-class ProductAnswersSheet implements
- FromCollection,
- WithHeadings,
- WithMapping,
- WithStyles,
- WithTitle,
- WithColumnWidths,
- ShouldAutoSize
+class ProductAnswersSheet implements FromCollection, WithHeadings, WithMapping, WithStyles, WithTitle, WithColumnWidths, ShouldAutoSize
{
public function __construct(
- private readonly Collection $answers,
+ private readonly Collection $answers,
private readonly QuestionAnswerFormatter $questionAnswerFormatter,
- )
- {
- }
+ ) {}
public function collection(): Collection
{
@@ -52,7 +43,7 @@ public function headings(): array
}
/**
- * @param QuestionAndAnswerViewDomainObject $row
+ * @param QuestionAndAnswerViewDomainObject $row
*/
public function map($row): array
{
@@ -63,7 +54,7 @@ public function map($row): array
);
$linkText = __('View Order');
- $hyperlink = '=HYPERLINK("' . $orderUrl . '","' . $linkText . '")';
+ $hyperlink = '=HYPERLINK("'.$orderUrl.'","'.$linkText.'")';
return [
$row->getTitle(),
@@ -72,7 +63,7 @@ public function map($row): array
QuestionTypeEnum::fromName($row->getQuestionType())
),
$row->getOrderPublicId() ?? '',
- trim($row->getOrderFirstName() . ' ' . $row->getOrderLastName()),
+ trim($row->getOrderFirstName().' '.$row->getOrderLastName()),
$row->getOrderEmail() ?? '',
$row->getProductTitle() ?? '',
$hyperlink,
@@ -84,7 +75,7 @@ public function styles(Worksheet $sheet): array
$highestRow = $sheet->getHighestRow();
if ($highestRow > 1) {
- $sheet->getStyle('G2:G' . $highestRow)->applyFromArray([
+ $sheet->getStyle('G2:G'.$highestRow)->applyFromArray([
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_CENTER,
],
diff --git a/backend/app/Exports/AnswersExport.php b/backend/app/Exports/AnswersExport.php
index 4ce2e009f3..a377326eba 100644
--- a/backend/app/Exports/AnswersExport.php
+++ b/backend/app/Exports/AnswersExport.php
@@ -17,13 +17,12 @@ class AnswersExport implements WithMultipleSheets
public function __construct(
private readonly QuestionAnswerFormatter $questionAnswerFormatter,
- )
- {
- }
+ ) {}
public function withData(Collection $answers): AnswersExport
{
$this->answers = $answers;
+
return $this;
}
@@ -34,21 +33,21 @@ public function sheets(): array
})->sortBy([
['title', 'asc'],
['order_id', 'asc'],
- ['attendee_id', 'asc']
+ ['attendee_id', 'asc'],
]);
$productAnswers = $this->answers->filter(function (QuestionAndAnswerViewDomainObject $answer) {
return $answer->getBelongsTo() === QuestionBelongsTo::PRODUCT->name && $answer->getAttendeeId() === null;
})->sortBy([
['title', 'asc'],
- ['order_id', 'asc']
+ ['order_id', 'asc'],
]);
$orderAnswers = $this->answers->filter(function (QuestionAndAnswerViewDomainObject $answer) {
return $answer->getBelongsTo() === QuestionBelongsTo::ORDER->name;
})->sortBy([
['title', 'asc'],
- ['order_id', 'asc']
+ ['order_id', 'asc'],
]);
return [
diff --git a/backend/app/Exports/AttendeesExport.php b/backend/app/Exports/AttendeesExport.php
index d617a5ca5e..d25d1680e1 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;
@@ -24,30 +22,33 @@
class AttendeesExport implements FromCollection, WithHeadings, WithMapping, WithStyles
{
private LengthAwarePaginator|Collection $data;
+
private Collection $productQuestions;
+
private Collection $orderQuestions;
- public function __construct(private QuestionAnswerFormatter $questionAnswerFormatter)
- {
- }
+ public function __construct(private QuestionAnswerFormatter $questionAnswerFormatter) {}
public function withData(LengthAwarePaginator|Collection $data, Collection $productQuestions, Collection $orderQuestions): AttendeesExport
{
$this->data = $data;
$this->productQuestions = $productQuestions;
$this->orderQuestions = $orderQuestions;
+
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
{
- $productQuestionTitles = $this->productQuestions->map(fn($question) => $question->getTitle())->toArray();
- $orderQuestionsTitles = $this->orderQuestions->map(fn($orderQuestion) => $orderQuestion->getTitle())->toArray();
+ $productQuestionTitles = $this->productQuestions->map(fn ($question) => $question->getTitle())->toArray();
+ $orderQuestionsTitles = $this->orderQuestions->map(fn ($orderQuestion) => $orderQuestion->getTitle())->toArray();
return array_merge([
__('ID'),
@@ -59,6 +60,7 @@ public function headings(): array
__('Product ID'),
__('Product Name'),
__('Event ID'),
+ __('Occurrence Date'),
__('Public ID'),
__('Short ID'),
__('Created Date'),
@@ -68,14 +70,13 @@ public function headings(): array
}
/**
- * @param AttendeeDomainObject $attendee
- * @return array
+ * @param AttendeeDomainObject $attendee
*/
public function map($attendee): array
{
$productAnswers = $this->productQuestions->map(function (QuestionDomainObject $question) use ($attendee) {
$answer = $attendee->getQuestionAndAnswerViews()
- ->first(fn($qav) => $qav->getQuestionId() === $question->getId())?->getAnswer() ?? '';
+ ->first(fn ($qav) => $qav->getQuestionId() === $question->getId())?->getAnswer() ?? '';
return $this->questionAnswerFormatter->getAnswerAsText(
$answer,
@@ -87,7 +88,7 @@ public function map($attendee): array
/** @var OrderDomainObject $order */
$order = $attendee->getOrder();
$answer = $order->getQuestionAndAnswerViews()
- ->first(fn($qav) => $qav->getQuestionId() === $question->getId())?->getAnswer() ?? '';
+ ->first(fn ($qav) => $qav->getQuestionId() === $question->getId())?->getAnswer() ?? '';
return $this->questionAnswerFormatter->getAnswerAsText(
$answer,
@@ -99,19 +100,19 @@ public function map($attendee): array
$ticket = $attendee->getProduct();
$ticketName = $ticket?->getTitle();
if ($ticket && $ticket->getType() === ProductPriceType::TIERED->name) {
- $ticketName .= ' - ' . $ticket
- ->getProductPrices()
- ->first(fn(ProductPriceDomainObject $tp) => $tp->getId() === $attendee->getProductPriceId())
- ->getLabel();
+ $ticketName .= ' - '.$ticket
+ ->getProductPrices()
+ ->first(fn (ProductPriceDomainObject $tp) => $tp->getId() === $attendee->getProductPriceId())
+ ->getLabel();
}
- if (!$ticketName) {
+ if (! $ticketName) {
$ticketName = __('Unknown');
}
$checkIns = $attendee->getCheckIns()
? $attendee->getCheckIns()
- ->map(fn($checkIn) => sprintf(
+ ->map(fn ($checkIn) => sprintf(
'%s (%s)',
$checkIn->getCheckInList()?->getName() ?? __('Unknown'),
Carbon::parse($checkIn->getCreatedAt())->format('Y-m-d H:i:s')
@@ -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..d42da3d9f6 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,14 @@ public function map($order): array
);
});
+ $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 +110,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..3603c72eb7 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;
@@ -16,12 +16,15 @@ class PromoCodesExport implements FromCollection, WithHeadings, WithMapping, Wit
public function withData($data): PromoCodesExport
{
$this->data = $data;
+
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
@@ -31,6 +34,7 @@ public function headings(): array
'Code',
'Discount',
'Discount Type',
+ 'Discount Applies To',
'Max Allowed Uses',
'Expiry Date',
'Event ID',
@@ -46,6 +50,7 @@ public function map($discountCode): array
$discountCode->getCode(),
$discountCode->getDiscount(),
$discountCode->getDiscountType(),
+ $discountCode->getDiscountAppliesTo(),
$discountCode->getMaxAllowedUsages(),
$discountCode->getExpiryDate(),
$discountCode->getEventId(),
diff --git a/backend/app/Helper/AddressHelper.php b/backend/app/Helper/AddressHelper.php
index 3710426cb9..fced01cd69 100644
--- a/backend/app/Helper/AddressHelper.php
+++ b/backend/app/Helper/AddressHelper.php
@@ -17,12 +17,11 @@ public static function formatAddress(?array $address): string
$address['city'] ?? null,
$address['state_or_region'] ?? null,
$address['zip_or_postal_code'] ?? null,
- $address['country'] ?? null
+ $address['country'] ?? null,
];
- $filteredAddressParts = array_filter($addressParts, static fn($part) => !is_null($part) && $part !== '');
+ $filteredAddressParts = array_filter($addressParts, static fn ($part) => ! is_null($part) && $part !== '');
return implode(', ', $filteredAddressParts);
}
}
-
diff --git a/backend/app/Helper/Currency.php b/backend/app/Helper/Currency.php
index 618bc163cc..d0d30f1bfc 100644
--- a/backend/app/Helper/Currency.php
+++ b/backend/app/Helper/Currency.php
@@ -22,7 +22,7 @@ class Currency
'VUV',
'XAF',
'XOF',
- 'XPF'
+ 'XPF',
];
public static function isZeroDecimalCurrency(string $currencyCode): bool
@@ -33,7 +33,8 @@ public static function isZeroDecimalCurrency(string $currencyCode): bool
public static function format(float|int $amount, string $currencyCode, string $locale = 'en_US'): string
{
$currencyCode = strtoupper($currencyCode);
- $formatter = new NumberFormatter($locale . '@currency=' . $currencyCode, NumberFormatter::CURRENCY);
+ $formatter = new NumberFormatter($locale.'@currency='.$currencyCode, NumberFormatter::CURRENCY);
+
return $formatter->formatCurrency($amount, $currencyCode);
}
diff --git a/backend/app/Helper/EmailHelper.php b/backend/app/Helper/EmailHelper.php
index e959c21d19..4d70306ccb 100644
--- a/backend/app/Helper/EmailHelper.php
+++ b/backend/app/Helper/EmailHelper.php
@@ -26,6 +26,6 @@ public static function normalize(string $email): string
$local = preg_replace('/\+.*$/', '', $local);
}
- return $local . '@' . $domain;
+ return $local.'@'.$domain;
}
}
diff --git a/backend/app/Helper/IdHelper.php b/backend/app/Helper/IdHelper.php
index 5a124effb8..cb7935fb8d 100644
--- a/backend/app/Helper/IdHelper.php
+++ b/backend/app/Helper/IdHelper.php
@@ -7,13 +7,23 @@
class IdHelper
{
public const ATTENDEE_PREFIX = 'a';
+
public const ORDER_PREFIX = 'o';
+
public const EVENT_PREFIX = 'e';
+
public const ACCOUNT_PREFIX = 'acc';
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
{
return sprintf('%s_%s', $prefix, Str::random($length));
@@ -21,6 +31,6 @@ public static function shortId(string $prefix, int $length = 13): string
public static function publicId(string $prefix = '', string $suffix = '', int $length = 7): string
{
- return Str::upper($prefix . '-' . Str::random($length) . $suffix);
+ return Str::upper($prefix.'-'.Str::random($length).$suffix);
}
}
diff --git a/backend/app/Helper/StringHelper.php b/backend/app/Helper/StringHelper.php
index c4f14aeab8..57f29048de 100644
--- a/backend/app/Helper/StringHelper.php
+++ b/backend/app/Helper/StringHelper.php
@@ -20,7 +20,7 @@ public static function previewFromHtml(string $text, int $length = 100): string
$text = trim(preg_replace('/\s+/', ' ', $text));
if (strlen($text) > $length) {
- $text = mb_substr($text, 0, $length - 3) . '...';
+ $text = mb_substr($text, 0, $length - 3).'...';
}
return $text;
diff --git a/backend/app/Helper/Url.php b/backend/app/Helper/Url.php
index 9809a136ec..4d87c80fc7 100644
--- a/backend/app/Helper/Url.php
+++ b/backend/app/Helper/Url.php
@@ -5,59 +5,68 @@
class Url
{
public const STRIPE_CONNECT_RETURN_URL = 'app.frontend_urls.stripe_connect_return_url';
+
public const STRIPE_CONNECT_REFRESH_URL = 'app.frontend_urls.stripe_connect_refresh_url';
+
public const RESET_PASSWORD = 'app.frontend_urls.reset_password';
+
public const CONFIRM_EMAIL_CHANGE = 'app.frontend_urls.confirm_email_change';
+
public const ACCEPT_INVITATION = 'app.frontend_urls.accept_invitation';
+
public const CONFIRM_EMAIL_ADDRESS = 'app.frontend_urls.confirm_email_address';
+
public const EVENT_HOMEPAGE = 'app.frontend_urls.event_homepage';
+
public const ATTENDEE_TICKET = 'app.frontend_urls.attendee_product';
+
public const ORDER_SUMMARY = 'app.frontend_urls.order_summary';
+
public const ORDER_DETAILS = 'app.frontend_urls.order_details';
+
public const ORGANIZER_ORDER_SUMMARY = 'app.frontend_urls.organizer_order_summary';
+
public const TICKET_LOOKUP = 'app.frontend_urls.ticket_lookup';
public static function getFrontEndUrlFromConfig(string $key, array $queryParams = []): string
{
- $url = config('app.frontend_url') . config($key);
+ $url = config('app.frontend_url').config($key);
return self::addQueryParamsToUrl($queryParams, $url);
}
public static function getApiUrl(string $path, array $queryParams = []): string
{
- $url = rtrim(config('app.api_url'), '/') . '/' . ltrim($path, '/');
+ $url = rtrim(config('app.api_url'), '/').'/'.ltrim($path, '/');
return self::addQueryParamsToUrl($queryParams, $url);
}
/**
- * Generates a CDN URL for the given path if a CDN URL is configured.
- * Falls back to generating a URL using the specified or default filesystem disk.
- *
- * @param string $path The relative path to the asset.
- * @return string The fully qualified URL to the asset, either via CDN or storage disk.
- */
+ * @param string $path The relative path to the asset.
+ * @return string The fully qualified URL to the asset, either via CDN or storage disk.
+ */
public static function getCdnUrl(string $path): string
{
// Fetch the CDN URL from environment variables
// Checking against the env variable instead of config() as config falls back to the default value
// and we want to ensure that if the env variable is not set, we do not use a default value.
- $envCDNUrl = env('APP_CDN_URL');
+ $envCDNUrl = env('APP_CDN_URL');
if ($envCDNUrl) {
- return $envCDNUrl . '/' . $path;
- }
+ return $envCDNUrl.'/'.$path;
+ }
$disk = config('filesystems.public', 'public');
+
return app('filesystem')->disk($disk)->url($path);
}
private static function addQueryParamsToUrl(array $queryParams, mixed $url): mixed
{
- if (!empty($queryParams)) {
+ if (! empty($queryParams)) {
$query = http_build_query($queryParams);
- $url = rtrim($url, '/') . '?' . $query;
+ $url = rtrim($url, '/').'?'.$query;
}
return $url;
diff --git a/backend/app/Http/Actions/Accounts/CreateAccountAction.php b/backend/app/Http/Actions/Accounts/CreateAccountAction.php
index b7136554be..1cc4cfd1c7 100644
--- a/backend/app/Http/Actions/Accounts/CreateAccountAction.php
+++ b/backend/app/Http/Actions/Accounts/CreateAccountAction.php
@@ -26,11 +26,9 @@ class CreateAccountAction extends BaseAuthAction
{
public function __construct(
private readonly CreateAccountHandler $createAccountHandler,
- private readonly LoginHandler $loginHandler,
- private readonly LocaleService $localeService,
- )
- {
- }
+ private readonly LoginHandler $loginHandler,
+ private readonly LocaleService $localeService,
+ ) {}
/**
* @throws Throwable
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/GetAccountAction.php b/backend/app/Http/Actions/Admin/Accounts/GetAccountAction.php
index 9598207787..30cf9629dd 100644
--- a/backend/app/Http/Actions/Admin/Accounts/GetAccountAction.php
+++ b/backend/app/Http/Actions/Admin/Accounts/GetAccountAction.php
@@ -14,9 +14,7 @@ class GetAccountAction extends BaseAction
{
public function __construct(
private readonly GetAccountHandler $handler,
- )
- {
- }
+ ) {}
public function __invoke(int $accountId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Admin/Accounts/GetAllAccountsAction.php b/backend/app/Http/Actions/Admin/Accounts/GetAllAccountsAction.php
index a283e93707..70d60a9415 100644
--- a/backend/app/Http/Actions/Admin/Accounts/GetAllAccountsAction.php
+++ b/backend/app/Http/Actions/Admin/Accounts/GetAllAccountsAction.php
@@ -16,16 +16,14 @@ class GetAllAccountsAction extends BaseAction
{
public function __construct(
private readonly GetAllAccountsHandler $handler,
- )
- {
- }
+ ) {}
public function __invoke(Request $request): JsonResponse
{
$this->minimumAllowedRole(Role::SUPERADMIN);
$accounts = $this->handler->handle(new GetAllAccountsDTO(
- perPage: min((int)$request->query('per_page', 20), 100),
+ perPage: min((int) $request->query('per_page', 20), 100),
search: $request->query('search'),
));
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/Accounts/UpdateAccountMessagingTierAction.php b/backend/app/Http/Actions/Admin/Accounts/UpdateAccountMessagingTierAction.php
index b8f5567c50..b91d431ae2 100644
--- a/backend/app/Http/Actions/Admin/Accounts/UpdateAccountMessagingTierAction.php
+++ b/backend/app/Http/Actions/Admin/Accounts/UpdateAccountMessagingTierAction.php
@@ -17,8 +17,7 @@ class UpdateAccountMessagingTierAction extends BaseAction
public function __construct(
private readonly UpdateAccountMessagingTierHandler $handler,
private readonly GetAccountHandler $getAccountHandler,
- ) {
- }
+ ) {}
public function __invoke(Request $request, int $accountId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Admin/Attribution/GetUtmAttributionStatsAction.php b/backend/app/Http/Actions/Admin/Attribution/GetUtmAttributionStatsAction.php
index 2d907b25be..5e4e6d851b 100644
--- a/backend/app/Http/Actions/Admin/Attribution/GetUtmAttributionStatsAction.php
+++ b/backend/app/Http/Actions/Admin/Attribution/GetUtmAttributionStatsAction.php
@@ -15,9 +15,7 @@ class GetUtmAttributionStatsAction extends BaseAction
{
public function __construct(
private readonly GetUtmAttributionStatsHandler $handler,
- )
- {
- }
+ ) {}
public function __invoke(Request $request): JsonResponse
{
diff --git a/backend/app/Http/Actions/Admin/Configurations/CreateConfigurationAction.php b/backend/app/Http/Actions/Admin/Configurations/CreateConfigurationAction.php
index 6babb80c19..8170f4a0ba 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,9 +15,8 @@
class CreateConfigurationAction extends BaseAction
{
public function __construct(
- private readonly AccountConfigurationRepositoryInterface $repository,
- ) {
- }
+ private readonly OrganizerConfigurationRepositoryInterface $repository,
+ ) {}
public function __invoke(Request $request): JsonResponse
{
@@ -40,7 +39,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/DeleteConfigurationAction.php b/backend/app/Http/Actions/Admin/Configurations/DeleteConfigurationAction.php
index 650f0a8603..3f718ef1f1 100644
--- a/backend/app/Http/Actions/Admin/Configurations/DeleteConfigurationAction.php
+++ b/backend/app/Http/Actions/Admin/Configurations/DeleteConfigurationAction.php
@@ -8,7 +8,6 @@
use HiEvents\Exceptions\CannotDeleteEntityException;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Services\Application\Handlers\Admin\DeleteConfigurationHandler;
-use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Validation\ValidationException;
@@ -16,8 +15,7 @@ class DeleteConfigurationAction extends BaseAction
{
public function __construct(
private readonly DeleteConfigurationHandler $handler,
- ) {
- }
+ ) {}
public function __invoke(int $configurationId): Response
{
diff --git a/backend/app/Http/Actions/Admin/Configurations/GetAllConfigurationsAction.php b/backend/app/Http/Actions/Admin/Configurations/GetAllConfigurationsAction.php
index 0ffcf213eb..0136d83ef5 100644
--- a/backend/app/Http/Actions/Admin/Configurations/GetAllConfigurationsAction.php
+++ b/backend/app/Http/Actions/Admin/Configurations/GetAllConfigurationsAction.php
@@ -6,16 +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;
class GetAllConfigurationsAction extends BaseAction
{
public function __construct(
- private readonly AccountConfigurationRepositoryInterface $repository,
- ) {
- }
+ private readonly OrganizerConfigurationRepositoryInterface $repository,
+ ) {}
public function __invoke(): JsonResponse
{
@@ -24,7 +23,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..d95e7cf227 100644
--- a/backend/app/Http/Actions/Admin/Configurations/UpdateConfigurationAction.php
+++ b/backend/app/Http/Actions/Admin/Configurations/UpdateConfigurationAction.php
@@ -6,17 +6,16 @@
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,
+ ) {}
public function __invoke(Request $request, int $configurationId): JsonResponse
{
@@ -41,7 +40,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/Events/GetAllEventsAction.php b/backend/app/Http/Actions/Admin/Events/GetAllEventsAction.php
index 73dbc9ea19..dedbce92ad 100644
--- a/backend/app/Http/Actions/Admin/Events/GetAllEventsAction.php
+++ b/backend/app/Http/Actions/Admin/Events/GetAllEventsAction.php
@@ -16,16 +16,14 @@ class GetAllEventsAction extends BaseAction
{
public function __construct(
private readonly GetAllEventsHandler $handler,
- )
- {
- }
+ ) {}
public function __invoke(Request $request): JsonResponse
{
$this->minimumAllowedRole(Role::SUPERADMIN);
$events = $this->handler->handle(new GetAllEventsDTO(
- perPage: min((int)$request->query('per_page', 20), 100),
+ perPage: min((int) $request->query('per_page', 20), 100),
search: $request->query('search'),
sortBy: $request->query('sort_by', 'start_date'),
sortDirection: $request->query('sort_direction', 'desc'),
diff --git a/backend/app/Http/Actions/Admin/Events/GetUpcomingEventsAction.php b/backend/app/Http/Actions/Admin/Events/GetUpcomingEventsAction.php
index 4a2b66de00..b23d05ff6e 100644
--- a/backend/app/Http/Actions/Admin/Events/GetUpcomingEventsAction.php
+++ b/backend/app/Http/Actions/Admin/Events/GetUpcomingEventsAction.php
@@ -16,16 +16,14 @@ class GetUpcomingEventsAction extends BaseAction
{
public function __construct(
private readonly GetUpcomingEventsHandler $handler,
- )
- {
- }
+ ) {}
public function __invoke(Request $request): JsonResponse
{
$this->minimumAllowedRole(Role::SUPERADMIN);
$events = $this->handler->handle(new GetUpcomingEventsDTO(
- perPage: min((int)$request->query('per_page', 20), 100),
+ perPage: min((int) $request->query('per_page', 20), 100),
));
return $this->resourceResponse(
diff --git a/backend/app/Http/Actions/Admin/FailedJobs/DeleteAllFailedJobsAction.php b/backend/app/Http/Actions/Admin/FailedJobs/DeleteAllFailedJobsAction.php
index b757c15fb2..e2593e0b41 100644
--- a/backend/app/Http/Actions/Admin/FailedJobs/DeleteAllFailedJobsAction.php
+++ b/backend/app/Http/Actions/Admin/FailedJobs/DeleteAllFailedJobsAction.php
@@ -13,8 +13,7 @@ class DeleteAllFailedJobsAction extends BaseAction
{
public function __construct(
private readonly DeleteFailedJobHandler $handler,
- ) {
- }
+ ) {}
public function __invoke(): JsonResponse
{
diff --git a/backend/app/Http/Actions/Admin/FailedJobs/DeleteFailedJobAction.php b/backend/app/Http/Actions/Admin/FailedJobs/DeleteFailedJobAction.php
index 4fd220aa32..4efd804dea 100644
--- a/backend/app/Http/Actions/Admin/FailedJobs/DeleteFailedJobAction.php
+++ b/backend/app/Http/Actions/Admin/FailedJobs/DeleteFailedJobAction.php
@@ -13,8 +13,7 @@ class DeleteFailedJobAction extends BaseAction
{
public function __construct(
private readonly DeleteFailedJobHandler $handler,
- ) {
- }
+ ) {}
public function __invoke(int $jobId): JsonResponse
{
@@ -22,7 +21,7 @@ public function __invoke(int $jobId): JsonResponse
$deleted = $this->handler->handle($jobId);
- if (!$deleted) {
+ if (! $deleted) {
return $this->errorResponse(__('Failed job not found'), 404);
}
diff --git a/backend/app/Http/Actions/Admin/FailedJobs/GetAllFailedJobsAction.php b/backend/app/Http/Actions/Admin/FailedJobs/GetAllFailedJobsAction.php
index 1aeb5fd826..91f68c0c13 100644
--- a/backend/app/Http/Actions/Admin/FailedJobs/GetAllFailedJobsAction.php
+++ b/backend/app/Http/Actions/Admin/FailedJobs/GetAllFailedJobsAction.php
@@ -16,15 +16,14 @@ class GetAllFailedJobsAction extends BaseAction
{
public function __construct(
private readonly GetAllFailedJobsHandler $handler,
- ) {
- }
+ ) {}
public function __invoke(Request $request): JsonResponse
{
$this->minimumAllowedRole(Role::SUPERADMIN);
$jobs = $this->handler->handle(new GetAllFailedJobsDTO(
- perPage: min((int)$request->query('per_page', 20), 100),
+ perPage: min((int) $request->query('per_page', 20), 100),
search: $request->query('search'),
queue: $request->query('queue'),
sortBy: $request->query('sort_by', 'failed_at'),
diff --git a/backend/app/Http/Actions/Admin/FailedJobs/RetryAllFailedJobsAction.php b/backend/app/Http/Actions/Admin/FailedJobs/RetryAllFailedJobsAction.php
index 22de9d8bf8..6bdb233d03 100644
--- a/backend/app/Http/Actions/Admin/FailedJobs/RetryAllFailedJobsAction.php
+++ b/backend/app/Http/Actions/Admin/FailedJobs/RetryAllFailedJobsAction.php
@@ -13,8 +13,7 @@ class RetryAllFailedJobsAction extends BaseAction
{
public function __construct(
private readonly RetryFailedJobHandler $handler,
- ) {
- }
+ ) {}
public function __invoke(): JsonResponse
{
diff --git a/backend/app/Http/Actions/Admin/FailedJobs/RetryFailedJobAction.php b/backend/app/Http/Actions/Admin/FailedJobs/RetryFailedJobAction.php
index 960db47135..db61d6156c 100644
--- a/backend/app/Http/Actions/Admin/FailedJobs/RetryFailedJobAction.php
+++ b/backend/app/Http/Actions/Admin/FailedJobs/RetryFailedJobAction.php
@@ -13,8 +13,7 @@ class RetryFailedJobAction extends BaseAction
{
public function __construct(
private readonly RetryFailedJobHandler $handler,
- ) {
- }
+ ) {}
public function __invoke(int $jobId): JsonResponse
{
@@ -22,7 +21,7 @@ public function __invoke(int $jobId): JsonResponse
$retried = $this->handler->handle($jobId);
- if (!$retried) {
+ if (! $retried) {
return $this->errorResponse(__('Failed job not found'), 404);
}
diff --git a/backend/app/Http/Actions/Admin/GetMessagingTiersAction.php b/backend/app/Http/Actions/Admin/GetMessagingTiersAction.php
index 7838e67f6b..29e100cd1e 100644
--- a/backend/app/Http/Actions/Admin/GetMessagingTiersAction.php
+++ b/backend/app/Http/Actions/Admin/GetMessagingTiersAction.php
@@ -14,8 +14,7 @@ class GetMessagingTiersAction extends BaseAction
{
public function __construct(
private readonly AccountMessagingTierRepositoryInterface $messagingTierRepository,
- ) {
- }
+ ) {}
public function __invoke(): JsonResponse
{
diff --git a/backend/app/Http/Actions/Admin/Messages/ApproveMessageAction.php b/backend/app/Http/Actions/Admin/Messages/ApproveMessageAction.php
index 1ecec56d9c..14e988a568 100644
--- a/backend/app/Http/Actions/Admin/Messages/ApproveMessageAction.php
+++ b/backend/app/Http/Actions/Admin/Messages/ApproveMessageAction.php
@@ -13,8 +13,7 @@ class ApproveMessageAction extends BaseAction
{
public function __construct(
private readonly ApproveMessageHandler $handler,
- ) {
- }
+ ) {}
public function __invoke(int $messageId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Admin/Messages/GetAllMessagesAction.php b/backend/app/Http/Actions/Admin/Messages/GetAllMessagesAction.php
index 3ca9d9cc07..95140e6a0c 100644
--- a/backend/app/Http/Actions/Admin/Messages/GetAllMessagesAction.php
+++ b/backend/app/Http/Actions/Admin/Messages/GetAllMessagesAction.php
@@ -16,15 +16,14 @@ class GetAllMessagesAction extends BaseAction
{
public function __construct(
private readonly GetAllMessagesForAdminHandler $handler,
- ) {
- }
+ ) {}
public function __invoke(Request $request): JsonResponse
{
$this->minimumAllowedRole(Role::SUPERADMIN);
$messages = $this->handler->handle(new GetAllMessagesForAdminDTO(
- perPage: min((int)$request->query('per_page', 20), 100),
+ perPage: min((int) $request->query('per_page', 20), 100),
search: $request->query('search'),
status: $request->query('status'),
type: $request->query('type'),
diff --git a/backend/app/Http/Actions/Admin/Orders/GetAllOrdersAction.php b/backend/app/Http/Actions/Admin/Orders/GetAllOrdersAction.php
index 3e27fc0ef1..d4ec6bf380 100644
--- a/backend/app/Http/Actions/Admin/Orders/GetAllOrdersAction.php
+++ b/backend/app/Http/Actions/Admin/Orders/GetAllOrdersAction.php
@@ -16,16 +16,14 @@ class GetAllOrdersAction extends BaseAction
{
public function __construct(
private readonly GetAllOrdersHandler $handler,
- )
- {
- }
+ ) {}
public function __invoke(Request $request): JsonResponse
{
$this->minimumAllowedRole(Role::SUPERADMIN);
$orders = $this->handler->handle(new GetAllOrdersDTO(
- perPage: min((int)$request->query('per_page', 20), 100),
+ perPage: min((int) $request->query('per_page', 20), 100),
search: $request->query('search'),
sortBy: $request->query('sort_by', 'created_at'),
sortDirection: $request->query('sort_direction', 'desc'),
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..12506ed308
--- /dev/null
+++ b/backend/app/Http/Actions/Admin/Organizers/AssignOrganizerConfigurationAction.php
@@ -0,0 +1,38 @@
+minimumAllowedRole(Role::SUPERADMIN);
+
+ $validated = $request->validate([
+ 'configuration_id' => [
+ 'required',
+ 'integer',
+ Rule::exists('organizer_configurations', 'id')->whereNull('deleted_at'),
+ ],
+ ]);
+
+ $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..f798094108
--- /dev/null
+++ b/backend/app/Http/Actions/Admin/Organizers/UpdateOrganizerConfigurationAction.php
@@ -0,0 +1,42 @@
+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..bca0e6b5e1 100644
--- a/backend/app/Http/Actions/Admin/Accounts/UpdateAccountVatSettingAction.php
+++ b/backend/app/Http/Actions/Admin/Organizers/UpdateOrganizerVatSettingAction.php
@@ -2,25 +2,23 @@
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 +31,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 +42,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/Admin/Stats/GetAdminDashboardDataAction.php b/backend/app/Http/Actions/Admin/Stats/GetAdminDashboardDataAction.php
index 32b348920d..32c4b61917 100644
--- a/backend/app/Http/Actions/Admin/Stats/GetAdminDashboardDataAction.php
+++ b/backend/app/Http/Actions/Admin/Stats/GetAdminDashboardDataAction.php
@@ -15,16 +15,15 @@ class GetAdminDashboardDataAction extends BaseAction
{
public function __construct(
private readonly GetAdminDashboardDataHandler $handler,
- ) {
- }
+ ) {}
public function __invoke(Request $request): JsonResponse
{
$this->minimumAllowedRole(Role::SUPERADMIN);
$data = $this->handler->handle(new GetAdminDashboardDataDTO(
- days: min((int)$request->query('days', 14), 90),
- limit: min((int)$request->query('limit', 10), 50),
+ days: min((int) $request->query('days', 14), 90),
+ limit: min((int) $request->query('limit', 10), 50),
));
return $this->jsonResponse($data->toArray());
diff --git a/backend/app/Http/Actions/Admin/Stats/GetAdminStatsAction.php b/backend/app/Http/Actions/Admin/Stats/GetAdminStatsAction.php
index 2d0e76a709..5bb098b7e0 100644
--- a/backend/app/Http/Actions/Admin/Stats/GetAdminStatsAction.php
+++ b/backend/app/Http/Actions/Admin/Stats/GetAdminStatsAction.php
@@ -13,9 +13,7 @@ class GetAdminStatsAction extends BaseAction
{
public function __construct(
private readonly GetAdminStatsHandler $handler,
- )
- {
- }
+ ) {}
public function __invoke(): JsonResponse
{
diff --git a/backend/app/Http/Actions/Admin/Users/GetAllUsersAction.php b/backend/app/Http/Actions/Admin/Users/GetAllUsersAction.php
index edaddd7069..e100949e3a 100644
--- a/backend/app/Http/Actions/Admin/Users/GetAllUsersAction.php
+++ b/backend/app/Http/Actions/Admin/Users/GetAllUsersAction.php
@@ -16,16 +16,14 @@ class GetAllUsersAction extends BaseAction
{
public function __construct(
private readonly GetAllUsersHandler $handler,
- )
- {
- }
+ ) {}
public function __invoke(Request $request): JsonResponse
{
$this->minimumAllowedRole(Role::SUPERADMIN);
$users = $this->handler->handle(new GetAllUsersDTO(
- perPage: min((int)$request->query('per_page', 20), 100),
+ perPage: min((int) $request->query('per_page', 20), 100),
search: $request->query('search'),
));
diff --git a/backend/app/Http/Actions/Admin/Users/StartImpersonationAction.php b/backend/app/Http/Actions/Admin/Users/StartImpersonationAction.php
index d28131191b..182a383793 100644
--- a/backend/app/Http/Actions/Admin/Users/StartImpersonationAction.php
+++ b/backend/app/Http/Actions/Admin/Users/StartImpersonationAction.php
@@ -15,16 +15,14 @@ class StartImpersonationAction extends BaseAuthAction
{
public function __construct(
private readonly StartImpersonationHandler $handler,
- )
- {
- }
+ ) {}
public function __invoke(Request $request, int $userId): JsonResponse
{
$this->minimumAllowedRole(Role::SUPERADMIN);
$this->validate($request, [
- 'account_id' => 'required|exists:accounts,id'
+ 'account_id' => 'required|exists:accounts,id',
]);
$token = $this->handler->handle(new StartImpersonationDTO(
@@ -36,7 +34,7 @@ public function __invoke(Request $request, int $userId): JsonResponse
$response = $this->jsonResponse([
'message' => __('Impersonation started'),
'redirect_url' => '/manage/events',
- 'token' => $token
+ 'token' => $token,
]);
return $this->addTokenToResponse($response, $token);
diff --git a/backend/app/Http/Actions/Admin/Users/StopImpersonationAction.php b/backend/app/Http/Actions/Admin/Users/StopImpersonationAction.php
index 43a6eabd7a..72118a97ec 100644
--- a/backend/app/Http/Actions/Admin/Users/StopImpersonationAction.php
+++ b/backend/app/Http/Actions/Admin/Users/StopImpersonationAction.php
@@ -14,16 +14,14 @@ class StopImpersonationAction extends BaseAuthAction
{
public function __construct(
private readonly StopImpersonationHandler $handler,
- private readonly AuthManager $authManager,
- )
- {
- }
+ private readonly AuthManager $authManager,
+ ) {}
public function __invoke(): JsonResponse
{
$isImpersonating = $this->authManager->payload()->get('is_impersonating');
- if (!$isImpersonating) {
+ if (! $isImpersonating) {
return $this->errorResponse(__('Not currently impersonating'));
}
@@ -36,7 +34,7 @@ public function __invoke(): JsonResponse
$response = $this->jsonResponse([
'message' => __('Impersonation ended'),
'redirect_url' => '/admin/users',
- 'token' => $token
+ 'token' => $token,
]);
return $this->addTokenToResponse($response, $token);
diff --git a/backend/app/Http/Actions/Affiliates/CreateAffiliateAction.php b/backend/app/Http/Actions/Affiliates/CreateAffiliateAction.php
index 07949d3dcb..d7dc8c67cf 100644
--- a/backend/app/Http/Actions/Affiliates/CreateAffiliateAction.php
+++ b/backend/app/Http/Actions/Affiliates/CreateAffiliateAction.php
@@ -20,9 +20,7 @@ class CreateAffiliateAction extends BaseAction
{
public function __construct(
private readonly CreateAffiliateHandler $createAffiliateHandler
- )
- {
- }
+ ) {}
/**
* @throws ValidationException
diff --git a/backend/app/Http/Actions/Affiliates/DeleteAffiliateAction.php b/backend/app/Http/Actions/Affiliates/DeleteAffiliateAction.php
index 13760f4196..c31a98e92e 100644
--- a/backend/app/Http/Actions/Affiliates/DeleteAffiliateAction.php
+++ b/backend/app/Http/Actions/Affiliates/DeleteAffiliateAction.php
@@ -14,9 +14,7 @@ class DeleteAffiliateAction extends BaseAction
{
public function __construct(
private readonly DeleteAffiliateHandler $deleteAffiliateHandler
- )
- {
- }
+ ) {}
public function __invoke(Request $request, int $eventId, int $affiliateId): Response
{
diff --git a/backend/app/Http/Actions/Affiliates/ExportAffiliatesAction.php b/backend/app/Http/Actions/Affiliates/ExportAffiliatesAction.php
index 56dc3c8828..b00f84e68b 100644
--- a/backend/app/Http/Actions/Affiliates/ExportAffiliatesAction.php
+++ b/backend/app/Http/Actions/Affiliates/ExportAffiliatesAction.php
@@ -16,10 +16,8 @@ class ExportAffiliatesAction extends BaseAction
{
public function __construct(
private readonly AffiliateRepositoryInterface $affiliateRepository,
- private readonly AffiliatesExport $export
- )
- {
- }
+ private readonly AffiliatesExport $export
+ ) {}
public function __invoke(int $eventId): BinaryFileResponse
{
@@ -35,4 +33,4 @@ public function __invoke(int $eventId): BinaryFileResponse
'affiliates.xlsx'
);
}
-}
\ No newline at end of file
+}
diff --git a/backend/app/Http/Actions/Affiliates/GetAffiliateAction.php b/backend/app/Http/Actions/Affiliates/GetAffiliateAction.php
index ad26455233..e5db5c5479 100644
--- a/backend/app/Http/Actions/Affiliates/GetAffiliateAction.php
+++ b/backend/app/Http/Actions/Affiliates/GetAffiliateAction.php
@@ -14,9 +14,7 @@
class GetAffiliateAction extends BaseAction
{
- public function __construct(private readonly AffiliateRepositoryInterface $affiliateRepository)
- {
- }
+ public function __construct(private readonly AffiliateRepositoryInterface $affiliateRepository) {}
public function __invoke(Request $request, int $eventId, int $affiliateId): JsonResponse
{
@@ -27,7 +25,7 @@ public function __invoke(Request $request, int $eventId, int $affiliateId): Json
'id' => $affiliateId,
]);
- if (!$affiliate) {
+ if (! $affiliate) {
throw new NotFoundHttpException(__('Affiliate not found'));
}
diff --git a/backend/app/Http/Actions/Affiliates/GetAffiliatesAction.php b/backend/app/Http/Actions/Affiliates/GetAffiliatesAction.php
index 699efb1e6e..9a3f8946d4 100644
--- a/backend/app/Http/Actions/Affiliates/GetAffiliatesAction.php
+++ b/backend/app/Http/Actions/Affiliates/GetAffiliatesAction.php
@@ -15,9 +15,7 @@
class GetAffiliatesAction extends BaseAction
{
- public function __construct(private readonly AffiliateRepositoryInterface $affiliateRepository)
- {
- }
+ public function __construct(private readonly AffiliateRepositoryInterface $affiliateRepository) {}
public function __invoke(Request $request, int $eventId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Affiliates/UpdateAffiliateAction.php b/backend/app/Http/Actions/Affiliates/UpdateAffiliateAction.php
index 57c5c7a296..1592287137 100644
--- a/backend/app/Http/Actions/Affiliates/UpdateAffiliateAction.php
+++ b/backend/app/Http/Actions/Affiliates/UpdateAffiliateAction.php
@@ -17,9 +17,7 @@ class UpdateAffiliateAction extends BaseAction
{
public function __construct(
private readonly UpdateAffiliateHandler $updateAffiliateHandler
- )
- {
- }
+ ) {}
public function __invoke(UpdateAffiliateRequest $request, int $eventId, int $affiliateId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Attendees/CheckInAttendeeAction.php b/backend/app/Http/Actions/Attendees/CheckInAttendeeAction.php
index 2717865eb5..7903af585d 100644
--- a/backend/app/Http/Actions/Attendees/CheckInAttendeeAction.php
+++ b/backend/app/Http/Actions/Attendees/CheckInAttendeeAction.php
@@ -16,9 +16,7 @@ class CheckInAttendeeAction extends BaseAction
{
public function __construct(
private readonly CheckInAttendeeHandler $checkInAttendeeHandler
- )
- {
- }
+ ) {}
public function __invoke(CheckInAttendeeRequest $request, int $eventId, string $attendeePublicId): JsonResponse
{
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/EditAttendeeAction.php b/backend/app/Http/Actions/Attendees/EditAttendeeAction.php
index ede204d4fc..5a3ddbd0c8 100644
--- a/backend/app/Http/Actions/Attendees/EditAttendeeAction.php
+++ b/backend/app/Http/Actions/Attendees/EditAttendeeAction.php
@@ -16,9 +16,7 @@
class EditAttendeeAction extends BaseAction
{
public function __construct(
- private readonly EditAttendeeHandler $handler)
- {
- }
+ private readonly EditAttendeeHandler $handler) {}
/**
* @throws ValidationException
diff --git a/backend/app/Http/Actions/Attendees/ExportAttendeesAction.php b/backend/app/Http/Actions/Attendees/ExportAttendeesAction.php
index dce25e228a..49b1942dcc 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,26 +16,27 @@
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;
class ExportAttendeesAction extends BaseAction
{
public function __construct(
- private readonly AttendeesExport $export,
+ private readonly AttendeesExport $export,
private readonly AttendeeRepositoryInterface $attendeeRepository,
private readonly QuestionRepositoryInterface $questionRepository
- )
- {
- }
+ ) {}
/**
* @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(
@@ -61,11 +63,15 @@ public function __invoke(int $eventId): BinaryFileResponse
nested: [
new Relationship(
domainObject: QuestionAndAnswerViewDomainObject::class
- )
+ ),
],
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/GetAttendeeAction.php b/backend/app/Http/Actions/Attendees/GetAttendeeAction.php
index 16cc30d2f7..832882b2b3 100644
--- a/backend/app/Http/Actions/Attendees/GetAttendeeAction.php
+++ b/backend/app/Http/Actions/Attendees/GetAttendeeAction.php
@@ -5,6 +5,9 @@
use HiEvents\DomainObjects\AttendeeCheckInDomainObject;
use HiEvents\DomainObjects\CheckInListDomainObject;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventLocationDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
+use HiEvents\DomainObjects\LocationDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\DomainObjects\QuestionAndAnswerViewDomainObject;
@@ -47,12 +50,21 @@ public function __invoke(int $eventId, int $attendeeId): Response|JsonResponse
],
name: 'check_ins'
))
+ ->loadRelation(new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ], name: 'event_location'),
+ ],
+ name: 'event_occurrence',
+ ))
->findFirstWhere([
'id' => $attendeeId,
'event_id' => $eventId,
]);
- if (!$attendee) {
+ if (! $attendee) {
return $this->notFoundResponse();
}
diff --git a/backend/app/Http/Actions/Attendees/GetAttendeeActionPublic.php b/backend/app/Http/Actions/Attendees/GetAttendeeActionPublic.php
index 5be7ddf396..ea6c35e1dc 100644
--- a/backend/app/Http/Actions/Attendees/GetAttendeeActionPublic.php
+++ b/backend/app/Http/Actions/Attendees/GetAttendeeActionPublic.php
@@ -2,7 +2,10 @@
namespace HiEvents\Http\Actions\Attendees;
+use HiEvents\DomainObjects\EventLocationDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\Generated\AttendeeDomainObjectAbstract;
+use HiEvents\DomainObjects\LocationDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\Http\Actions\BaseAction;
@@ -34,11 +37,24 @@ public function __invoke(int $eventId, string $attendeeShortId): JsonResponse|Re
domainObject: ProductPriceDomainObject::class,
),
], name: 'product'))
+ ->loadRelation(new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: EventLocationDomainObject::class,
+ nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ],
+ name: 'event_location',
+ ),
+ ],
+ name: 'event_occurrence',
+ ))
->findFirstWhere([
- AttendeeDomainObjectAbstract::SHORT_ID => $attendeeShortId
+ AttendeeDomainObjectAbstract::SHORT_ID => $attendeeShortId,
]);
- if (!$attendee) {
+ if (! $attendee) {
return $this->notFoundResponse();
}
diff --git a/backend/app/Http/Actions/Attendees/GetAttendeesAction.php b/backend/app/Http/Actions/Attendees/GetAttendeesAction.php
index 65a4582315..d670341881 100644
--- a/backend/app/Http/Actions/Attendees/GetAttendeesAction.php
+++ b/backend/app/Http/Actions/Attendees/GetAttendeesAction.php
@@ -15,9 +15,7 @@ class GetAttendeesAction extends BaseAction
{
public function __construct(
private readonly GetAttendeesHandler $getAttendeesHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, Request $request): JsonResponse
{
diff --git a/backend/app/Http/Actions/Attendees/PartialEditAttendeeAction.php b/backend/app/Http/Actions/Attendees/PartialEditAttendeeAction.php
index e62d0dbd00..4993690cb6 100644
--- a/backend/app/Http/Actions/Attendees/PartialEditAttendeeAction.php
+++ b/backend/app/Http/Actions/Attendees/PartialEditAttendeeAction.php
@@ -14,9 +14,7 @@ class PartialEditAttendeeAction extends BaseAction
{
public function __construct(
private readonly PartialEditAttendeeHandler $partialEditAttendeeHandler,
- )
- {
- }
+ ) {}
public function __invoke(PartialEditAttendeeRequest $request, int $eventId, int $attendeeId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Attendees/ResendAttendeeTicketAction.php b/backend/app/Http/Actions/Attendees/ResendAttendeeTicketAction.php
index 191fdb8891..cf5a77885e 100644
--- a/backend/app/Http/Actions/Attendees/ResendAttendeeTicketAction.php
+++ b/backend/app/Http/Actions/Attendees/ResendAttendeeTicketAction.php
@@ -14,9 +14,7 @@ class ResendAttendeeTicketAction extends BaseAction
{
public function __construct(
private readonly ResendAttendeeTicketHandler $handler
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, int $attendeeId): JsonResponse|Response
{
diff --git a/backend/app/Http/Actions/Auth/AcceptInvitationAction.php b/backend/app/Http/Actions/Auth/AcceptInvitationAction.php
index ca147ee92b..c58b562075 100644
--- a/backend/app/Http/Actions/Auth/AcceptInvitationAction.php
+++ b/backend/app/Http/Actions/Auth/AcceptInvitationAction.php
@@ -16,9 +16,7 @@
class AcceptInvitationAction extends BaseAction
{
- public function __construct(private readonly AcceptInvitationHandler $handler)
- {
- }
+ public function __construct(private readonly AcceptInvitationHandler $handler) {}
public function __invoke(AcceptInvitationRequest $request, string $inviteToken): Response
{
diff --git a/backend/app/Http/Actions/Auth/BaseAuthAction.php b/backend/app/Http/Actions/Auth/BaseAuthAction.php
index e5c5ecace7..5c26b33ff8 100644
--- a/backend/app/Http/Actions/Auth/BaseAuthAction.php
+++ b/backend/app/Http/Actions/Auth/BaseAuthAction.php
@@ -25,7 +25,7 @@ protected function getAuthCookie(string $token): SymfonyCookie
protected function addTokenToResponse(JsonResponse|Response $response, ?string $token): JsonResponse
{
- if (!$token) {
+ if (! $token) {
return $response;
}
diff --git a/backend/app/Http/Actions/Auth/GetUserInvitationAction.php b/backend/app/Http/Actions/Auth/GetUserInvitationAction.php
index 9f42b92767..468df2900b 100644
--- a/backend/app/Http/Actions/Auth/GetUserInvitationAction.php
+++ b/backend/app/Http/Actions/Auth/GetUserInvitationAction.php
@@ -22,10 +22,8 @@ class GetUserInvitationAction extends BaseAction
public function __construct(
private readonly UserRepositoryInterface $userRepository,
private readonly EncryptedPayloadService $encryptedPayloadService,
- private readonly LoggerInterface $logger,
- )
- {
- }
+ private readonly LoggerInterface $logger,
+ ) {}
public function __invoke(string $inviteToken): JsonResponse
{
diff --git a/backend/app/Http/Actions/Auth/LoginAction.php b/backend/app/Http/Actions/Auth/LoginAction.php
index 3f0e0efd6e..03a51a5122 100644
--- a/backend/app/Http/Actions/Auth/LoginAction.php
+++ b/backend/app/Http/Actions/Auth/LoginAction.php
@@ -26,7 +26,7 @@ public function __invoke(LoginRequest $request): JsonResponse
$loginResponse = $this->loginHandler->handle(new LoginCredentialsDTO(
email: strtolower($request->validated('email')),
password: $request->validated('password'),
- accountId: (int)$request->validated('account_id'),
+ accountId: (int) $request->validated('account_id'),
));
} catch (UnauthorizedException $e) {
return $this->errorResponse(
diff --git a/backend/app/Http/Actions/Auth/LogoutAction.php b/backend/app/Http/Actions/Auth/LogoutAction.php
index 191c4e46e7..ee963da1d1 100644
--- a/backend/app/Http/Actions/Auth/LogoutAction.php
+++ b/backend/app/Http/Actions/Auth/LogoutAction.php
@@ -15,7 +15,7 @@ public function __invoke(): Response
$cookie = Cookie::forget('token');
return (new Response([
- 'message' => __('Logout Successful')
+ 'message' => __('Logout Successful'),
]))
->withCookie($cookie);
}
diff --git a/backend/app/Http/Actions/BaseAction.php b/backend/app/Http/Actions/BaseAction.php
index 9b26d98de4..eb21b4b1ab 100644
--- a/backend/app/Http/Actions/BaseAction.php
+++ b/backend/app/Http/Actions/BaseAction.php
@@ -33,19 +33,15 @@ abstract class BaseAction extends Controller
use ValidatesRequests;
/**
- * @param class-string $resource
- * @param Collection|DomainObjectInterface|LengthAwarePaginator $data
- * @param int $statusCode
- * @param class-string $domainObject
- * @return JsonResponse
+ * @param class-string $resource
+ * @param class-string $domainObject
*/
protected function filterableResourceResponse(
- string $resource,
+ string $resource,
Collection|DomainObjectInterface|LengthAwarePaginator $data,
- string $domainObject,
- int $statusCode = ResponseCodes::HTTP_OK,
- ): JsonResponse
- {
+ string $domainObject,
+ int $statusCode = ResponseCodes::HTTP_OK,
+ ): JsonResponse {
$metaFields = [];
if (is_a($domainObject, IsFilterable::class, true)) {
@@ -62,23 +58,16 @@ protected function filterableResourceResponse(
}
/**
- * @param class-string $resource
- * @param Collection|DomainObjectInterface|LengthAwarePaginator|BaseDTO|Paginator|BaseDataObject $data
- * @param int $statusCode
- * @param array $meta
- * @param array $headers
- * @param array $errors
- * @return JsonResponse
+ * @param class-string $resource
*/
protected function resourceResponse(
- string $resource,
+ string $resource,
Collection|DomainObjectInterface|LengthAwarePaginator|BaseDTO|Paginator|BaseDataObject $data,
- int $statusCode = ResponseCodes::HTTP_OK,
- array $meta = [],
- array $headers = [],
- array $errors = [],
- ): JsonResponse
- {
+ int $statusCode = ResponseCodes::HTTP_OK,
+ array $meta = [],
+ array $headers = [],
+ array $errors = [],
+ ): JsonResponse {
if ($data instanceof Collection || $data instanceof Paginator) {
$additional = array_filter([
'meta' => $meta ?? null,
@@ -117,10 +106,9 @@ protected function notFoundResponse(): LaravelResponse
protected function errorResponse(
string $message,
- int $statusCode = ResponseCodes::HTTP_BAD_REQUEST,
- array $errors = [],
- ): JsonResponse
- {
+ int $statusCode = ResponseCodes::HTTP_BAD_REQUEST,
+ array $errors = [],
+ ): JsonResponse {
return $this->jsonResponse([
'message' => $message,
'errors' => $errors,
@@ -129,10 +117,9 @@ protected function errorResponse(
protected function jsonResponse(
mixed $data,
- int $statusCode = ResponseCodes::HTTP_OK,
- bool $wrapInData = false,
- ): JsonResponse
- {
+ int $statusCode = ResponseCodes::HTTP_OK,
+ bool $wrapInData = false,
+ ): JsonResponse {
if ($wrapInData) {
$data = [
'data' => $data,
@@ -144,10 +131,9 @@ protected function jsonResponse(
protected function xmlResponse(
string $xmlContent,
- int $statusCode = ResponseCodes::HTTP_OK,
- array $headers = [],
- ): LaravelResponse
- {
+ int $statusCode = ResponseCodes::HTTP_OK,
+ array $headers = [],
+ ): LaravelResponse {
$defaultHeaders = [
'Content-Type' => 'application/xml',
];
@@ -158,11 +144,10 @@ protected function xmlResponse(
}
protected function isActionAuthorized(
- int $entityId,
+ int $entityId,
string $entityType,
- Role $minimumRole = Role::ORGANIZER
- ): void
- {
+ Role $minimumRole = Role::ORGANIZER
+ ): void {
/** @var IsAuthorizedService $authService */
$authService = app()->make(IsAuthorizedService::class);
@@ -189,7 +174,7 @@ protected function getAuthenticatedAccountId(): int
return $accountId;
}
- throw new UnauthorizedException();
+ throw new UnauthorizedException;
}
protected function getAuthenticatedUserRole(): Role
@@ -206,7 +191,7 @@ protected function getAuthenticatedUserRole(): Role
return $role;
}
- throw new UnauthorizedException();
+ throw new UnauthorizedException;
}
protected function getAuthenticatedUser(): UserDomainObject|DomainObjectInterface
@@ -214,10 +199,11 @@ protected function getAuthenticatedUser(): UserDomainObject|DomainObjectInterfac
if (Auth::check()) {
/** @var AuthUserService $service */
$service = app(AuthUserService::class);
+
return $service->getUser();
}
- throw new UnauthorizedException();
+ throw new UnauthorizedException;
}
protected function isUserAuthenticated(): bool
diff --git a/backend/app/Http/Actions/CapacityAssignments/CreateCapacityAssignmentAction.php b/backend/app/Http/Actions/CapacityAssignments/CreateCapacityAssignmentAction.php
index d9cc6e1d97..ca9d60052d 100644
--- a/backend/app/Http/Actions/CapacityAssignments/CreateCapacityAssignmentAction.php
+++ b/backend/app/Http/Actions/CapacityAssignments/CreateCapacityAssignmentAction.php
@@ -16,9 +16,7 @@ class CreateCapacityAssignmentAction extends BaseAction
{
public function __construct(
private readonly CreateCapacityAssignmentHandler $createCapacityAssignmentHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, UpsertCapacityAssignmentRequest $request): JsonResponse
{
diff --git a/backend/app/Http/Actions/CapacityAssignments/DeleteCapacityAssignmentAction.php b/backend/app/Http/Actions/CapacityAssignments/DeleteCapacityAssignmentAction.php
index e31d6927bf..cacc315098 100644
--- a/backend/app/Http/Actions/CapacityAssignments/DeleteCapacityAssignmentAction.php
+++ b/backend/app/Http/Actions/CapacityAssignments/DeleteCapacityAssignmentAction.php
@@ -11,9 +11,7 @@ class DeleteCapacityAssignmentAction extends BaseAction
{
public function __construct(
private readonly DeleteCapacityAssignmentHandler $deleteCapacityAssignmentHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, int $capacityAssignmentId): Response
{
diff --git a/backend/app/Http/Actions/CapacityAssignments/GetCapacityAssignmentAction.php b/backend/app/Http/Actions/CapacityAssignments/GetCapacityAssignmentAction.php
index 676cb218d9..93587dc405 100644
--- a/backend/app/Http/Actions/CapacityAssignments/GetCapacityAssignmentAction.php
+++ b/backend/app/Http/Actions/CapacityAssignments/GetCapacityAssignmentAction.php
@@ -12,9 +12,7 @@ class GetCapacityAssignmentAction extends BaseAction
{
public function __construct(
private readonly GetCapacityAssignmentHandler $getCapacityAssignmentsHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, int $capacityAssignmentId): JsonResponse
{
diff --git a/backend/app/Http/Actions/CapacityAssignments/GetCapacityAssignmentsAction.php b/backend/app/Http/Actions/CapacityAssignments/GetCapacityAssignmentsAction.php
index f6c4f791ba..7ca1697d60 100644
--- a/backend/app/Http/Actions/CapacityAssignments/GetCapacityAssignmentsAction.php
+++ b/backend/app/Http/Actions/CapacityAssignments/GetCapacityAssignmentsAction.php
@@ -15,9 +15,7 @@ class GetCapacityAssignmentsAction extends BaseAction
{
public function __construct(
private readonly GetCapacityAssignmentsHandler $getCapacityAssignmentsHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, Request $request): JsonResponse
{
diff --git a/backend/app/Http/Actions/CapacityAssignments/UpdateCapacityAssignmentAction.php b/backend/app/Http/Actions/CapacityAssignments/UpdateCapacityAssignmentAction.php
index b80035d7c1..562235b699 100644
--- a/backend/app/Http/Actions/CapacityAssignments/UpdateCapacityAssignmentAction.php
+++ b/backend/app/Http/Actions/CapacityAssignments/UpdateCapacityAssignmentAction.php
@@ -16,9 +16,7 @@ class UpdateCapacityAssignmentAction extends BaseAction
{
public function __construct(
private readonly UpdateCapacityAssignmentHandler $updateCapacityAssignmentHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, int $capacityAssignmentId, UpsertCapacityAssignmentRequest $request): JsonResponse
{
diff --git a/backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php b/backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php
index c9dc08d3ed..b6d1a779ae 100644
--- a/backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php
+++ b/backend/app/Http/Actions/CheckInLists/CreateCheckInListAction.php
@@ -16,9 +16,7 @@ class CreateCheckInListAction extends BaseAction
{
public function __construct(
private readonly CreateCheckInListHandler $checkInListHandler,
- )
- {
- }
+ ) {}
public function __invoke(UpsertCheckInListRequest $request, int $eventId): JsonResponse
{
@@ -30,9 +28,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') ?? false,
+ publicShowQuestionAnswers: $request->validated('public_show_question_answers') ?? false,
+ publicShowOrderDetails: $request->validated('public_show_order_details') ?? false,
)
);
} catch (UnrecognizedProductIdException $exception) {
diff --git a/backend/app/Http/Actions/CheckInLists/DeleteCheckInListAction.php b/backend/app/Http/Actions/CheckInLists/DeleteCheckInListAction.php
index f707a0737a..c4cf862435 100644
--- a/backend/app/Http/Actions/CheckInLists/DeleteCheckInListAction.php
+++ b/backend/app/Http/Actions/CheckInLists/DeleteCheckInListAction.php
@@ -3,26 +3,33 @@
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
{
public function __construct(
private readonly DeleteCheckInListHandler $deleteCheckInListHandler,
- )
- {
- }
+ ) {}
- 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/GetCheckInListAction.php b/backend/app/Http/Actions/CheckInLists/GetCheckInListAction.php
index 6e60ce9df2..91b4e999b9 100644
--- a/backend/app/Http/Actions/CheckInLists/GetCheckInListAction.php
+++ b/backend/app/Http/Actions/CheckInLists/GetCheckInListAction.php
@@ -12,9 +12,7 @@ class GetCheckInListAction extends BaseAction
{
public function __construct(
private readonly GetCheckInListHandler $getCheckInListHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, int $checkInListId): JsonResponse
{
diff --git a/backend/app/Http/Actions/CheckInLists/GetCheckInListsAction.php b/backend/app/Http/Actions/CheckInLists/GetCheckInListsAction.php
index bc7ca01d2f..3d3c7263f8 100644
--- a/backend/app/Http/Actions/CheckInLists/GetCheckInListsAction.php
+++ b/backend/app/Http/Actions/CheckInLists/GetCheckInListsAction.php
@@ -15,9 +15,7 @@ class GetCheckInListsAction extends BaseAction
{
public function __construct(
private readonly GetCheckInListsHandler $getCheckInListsHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, Request $request): JsonResponse
{
diff --git a/backend/app/Http/Actions/CheckInLists/Public/CreateAttendeeCheckInPublicAction.php b/backend/app/Http/Actions/CheckInLists/Public/CreateAttendeeCheckInPublicAction.php
index ae5ea274b3..97346ff91c 100644
--- a/backend/app/Http/Actions/CheckInLists/Public/CreateAttendeeCheckInPublicAction.php
+++ b/backend/app/Http/Actions/CheckInLists/Public/CreateAttendeeCheckInPublicAction.php
@@ -15,15 +15,12 @@ class CreateAttendeeCheckInPublicAction extends BaseAction
{
public function __construct(
private readonly CreateAttendeeCheckInPublicHandler $createAttendeeCheckInPublicHandler,
- )
- {
- }
+ ) {}
public function __invoke(
- string $checkInListUuid,
+ string $checkInListUuid,
CreateAttendeeCheckInPublicRequest $request,
- ): JsonResponse
- {
+ ): JsonResponse {
try {
$checkIns = $this->createAttendeeCheckInPublicHandler->handle(CreateAttendeeCheckInPublicDTO::from([
'checkInListUuid' => $checkInListUuid,
diff --git a/backend/app/Http/Actions/CheckInLists/Public/DeleteAttendeeCheckInPublicAction.php b/backend/app/Http/Actions/CheckInLists/Public/DeleteAttendeeCheckInPublicAction.php
index f19abe5244..71d9f9e95c 100644
--- a/backend/app/Http/Actions/CheckInLists/Public/DeleteAttendeeCheckInPublicAction.php
+++ b/backend/app/Http/Actions/CheckInLists/Public/DeleteAttendeeCheckInPublicAction.php
@@ -14,16 +14,13 @@ class DeleteAttendeeCheckInPublicAction extends BaseAction
{
public function __construct(
private readonly DeleteAttendeeCheckInPublicHandler $deleteAttendeeCheckInPublicHandler,
- )
- {
- }
+ ) {}
public function __invoke(
- string $checkInListShortId,
- string $checkInShortId,
+ string $checkInListShortId,
+ string $checkInShortId,
Request $request
- ): Response|JsonResponse
- {
+ ): Response|JsonResponse {
try {
$this->deleteAttendeeCheckInPublicHandler->handle(new DeleteAttendeeCheckInPublicDTO(
checkInListShortId: $checkInListShortId,
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..f61ee31f7e
--- /dev/null
+++ b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeeDetailPublicAction.php
@@ -0,0 +1,50 @@
+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,
+ );
+ }
+
+ private function resolveStaffAccountId(): ?int
+ {
+ try {
+ return $this->authUserService->getAuthenticatedAccountId();
+ } catch (Throwable) {
+ return null;
+ }
+ }
+}
diff --git a/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeePublicAction.php b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeePublicAction.php
index d9d8951863..04381e0725 100644
--- a/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeePublicAction.php
+++ b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeePublicAction.php
@@ -14,9 +14,7 @@ class GetCheckInListAttendeePublicAction extends BaseAction
{
public function __construct(
private readonly GetCheckInListAttendeePublicHandler $getCheckInListAttendeePublicHandler,
- )
- {
- }
+ ) {}
public function __invoke(string $shortId, string $attendeePublicId, Request $request): JsonResponse
{
diff --git a/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeesPublicAction.php b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeesPublicAction.php
index cb432d7b1c..6505784d80 100644
--- a/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeesPublicAction.php
+++ b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListAttendeesPublicAction.php
@@ -15,9 +15,7 @@ class GetCheckInListAttendeesPublicAction extends BaseAction
{
public function __construct(
private readonly GetCheckInListAttendeesPublicHandler $getCheckInListAttendeesPublicHandler,
- )
- {
- }
+ ) {}
public function __invoke(string $checkInListShortId, Request $request): JsonResponse
{
diff --git a/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListPublicAction.php b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListPublicAction.php
index fded28bf99..6c9ec7ca21 100644
--- a/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListPublicAction.php
+++ b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListPublicAction.php
@@ -11,9 +11,7 @@ class GetCheckInListPublicAction extends BaseAction
{
public function __construct(
private readonly GetCheckInListPublicHandler $getCheckInListPublicHandler,
- )
- {
- }
+ ) {}
public function __invoke(string $checkInListShortId): JsonResponse
{
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..20c7c2ff04
--- /dev/null
+++ b/backend/app/Http/Actions/CheckInLists/Public/GetCheckInListStatsPublicAction.php
@@ -0,0 +1,38 @@
+query('event_occurrence_id');
+ $occurrenceIdInt = is_numeric($occurrenceId) ? (int) $occurrenceId : null;
+
+ try {
+ $stats = $this->getCheckInListStatsPublicHandler->handle($checkInListShortId, $occurrenceIdInt);
+ } catch (CannotCheckInException $e) {
+ return $this->errorResponse(
+ message: $e->getMessage(),
+ statusCode: Response::HTTP_FORBIDDEN,
+ );
+ }
+
+ 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..5db8972201 100644
--- a/backend/app/Http/Actions/CheckInLists/UpdateCheckInListAction.php
+++ b/backend/app/Http/Actions/CheckInLists/UpdateCheckInListAction.php
@@ -16,9 +16,7 @@ class UpdateCheckInListAction extends BaseAction
{
public function __construct(
private readonly UpdateCheckInlistHandler $updateCheckInlistHandler,
- )
- {
- }
+ ) {}
public function __invoke(UpsertCheckInListRequest $request, int $eventId, int $checkInListId): JsonResponse
{
@@ -30,10 +28,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') ?? false,
+ publicShowQuestionAnswers: $request->validated('public_show_question_answers') ?? false,
+ publicShowOrderDetails: $request->validated('public_show_order_details') ?? false,
)
);
} catch (UnrecognizedProductIdException $exception) {
diff --git a/backend/app/Http/Actions/Common/Webhooks/StripeIncomingWebhookAction.php b/backend/app/Http/Actions/Common/Webhooks/StripeIncomingWebhookAction.php
index bbd59a975c..a4490a38f4 100644
--- a/backend/app/Http/Actions/Common/Webhooks/StripeIncomingWebhookAction.php
+++ b/backend/app/Http/Actions/Common/Webhooks/StripeIncomingWebhookAction.php
@@ -32,6 +32,7 @@ public function __invoke(Request $request): Response
} catch (Throwable $exception) {
logger()?->error($exception->getMessage(), $exception->getTrace());
+
return $this->noContentResponse(ResponseCodes::HTTP_BAD_REQUEST);
}
diff --git a/backend/app/Http/Actions/EmailTemplates/BaseEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/BaseEmailTemplateAction.php
index c8648519fa..ea183288b0 100644
--- a/backend/app/Http/Actions/EmailTemplates/BaseEmailTemplateAction.php
+++ b/backend/app/Http/Actions/EmailTemplates/BaseEmailTemplateAction.php
@@ -25,7 +25,7 @@ protected function verifyAccountCanModifyEmailTemplates(): void
/** @var Repository $config */
$config = app(Repository::class);
- if (!$config->get('app.saas_mode_enabled')) {
+ if (! $config->get('app.saas_mode_enabled')) {
return;
}
@@ -38,7 +38,7 @@ protected function verifyAccountCanModifyEmailTemplates(): void
throw new AccountNotVerifiedException(__('You cannot modify email templates until your account is verified.'));
}
- if (!$account->getIsManuallyVerified()) {
+ if (! $account->getIsManuallyVerified()) {
throw new AccountNotVerifiedException(
__('Due to issues with spam, you must connect a Stripe account before you can modify email templates.')
);
@@ -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..f9fd2967cc 100644
--- a/backend/app/Http/Actions/EmailTemplates/CreateEventEmailTemplateAction.php
+++ b/backend/app/Http/Actions/EmailTemplates/CreateEventEmailTemplateAction.php
@@ -20,9 +20,7 @@ class CreateEventEmailTemplateAction extends BaseEmailTemplateAction
{
public function __construct(
private readonly CreateEmailTemplateHandler $handler
- )
- {
- }
+ ) {}
/**
* @throws ValidationException
@@ -42,9 +40,9 @@ 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(
new UpsertEmailTemplateDTO(
account_id: $this->getAuthenticatedAccountId(),
diff --git a/backend/app/Http/Actions/EmailTemplates/CreateOrganizerEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/CreateOrganizerEmailTemplateAction.php
index c0e4411182..e836a174aa 100644
--- a/backend/app/Http/Actions/EmailTemplates/CreateOrganizerEmailTemplateAction.php
+++ b/backend/app/Http/Actions/EmailTemplates/CreateOrganizerEmailTemplateAction.php
@@ -20,9 +20,7 @@ class CreateOrganizerEmailTemplateAction extends BaseEmailTemplateAction
{
public function __construct(
private readonly CreateEmailTemplateHandler $handler
- )
- {
- }
+ ) {}
/**
* @throws ValidationException
@@ -42,9 +40,9 @@ 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(
new UpsertEmailTemplateDTO(
account_id: $this->getAuthenticatedAccountId(),
diff --git a/backend/app/Http/Actions/EmailTemplates/DeleteEventEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/DeleteEventEmailTemplateAction.php
index 0051049cfd..a3b00ee0f4 100644
--- a/backend/app/Http/Actions/EmailTemplates/DeleteEventEmailTemplateAction.php
+++ b/backend/app/Http/Actions/EmailTemplates/DeleteEventEmailTemplateAction.php
@@ -16,9 +16,7 @@ class DeleteEventEmailTemplateAction extends BaseEmailTemplateAction
{
public function __construct(
private readonly DeleteEmailTemplateHandler $handler
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, int $templateId): Response|JsonResponse
{
diff --git a/backend/app/Http/Actions/EmailTemplates/DeleteOrganizerEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/DeleteOrganizerEmailTemplateAction.php
index d47d82f14b..d995893176 100644
--- a/backend/app/Http/Actions/EmailTemplates/DeleteOrganizerEmailTemplateAction.php
+++ b/backend/app/Http/Actions/EmailTemplates/DeleteOrganizerEmailTemplateAction.php
@@ -15,8 +15,7 @@ class DeleteOrganizerEmailTemplateAction extends BaseEmailTemplateAction
{
public function __construct(
private readonly DeleteEmailTemplateHandler $handler
- ) {
- }
+ ) {}
public function __invoke(int $organizerId, int $templateId): JsonResponse
{
@@ -44,4 +43,4 @@ public function __invoke(int $organizerId, int $templateId): JsonResponse
return response()->json(['message' => 'Template deleted successfully'], ResponseCodes::HTTP_OK);
}
-}
\ No newline at end of file
+}
diff --git a/backend/app/Http/Actions/EmailTemplates/GetAvailableTokensAction.php b/backend/app/Http/Actions/EmailTemplates/GetAvailableTokensAction.php
index b3568dba7e..087018a1da 100644
--- a/backend/app/Http/Actions/EmailTemplates/GetAvailableTokensAction.php
+++ b/backend/app/Http/Actions/EmailTemplates/GetAvailableTokensAction.php
@@ -12,16 +12,13 @@ class GetAvailableTokensAction extends BaseAction
{
public function __construct(
private readonly GetAvailableTokensHandler $handler
- ) {
- }
+ ) {}
public function __invoke(string $templateType): JsonResponse
{
- //no authorization needed
-
$type = EmailTemplateType::tryFrom($templateType);
- if (!$type) {
+ if (! $type) {
return $this->jsonResponse(['error' => __('Invalid template type')], ResponseCodes::HTTP_BAD_REQUEST);
}
diff --git a/backend/app/Http/Actions/EmailTemplates/GetDefaultEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/GetDefaultEmailTemplateAction.php
index 565a7c28c5..421de45a6a 100644
--- a/backend/app/Http/Actions/EmailTemplates/GetDefaultEmailTemplateAction.php
+++ b/backend/app/Http/Actions/EmailTemplates/GetDefaultEmailTemplateAction.php
@@ -12,9 +12,7 @@ class GetDefaultEmailTemplateAction extends BaseAction
{
public function __construct(
private readonly EmailTemplateService $emailTemplateService,
- )
- {
- }
+ ) {}
public function __invoke(Request $request): JsonResponse
{
diff --git a/backend/app/Http/Actions/EmailTemplates/GetEventEmailTemplatesAction.php b/backend/app/Http/Actions/EmailTemplates/GetEventEmailTemplatesAction.php
index 7bacc87ab0..2d85f0d537 100644
--- a/backend/app/Http/Actions/EmailTemplates/GetEventEmailTemplatesAction.php
+++ b/backend/app/Http/Actions/EmailTemplates/GetEventEmailTemplatesAction.php
@@ -16,9 +16,7 @@ class GetEventEmailTemplatesAction extends BaseAction
{
public function __construct(
private readonly GetEmailTemplatesHandler $handler
- )
- {
- }
+ ) {}
public function __invoke(Request $request, int $eventId): JsonResponse
{
diff --git a/backend/app/Http/Actions/EmailTemplates/GetOrganizerEmailTemplatesAction.php b/backend/app/Http/Actions/EmailTemplates/GetOrganizerEmailTemplatesAction.php
index 075156488d..b1fdd5bf65 100644
--- a/backend/app/Http/Actions/EmailTemplates/GetOrganizerEmailTemplatesAction.php
+++ b/backend/app/Http/Actions/EmailTemplates/GetOrganizerEmailTemplatesAction.php
@@ -6,8 +6,8 @@
use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Http\Resources\EmailTemplateResource;
-use HiEvents\Services\Application\Handlers\EmailTemplate\GetEmailTemplatesHandler;
use HiEvents\Services\Application\Handlers\EmailTemplate\DTO\GetEmailTemplatesDTO;
+use HiEvents\Services\Application\Handlers\EmailTemplate\GetEmailTemplatesHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rules\Enum;
@@ -16,8 +16,7 @@ class GetOrganizerEmailTemplatesAction extends BaseAction
{
public function __construct(
private readonly GetEmailTemplatesHandler $handler
- ) {
- }
+ ) {}
public function __invoke(Request $request, int $organizerId): JsonResponse
{
diff --git a/backend/app/Http/Actions/EmailTemplates/PreviewEventEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/PreviewEventEmailTemplateAction.php
index a9a3c27113..2016609830 100644
--- a/backend/app/Http/Actions/EmailTemplates/PreviewEventEmailTemplateAction.php
+++ b/backend/app/Http/Actions/EmailTemplates/PreviewEventEmailTemplateAction.php
@@ -11,9 +11,7 @@ class PreviewEventEmailTemplateAction extends BaseEmailTemplateAction
{
public function __construct(
private readonly PreviewEmailTemplateHandler $handler
- )
- {
- }
+ ) {}
public function __invoke(Request $request, int $eventId): JsonResponse
{
@@ -21,4 +19,4 @@ public function __invoke(Request $request, int $eventId): JsonResponse
return $this->handlePreviewRequest($request, $this->handler);
}
-}
\ No newline at end of file
+}
diff --git a/backend/app/Http/Actions/EmailTemplates/PreviewOrganizerEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/PreviewOrganizerEmailTemplateAction.php
index 8a3bfcb028..a37f7aba5d 100644
--- a/backend/app/Http/Actions/EmailTemplates/PreviewOrganizerEmailTemplateAction.php
+++ b/backend/app/Http/Actions/EmailTemplates/PreviewOrganizerEmailTemplateAction.php
@@ -11,9 +11,7 @@ class PreviewOrganizerEmailTemplateAction extends BaseEmailTemplateAction
{
public function __construct(
private readonly PreviewEmailTemplateHandler $handler
- )
- {
- }
+ ) {}
public function __invoke(Request $request, int $organizerId): JsonResponse
{
@@ -21,4 +19,4 @@ public function __invoke(Request $request, int $organizerId): JsonResponse
return $this->handlePreviewRequest($request, $this->handler);
}
-}
\ No newline at end of file
+}
diff --git a/backend/app/Http/Actions/EmailTemplates/UpdateEventEmailTemplateAction.php b/backend/app/Http/Actions/EmailTemplates/UpdateEventEmailTemplateAction.php
index f7be0096e3..be04e08d54 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,10 +21,9 @@
class UpdateEventEmailTemplateAction extends BaseEmailTemplateAction
{
public function __construct(
- private readonly UpdateEmailTemplateHandler $handler
- )
- {
- }
+ private readonly UpdateEmailTemplateHandler $handler,
+ private readonly EmailTemplateRepositoryInterface $emailTemplateRepository,
+ ) {}
/**
* @throws ValidationException
@@ -41,15 +41,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..8a1150256c 100644
--- a/backend/app/Http/Actions/EmailTemplates/UpdateOrganizerEmailTemplateAction.php
+++ b/backend/app/Http/Actions/EmailTemplates/UpdateOrganizerEmailTemplateAction.php
@@ -10,8 +10,9 @@
use HiEvents\Exceptions\InvalidEmailTemplateException;
use HiEvents\Http\Resources\EmailTemplateResource;
use HiEvents\Http\ResponseCodes;
-use HiEvents\Services\Application\Handlers\EmailTemplate\UpdateEmailTemplateHandler;
+use HiEvents\Repository\Interfaces\EmailTemplateRepositoryInterface;
use HiEvents\Services\Application\Handlers\EmailTemplate\DTO\UpsertEmailTemplateDTO;
+use HiEvents\Services\Application\Handlers\EmailTemplate\UpdateEmailTemplateHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@@ -20,9 +21,9 @@
class UpdateOrganizerEmailTemplateAction extends BaseEmailTemplateAction
{
public function __construct(
- private readonly UpdateEmailTemplateHandler $handler
- ) {
- }
+ private readonly UpdateEmailTemplateHandler $handler,
+ private readonly EmailTemplateRepositoryInterface $emailTemplateRepository,
+ ) {}
/**
* @throws ValidationException
@@ -40,15 +41,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..895838239f
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/BulkUpdateOccurrencesAction.php
@@ -0,0 +1,71 @@
+isActionAuthorized($eventId, EventDomainObject::class);
+
+ $event = $this->eventRepository->findById($eventId);
+
+ $eventLocationPayload = $request->validated('event_location');
+
+ try {
+ $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),
+ )
+ );
+ } catch (InvalidOccurrenceDatesException $e) {
+ throw ValidationException::withMessages([
+ 'start_time_shift' => [$e->getMessage()],
+ ]);
+ }
+
+ 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..b5d71e3e1b
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/CreateEventOccurrenceAction.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(
+ 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'),
+ show_available_capacity: $request->validated('show_available_capacity'),
+ 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..769bf4a7fb
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/GetEventOccurrencesAction.php
@@ -0,0 +1,38 @@
+isActionAuthorized($eventId, EventDomainObject::class);
+
+ $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/GetEventOccurrencesPublicAction.php b/backend/app/Http/Actions/EventOccurrences/GetEventOccurrencesPublicAction.php
new file mode 100644
index 0000000000..1598706766
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/GetEventOccurrencesPublicAction.php
@@ -0,0 +1,54 @@
+query('start_date_from');
+ $startDateTo = $request->query('start_date_to');
+
+ try {
+ $result = $this->handler->handle(new GetPublicEventOccurrencesDTO(
+ eventId: $eventId,
+ startDateFrom: is_string($startDateFrom) ? $startDateFrom : null,
+ startDateTo: is_string($startDateTo) ? $startDateTo : null,
+ ));
+ } catch (InvalidOccurrenceDatesException $exception) {
+ throw ValidationException::withMessages([
+ 'start_date_from' => $exception->getMessage(),
+ ]);
+ }
+
+ if (! $this->canUserViewEvent($result->event)) {
+ return $this->notFoundResponse();
+ }
+
+ $showCapacity = $result->event->getEventSettings()?->getShowAvailableOccurrenceCapacity() ?? false;
+
+ return $this->jsonResponse([
+ 'data' => $result->occurrences->map(
+ fn (EventOccurrenceDomainObject $occurrence) => new EventOccurrenceResourcePublic($occurrence, $showCapacity)
+ )->values(),
+ ]);
+ }
+}
diff --git a/backend/app/Http/Actions/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..8bb8f61350
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/UpdateEventOccurrenceAction.php
@@ -0,0 +1,53 @@
+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'),
+ show_available_capacity: $request->validated('show_available_capacity'),
+ 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/EventSettings/EditEventSettingsAction.php b/backend/app/Http/Actions/EventSettings/EditEventSettingsAction.php
index ff97d97837..cba553d586 100644
--- a/backend/app/Http/Actions/EventSettings/EditEventSettingsAction.php
+++ b/backend/app/Http/Actions/EventSettings/EditEventSettingsAction.php
@@ -14,9 +14,7 @@ class EditEventSettingsAction extends BaseAction
{
public function __construct(
private readonly UpdateEventSettingsHandler $updateEventSettingsHandler
- )
- {
- }
+ ) {}
public function __invoke(UpdateEventSettingsRequest $request, int $eventId): JsonResponse
{
diff --git a/backend/app/Http/Actions/EventSettings/GetEventSettingsAction.php b/backend/app/Http/Actions/EventSettings/GetEventSettingsAction.php
index 9b6a59d944..751f5fcf82 100644
--- a/backend/app/Http/Actions/EventSettings/GetEventSettingsAction.php
+++ b/backend/app/Http/Actions/EventSettings/GetEventSettingsAction.php
@@ -11,16 +11,14 @@
class GetEventSettingsAction extends BaseAction
{
- public function __construct(private readonly EventSettingsRepositoryInterface $eventSettingsRepository)
- {
- }
+ public function __construct(private readonly EventSettingsRepositoryInterface $eventSettingsRepository) {}
public function __invoke(int $eventId): Response|JsonResponse
{
$this->isActionAuthorized($eventId, EventDomainObject::class);
$settings = $this->eventSettingsRepository->findFirstWhere([
- 'event_id' => $eventId
+ 'event_id' => $eventId,
]);
if ($settings === null) {
diff --git a/backend/app/Http/Actions/EventSettings/GetPlatformFeePreviewAction.php b/backend/app/Http/Actions/EventSettings/GetPlatformFeePreviewAction.php
index 1afd068e6d..df8b02a2d4 100644
--- a/backend/app/Http/Actions/EventSettings/GetPlatformFeePreviewAction.php
+++ b/backend/app/Http/Actions/EventSettings/GetPlatformFeePreviewAction.php
@@ -14,9 +14,7 @@ class GetPlatformFeePreviewAction extends BaseAction
{
public function __construct(
private readonly GetPlatformFeePreviewHandler $handler,
- )
- {
- }
+ ) {}
public function __invoke(Request $request, int $eventId): JsonResponse
{
@@ -28,7 +26,7 @@ public function __invoke(Request $request, int $eventId): JsonResponse
$dto = new GetPlatformFeePreviewDTO(
eventId: $eventId,
- price: (float)$request->input('price'),
+ price: (float) $request->input('price'),
);
$result = $this->handler->handle($dto);
diff --git a/backend/app/Http/Actions/EventSettings/PartialEditEventSettingsAction.php b/backend/app/Http/Actions/EventSettings/PartialEditEventSettingsAction.php
index 09b20aabed..a2ccb4223e 100644
--- a/backend/app/Http/Actions/EventSettings/PartialEditEventSettingsAction.php
+++ b/backend/app/Http/Actions/EventSettings/PartialEditEventSettingsAction.php
@@ -15,9 +15,7 @@ class PartialEditEventSettingsAction extends BaseAction
{
public function __construct(
private readonly PartialUpdateEventSettingsHandler $partialUpdateEventSettingsHandler
- )
- {
- }
+ ) {}
/**
* @throws Throwable
diff --git a/backend/app/Http/Actions/Events/BasePublicEventAction.php b/backend/app/Http/Actions/Events/BasePublicEventAction.php
new file mode 100644
index 0000000000..6c24722eeb
--- /dev/null
+++ b/backend/app/Http/Actions/Events/BasePublicEventAction.php
@@ -0,0 +1,36 @@
+getStatus() === EventStatus::LIVE->name) {
+ return true;
+ }
+
+ if ($this->isUserAuthenticated() && $event->getAccountId() === $this->getAuthenticatedAccountId()) {
+ return true;
+ }
+
+ if ($this->isUserAuthenticated() && $this->getAuthenticatedUserRole() === Role::SUPERADMIN) {
+ Log::debug(__('Superadmin user is viewing non-live event with ID :eventId', [
+ 'eventId' => $event->getId(),
+ 'accountId' => $this->getAuthenticatedAccountId(),
+ ]));
+
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/backend/app/Http/Actions/Events/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/DeleteEventAction.php b/backend/app/Http/Actions/Events/DeleteEventAction.php
index e3d432ca39..bbdf340351 100644
--- a/backend/app/Http/Actions/Events/DeleteEventAction.php
+++ b/backend/app/Http/Actions/Events/DeleteEventAction.php
@@ -18,9 +18,7 @@ class DeleteEventAction extends BaseAction
{
public function __construct(
private readonly DeleteEventHandler $deleteEventHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId): Response|JsonResponse
{
diff --git a/backend/app/Http/Actions/Events/DuplicateEventAction.php b/backend/app/Http/Actions/Events/DuplicateEventAction.php
index 160328fd27..4d2312a918 100644
--- a/backend/app/Http/Actions/Events/DuplicateEventAction.php
+++ b/backend/app/Http/Actions/Events/DuplicateEventAction.php
@@ -13,9 +13,7 @@
class DuplicateEventAction extends BaseAction
{
- public function __construct(private readonly DuplicateEventHandler $handler)
- {
- }
+ public function __construct(private readonly DuplicateEventHandler $handler) {}
/**
* @throws Throwable
@@ -39,6 +37,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..b18135eb2e 100644
--- a/backend/app/Http/Actions/Events/GetEventAction.php
+++ b/backend/app/Http/Actions/Events/GetEventAction.php
@@ -5,12 +5,15 @@
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;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
+use HiEvents\DomainObjects\TaxAndFeesDomainObject;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
@@ -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/GetEventDeletionStatusAction.php b/backend/app/Http/Actions/Events/GetEventDeletionStatusAction.php
index cd8771be99..fc537e19b6 100644
--- a/backend/app/Http/Actions/Events/GetEventDeletionStatusAction.php
+++ b/backend/app/Http/Actions/Events/GetEventDeletionStatusAction.php
@@ -13,9 +13,7 @@ class GetEventDeletionStatusAction extends BaseAction
{
public function __construct(
private readonly EventDeletionService $eventDeletionService,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Events/GetEventPublicAction.php b/backend/app/Http/Actions/Events/GetEventPublicAction.php
index 6f909b2724..e5e8e4c7c9 100644
--- a/backend/app/Http/Actions/Events/GetEventPublicAction.php
+++ b/backend/app/Http/Actions/Events/GetEventPublicAction.php
@@ -2,10 +2,6 @@
namespace HiEvents\Http\Actions\Events;
-use HiEvents\DomainObjects\Enums\Role;
-use HiEvents\DomainObjects\EventDomainObject;
-use HiEvents\DomainObjects\Status\EventStatus;
-use HiEvents\Http\Actions\BaseAction;
use HiEvents\Resources\Event\EventResourcePublic;
use HiEvents\Services\Application\Handlers\Event\DTO\GetPublicEventDTO;
use HiEvents\Services\Application\Handlers\Event\GetPublicEventHandler;
@@ -14,14 +10,12 @@
use Illuminate\Http\Response;
use Psr\Log\LoggerInterface;
-class GetEventPublicAction extends BaseAction
+class GetEventPublicAction extends BasePublicEventAction
{
public function __construct(
private readonly GetPublicEventHandler $getPublicEventHandler,
- private readonly LoggerInterface $logger,
- )
- {
- }
+ private readonly LoggerInterface $logger,
+ ) {}
public function __invoke(int $eventId, Request $request): Response|JsonResponse
{
@@ -30,11 +24,12 @@ 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)) {
+ if (! $this->canUserViewEvent($event)) {
$this->logger->debug(__('Event with ID :eventId is not live and user is not authenticated', [
- 'eventId' => $eventId
+ 'eventId' => $eventId,
]));
return $this->notFoundResponse();
@@ -42,25 +37,4 @@ public function __invoke(int $eventId, Request $request): Response|JsonResponse
return $this->resourceResponse(EventResourcePublic::class, $event);
}
-
- private function canUserViewEvent(EventDomainObject $event): bool
- {
- if ($event->getStatus() === EventStatus::LIVE->name) {
- return true;
- }
-
- if ($this->isUserAuthenticated() && $event->getAccountId() === $this->getAuthenticatedAccountId()) {
- return true;
- }
-
- if ($this->isUserAuthenticated() && $this->getAuthenticatedUserRole() === Role::SUPERADMIN) {
- $this->logger->debug(__('Superadmin user is viewing non-live event with ID :eventId', [
- 'eventId' => $event->getId(),
- 'accountId' => $this->getAuthenticatedAccountId(),
- ]));
- return true;
- }
-
- return false;
- }
}
diff --git a/backend/app/Http/Actions/Events/GetEventsAction.php b/backend/app/Http/Actions/Events/GetEventsAction.php
index c269285b76..dda42a819c 100644
--- a/backend/app/Http/Actions/Events/GetEventsAction.php
+++ b/backend/app/Http/Actions/Events/GetEventsAction.php
@@ -17,9 +17,7 @@ class GetEventsAction extends BaseAction
{
public function __construct(
private readonly GetEventsHandler $getEventsHandler,
- )
- {
- }
+ ) {}
public function __invoke(Request $request): JsonResponse
{
diff --git a/backend/app/Http/Actions/Events/GetOrganizerEventsPublicAction.php b/backend/app/Http/Actions/Events/GetOrganizerEventsPublicAction.php
index 1ce6437f77..d5ff7595ed 100644
--- a/backend/app/Http/Actions/Events/GetOrganizerEventsPublicAction.php
+++ b/backend/app/Http/Actions/Events/GetOrganizerEventsPublicAction.php
@@ -13,9 +13,7 @@ class GetOrganizerEventsPublicAction extends BaseAction
{
public function __construct(
private readonly GetPublicEventsHandler $handler,
- )
- {
- }
+ ) {}
public function __invoke(int $organizerId, Request $request): JsonResponse
{
diff --git a/backend/app/Http/Actions/Events/Images/DeleteEventImageAction.php b/backend/app/Http/Actions/Events/Images/DeleteEventImageAction.php
index 53ad71e9d8..52d9d5d12e 100644
--- a/backend/app/Http/Actions/Events/Images/DeleteEventImageAction.php
+++ b/backend/app/Http/Actions/Events/Images/DeleteEventImageAction.php
@@ -10,9 +10,7 @@
class DeleteEventImageAction extends BaseAction
{
- public function __construct(private readonly DeleteEventImageHandler $deleteEventImageHandler)
- {
- }
+ public function __construct(private readonly DeleteEventImageHandler $deleteEventImageHandler) {}
public function __invoke(int $eventId, int $imageId): Response
{
diff --git a/backend/app/Http/Actions/Events/Images/GetEventImagesAction.php b/backend/app/Http/Actions/Events/Images/GetEventImagesAction.php
index c32b7b0ad8..33bc6bd58b 100644
--- a/backend/app/Http/Actions/Events/Images/GetEventImagesAction.php
+++ b/backend/app/Http/Actions/Events/Images/GetEventImagesAction.php
@@ -10,9 +10,7 @@
class GetEventImagesAction extends BaseAction
{
- public function __construct(private readonly ImageRepositoryInterface $imageRepository)
- {
- }
+ public function __construct(private readonly ImageRepositoryInterface $imageRepository) {}
public function __invoke(int $eventId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php b/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php
index aa45ebb860..f23b4d1251 100644
--- a/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php
+++ b/backend/app/Http/Actions/Events/Stats/GetEventStatsAction.php
@@ -9,26 +9,67 @@
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/Events/UpdateEventStatusAction.php b/backend/app/Http/Actions/Events/UpdateEventStatusAction.php
index 0d33a45f54..fba1951710 100644
--- a/backend/app/Http/Actions/Events/UpdateEventStatusAction.php
+++ b/backend/app/Http/Actions/Events/UpdateEventStatusAction.php
@@ -16,9 +16,7 @@ class UpdateEventStatusAction extends BaseAction
{
public function __construct(
private readonly UpdateEventStatusHandler $updateEventStatusHandler,
- )
- {
- }
+ ) {}
public function __invoke(UpdateEventStatusRequest $request, int $eventId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Images/CreateImageAction.php b/backend/app/Http/Actions/Images/CreateImageAction.php
index f8f3b5a112..4d3ac2690b 100644
--- a/backend/app/Http/Actions/Images/CreateImageAction.php
+++ b/backend/app/Http/Actions/Images/CreateImageAction.php
@@ -15,9 +15,7 @@ class CreateImageAction extends BaseAction
{
public function __construct(
public readonly CreateImageHandler $createImageHandler,
- )
- {
- }
+ ) {}
/**
* @throws CouldNotUploadImageException
diff --git a/backend/app/Http/Actions/Images/DeleteImageAction.php b/backend/app/Http/Actions/Images/DeleteImageAction.php
index 2b9c54a255..1471dfc4ce 100644
--- a/backend/app/Http/Actions/Images/DeleteImageAction.php
+++ b/backend/app/Http/Actions/Images/DeleteImageAction.php
@@ -13,9 +13,7 @@ class DeleteImageAction extends BaseAction
{
public function __construct(
public readonly DeleteImageHandler $deleteImageHandler,
- )
- {
- }
+ ) {}
/**
* @throws CannotDeleteEntityException
diff --git a/backend/app/Http/Actions/Locations/CreateLocationAction.php b/backend/app/Http/Actions/Locations/CreateLocationAction.php
new file mode 100644
index 0000000000..d79d29fa28
--- /dev/null
+++ b/backend/app/Http/Actions/Locations/CreateLocationAction.php
@@ -0,0 +1,47 @@
+isActionAuthorized($organizerId, OrganizerDomainObject::class);
+
+ $latitude = $request->validated('latitude');
+ $longitude = $request->validated('longitude');
+
+ $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: $latitude === null ? null : (float) $latitude,
+ longitude: $longitude === null ? null : (float) $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..33d9b76436
--- /dev/null
+++ b/backend/app/Http/Actions/Locations/DeleteLocationAction.php
@@ -0,0 +1,36 @@
+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..04df5180ee
--- /dev/null
+++ b/backend/app/Http/Actions/Locations/GeoAutocompleteAction.php
@@ -0,0 +1,57 @@
+isActionAuthorized($organizerId, OrganizerDomainObject::class);
+
+ $rawQuery = $request->query('query');
+ $query = mb_substr(is_string($rawQuery) ? $rawQuery : '', 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->resourceResponse(
+ resource: GeoSuggestionResource::class,
+ data: collect($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..70adf628fb
--- /dev/null
+++ b/backend/app/Http/Actions/Locations/GeoPlaceDetailsAction.php
@@ -0,0 +1,57 @@
+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,
+ );
+ }
+
+ return $this->resourceResponse(
+ resource: GeoPlaceResource::class,
+ data: $place,
+ );
+ }
+}
diff --git a/backend/app/Http/Actions/Locations/GetGeoStatusAction.php b/backend/app/Http/Actions/Locations/GetGeoStatusAction.php
new file mode 100644
index 0000000000..d07d00217e
--- /dev/null
+++ b/backend/app/Http/Actions/Locations/GetGeoStatusAction.php
@@ -0,0 +1,25 @@
+jsonResponse([
+ 'data' => [
+ 'available' => $this->geoProvider->isAvailable(),
+ ],
+ ]);
+ }
+}
diff --git a/backend/app/Http/Actions/Locations/GetLocationsAction.php b/backend/app/Http/Actions/Locations/GetLocationsAction.php
new file mode 100644
index 0000000000..9469ef5f80
--- /dev/null
+++ b/backend/app/Http/Actions/Locations/GetLocationsAction.php
@@ -0,0 +1,38 @@
+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: 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..9e689c52b3
--- /dev/null
+++ b/backend/app/Http/Actions/Locations/UpdateLocationAction.php
@@ -0,0 +1,54 @@
+isActionAuthorized($organizerId, OrganizerDomainObject::class);
+
+ $latitude = $request->validated('latitude');
+ $longitude = $request->validated('longitude');
+
+ try {
+ $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: $latitude === null ? null : (float) $latitude,
+ longitude: $longitude === null ? null : (float) $longitude,
+ provider: $request->validated('provider'),
+ provider_place_id: $request->validated('provider_place_id'),
+ ));
+ } catch (ResourceConflictException $exception) {
+ return $this->errorResponse(
+ message: $exception->getMessage(),
+ statusCode: ResponseCodes::HTTP_CONFLICT,
+ );
+ }
+
+ return $this->resourceResponse(
+ resource: LocationResource::class,
+ data: $location,
+ );
+ }
+}
diff --git a/backend/app/Http/Actions/Messages/CancelMessageAction.php b/backend/app/Http/Actions/Messages/CancelMessageAction.php
index ff232f09fe..e97fffa25d 100644
--- a/backend/app/Http/Actions/Messages/CancelMessageAction.php
+++ b/backend/app/Http/Actions/Messages/CancelMessageAction.php
@@ -13,9 +13,7 @@ class CancelMessageAction extends BaseAction
{
public function __construct(
private readonly CancelMessageHandler $cancelMessageHandler,
- )
- {
- }
+ ) {}
public function __invoke(Request $request, int $eventId, int $messageId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Messages/GetMessageRecipientsAction.php b/backend/app/Http/Actions/Messages/GetMessageRecipientsAction.php
index 4b944ca379..a1233ef927 100644
--- a/backend/app/Http/Actions/Messages/GetMessageRecipientsAction.php
+++ b/backend/app/Http/Actions/Messages/GetMessageRecipientsAction.php
@@ -13,9 +13,7 @@ class GetMessageRecipientsAction extends BaseAction
{
public function __construct(
private readonly GetMessageRecipientsHandler $handler,
- )
- {
- }
+ ) {}
public function __invoke(Request $request, int $eventId, int $messageId): JsonResponse
{
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/CancelOrderAction.php b/backend/app/Http/Actions/Orders/CancelOrderAction.php
index 67cf6f6e1a..6d81aa082d 100644
--- a/backend/app/Http/Actions/Orders/CancelOrderAction.php
+++ b/backend/app/Http/Actions/Orders/CancelOrderAction.php
@@ -22,9 +22,7 @@ class CancelOrderAction extends BaseAction
{
public function __construct(
private readonly CancelOrderHandler $cancelOrderHandler,
- )
- {
- }
+ ) {}
/**
* @throws Throwable
@@ -45,7 +43,7 @@ public function __invoke(int $eventId, int $orderId, Request $request): JsonResp
} catch (ApiErrorException|RefundNotPossibleException $exception) {
throw ValidationException::withMessages([
'refund' => $exception instanceof ApiErrorException
- ? 'Stripe error: ' . $exception->getMessage()
+ ? 'Stripe error: '.$exception->getMessage()
: $exception->getMessage(),
]);
}
diff --git a/backend/app/Http/Actions/Orders/DownloadOrderInvoiceAction.php b/backend/app/Http/Actions/Orders/DownloadOrderInvoiceAction.php
index b78a2dc466..0f3dc1bb89 100644
--- a/backend/app/Http/Actions/Orders/DownloadOrderInvoiceAction.php
+++ b/backend/app/Http/Actions/Orders/DownloadOrderInvoiceAction.php
@@ -13,9 +13,7 @@ class DownloadOrderInvoiceAction extends BaseAction
{
public function __construct(
private readonly GetOrderInvoiceHandler $orderInvoiceHandler,
- )
- {
- }
+ ) {}
public function __invoke(Request $request, int $eventId, int $orderId): Response
{
diff --git a/backend/app/Http/Actions/Orders/EditOrderAction.php b/backend/app/Http/Actions/Orders/EditOrderAction.php
index ba919daf00..0a85f8962d 100644
--- a/backend/app/Http/Actions/Orders/EditOrderAction.php
+++ b/backend/app/Http/Actions/Orders/EditOrderAction.php
@@ -14,9 +14,7 @@ class EditOrderAction extends BaseAction
{
public function __construct(
private readonly EditOrderHandler $handler
- )
- {
- }
+ ) {}
public function __invoke(EditOrderRequest $request, int $eventId, int $orderId): JsonResponse
{
@@ -33,5 +31,4 @@ public function __invoke(EditOrderRequest $request, int $eventId, int $orderId):
return $this->resourceResponse(OrderResource::class, $order);
}
-
}
diff --git a/backend/app/Http/Actions/Orders/ExportOrdersAction.php b/backend/app/Http/Actions/Orders/ExportOrdersAction.php
index 66043857d6..f30f317a83 100644
--- a/backend/app/Http/Actions/Orders/ExportOrdersAction.php
+++ b/backend/app/Http/Actions/Orders/ExportOrdersAction.php
@@ -4,35 +4,59 @@
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;
class ExportOrdersAction extends BaseAction
{
public function __construct(
- private readonly OrderRepositoryInterface $orderRepository,
+ private readonly OrderRepositoryInterface $orderRepository,
private readonly QuestionRepositoryInterface $questionRepository,
- private readonly OrdersExport $export
- )
- {
- }
+ private readonly OrdersExport $export
+ ) {}
- 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/MarkOrderAsPaidAction.php b/backend/app/Http/Actions/Orders/MarkOrderAsPaidAction.php
index d9126afe3e..fe23ee683b 100644
--- a/backend/app/Http/Actions/Orders/MarkOrderAsPaidAction.php
+++ b/backend/app/Http/Actions/Orders/MarkOrderAsPaidAction.php
@@ -15,9 +15,7 @@ class MarkOrderAsPaidAction extends BaseAction
{
public function __construct(
private readonly MarkOrderAsPaidHandler $markOrderAsPaidHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, int $orderId): JsonResponse|Response
{
diff --git a/backend/app/Http/Actions/Orders/Payment/RefundOrderAction.php b/backend/app/Http/Actions/Orders/Payment/RefundOrderAction.php
index 205409e2ed..ebc2b77815 100644
--- a/backend/app/Http/Actions/Orders/Payment/RefundOrderAction.php
+++ b/backend/app/Http/Actions/Orders/Payment/RefundOrderAction.php
@@ -16,9 +16,7 @@
class RefundOrderAction extends BaseAction
{
- public function __construct(private readonly RefundOrderHandler $refundOrderHandler)
- {
- }
+ public function __construct(private readonly RefundOrderHandler $refundOrderHandler) {}
/**
* @throws Throwable
@@ -38,7 +36,7 @@ public function __invoke(RefundOrderRequest $request, int $eventId, int $orderId
} catch (ApiErrorException|RefundNotPossibleException $exception) {
throw ValidationException::withMessages([
'amount' => $exception instanceof ApiErrorException
- ? 'Stripe error: ' . $exception->getMessage()
+ ? 'Stripe error: '.$exception->getMessage()
: $exception->getMessage(),
]);
}
diff --git a/backend/app/Http/Actions/Orders/Payment/Stripe/CreatePaymentIntentActionPublic.php b/backend/app/Http/Actions/Orders/Payment/Stripe/CreatePaymentIntentActionPublic.php
index bd2ea51c78..e9c86fe965 100644
--- a/backend/app/Http/Actions/Orders/Payment/Stripe/CreatePaymentIntentActionPublic.php
+++ b/backend/app/Http/Actions/Orders/Payment/Stripe/CreatePaymentIntentActionPublic.php
@@ -12,9 +12,7 @@ class CreatePaymentIntentActionPublic extends BaseAction
{
public function __construct(
private readonly CreatePaymentIntentHandler $createPaymentIntentHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, string $orderShortId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Orders/Payment/Stripe/GetPaymentIntentActionPublic.php b/backend/app/Http/Actions/Orders/Payment/Stripe/GetPaymentIntentActionPublic.php
index 3b5073c1aa..f897249925 100644
--- a/backend/app/Http/Actions/Orders/Payment/Stripe/GetPaymentIntentActionPublic.php
+++ b/backend/app/Http/Actions/Orders/Payment/Stripe/GetPaymentIntentActionPublic.php
@@ -10,9 +10,7 @@ class GetPaymentIntentActionPublic extends BaseAction
{
public function __construct(
private readonly GetPaymentIntentHandler $getPaymentIntentHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, string $orderShortId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Orders/Public/AbandonOrderActionPublic.php b/backend/app/Http/Actions/Orders/Public/AbandonOrderActionPublic.php
index 3fd3ead73e..fab1edfb96 100644
--- a/backend/app/Http/Actions/Orders/Public/AbandonOrderActionPublic.php
+++ b/backend/app/Http/Actions/Orders/Public/AbandonOrderActionPublic.php
@@ -15,9 +15,7 @@ class AbandonOrderActionPublic extends BaseAction
{
public function __construct(
private readonly AbandonOrderPublicHandler $abandonOrderPublicHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, string $orderShortId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Orders/Public/CompleteOrderActionPublic.php b/backend/app/Http/Actions/Orders/Public/CompleteOrderActionPublic.php
index e34a362644..96704dcd5f 100644
--- a/backend/app/Http/Actions/Orders/Public/CompleteOrderActionPublic.php
+++ b/backend/app/Http/Actions/Orders/Public/CompleteOrderActionPublic.php
@@ -16,9 +16,7 @@
class CompleteOrderActionPublic extends BaseAction
{
- public function __construct(private readonly CompleteOrderHandler $orderService)
- {
- }
+ public function __construct(private readonly CompleteOrderHandler $orderService) {}
public function __invoke(CompleteOrderRequest $request, int $eventId, string $orderShortId): JsonResponse
{
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/Public/DownloadOrderInvoicePublicAction.php b/backend/app/Http/Actions/Orders/Public/DownloadOrderInvoicePublicAction.php
index 686662c63f..2c57de8d8d 100644
--- a/backend/app/Http/Actions/Orders/Public/DownloadOrderInvoicePublicAction.php
+++ b/backend/app/Http/Actions/Orders/Public/DownloadOrderInvoicePublicAction.php
@@ -10,9 +10,7 @@ class DownloadOrderInvoicePublicAction extends BaseAction
{
public function __construct(
private readonly DownloadOrderInvoicePublicHandler $downloadOrderInvoicePublicHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, string $orderShortId): Response
{
diff --git a/backend/app/Http/Actions/Orders/Public/GetOrderActionPublic.php b/backend/app/Http/Actions/Orders/Public/GetOrderActionPublic.php
index ff8133af4f..e28893a104 100644
--- a/backend/app/Http/Actions/Orders/Public/GetOrderActionPublic.php
+++ b/backend/app/Http/Actions/Orders/Public/GetOrderActionPublic.php
@@ -13,11 +13,9 @@
class GetOrderActionPublic extends BaseAction
{
public function __construct(
- private readonly GetOrderPublicHandler $getOrderPublicHandler,
+ private readonly GetOrderPublicHandler $getOrderPublicHandler,
private readonly CheckoutSessionManagementService $sessionService,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, string $orderShortId, Request $request): JsonResponse
{
diff --git a/backend/app/Http/Actions/Orders/Public/TransitionOrderToOfflinePaymentPublicAction.php b/backend/app/Http/Actions/Orders/Public/TransitionOrderToOfflinePaymentPublicAction.php
index 39180937bc..12fc6e56f5 100644
--- a/backend/app/Http/Actions/Orders/Public/TransitionOrderToOfflinePaymentPublicAction.php
+++ b/backend/app/Http/Actions/Orders/Public/TransitionOrderToOfflinePaymentPublicAction.php
@@ -13,9 +13,7 @@ class TransitionOrderToOfflinePaymentPublicAction extends BaseAction
{
public function __construct(
private readonly TransitionOrderToOfflinePaymentHandler $initializeOrderOfflinePaymentPublicHandler,
- )
- {
- }
+ ) {}
public function __invoke(Request $request, int $eventId, string $orderShortId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php b/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php
index c38c6cbac3..309bca9d7c 100644
--- a/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php
+++ b/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php
@@ -3,13 +3,15 @@
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;
-use HiEvents\Mail\Order\OrderSummary;
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
@@ -22,11 +24,9 @@ class ResendOrderConfirmationAction extends BaseAction
public function __construct(
private readonly EventRepositoryInterface $eventRepository,
private readonly OrderRepositoryInterface $orderRepository,
- private readonly Mailer $mailer,
- private readonly MailBuilderService $mailBuilderService,
- )
- {
- }
+ private readonly Mailer $mailer,
+ private readonly MailBuilderService $mailBuilderService,
+ ) {}
/**
* @todo - move this to a handler
@@ -36,14 +36,24 @@ 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,
OrderDomainObjectAbstract::ID => $orderId,
]);
- if (!$order) {
+ if (! $order) {
return $this->notFoundResponse();
}
@@ -51,6 +61,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/CreateOrganizerAction.php b/backend/app/Http/Actions/Organizers/CreateOrganizerAction.php
index 85448d0bf8..6a2f571ef1 100644
--- a/backend/app/Http/Actions/Organizers/CreateOrganizerAction.php
+++ b/backend/app/Http/Actions/Organizers/CreateOrganizerAction.php
@@ -12,9 +12,7 @@
class CreateOrganizerAction extends BaseAction
{
- public function __construct(private readonly CreateOrganizerHandler $createOrganizerHandler)
- {
- }
+ public function __construct(private readonly CreateOrganizerHandler $createOrganizerHandler) {}
public function __invoke(UpsertOrganizerRequest $request): JsonResponse
{
diff --git a/backend/app/Http/Actions/Organizers/DeleteOrganizerAction.php b/backend/app/Http/Actions/Organizers/DeleteOrganizerAction.php
index 54146aa7dc..af509ca400 100644
--- a/backend/app/Http/Actions/Organizers/DeleteOrganizerAction.php
+++ b/backend/app/Http/Actions/Organizers/DeleteOrganizerAction.php
@@ -18,9 +18,7 @@ class DeleteOrganizerAction extends BaseAction
{
public function __construct(
private readonly DeleteOrganizerHandler $deleteOrganizerHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $organizerId): Response|JsonResponse
{
diff --git a/backend/app/Http/Actions/Organizers/EditOrganizerAction.php b/backend/app/Http/Actions/Organizers/EditOrganizerAction.php
index 397e21c1a3..5499c4ac13 100644
--- a/backend/app/Http/Actions/Organizers/EditOrganizerAction.php
+++ b/backend/app/Http/Actions/Organizers/EditOrganizerAction.php
@@ -12,9 +12,7 @@
class EditOrganizerAction extends BaseAction
{
- public function __construct(private readonly EditOrganizerHandler $editOrganizerHandler)
- {
- }
+ public function __construct(private readonly EditOrganizerHandler $editOrganizerHandler) {}
public function __invoke(UpsertOrganizerRequest $request, int $organizerId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Organizers/GetOrganizerAction.php b/backend/app/Http/Actions/Organizers/GetOrganizerAction.php
index 896c7cd0cc..e43912050a 100644
--- a/backend/app/Http/Actions/Organizers/GetOrganizerAction.php
+++ b/backend/app/Http/Actions/Organizers/GetOrganizerAction.php
@@ -3,17 +3,19 @@
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\Http\Actions\BaseAction;
+use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface;
use HiEvents\Resources\Organizer\OrganizerResource;
use Symfony\Component\HttpFoundation\Response;
class GetOrganizerAction extends BaseAction
{
- public function __construct(private readonly OrganizerRepositoryInterface $organizerRepository)
- {
- }
+ public function __construct(private readonly OrganizerRepositoryInterface $organizerRepository) {}
public function __invoke(int $organizerId): Response
{
@@ -24,6 +26,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/GetOrganizerDeletionStatusAction.php b/backend/app/Http/Actions/Organizers/GetOrganizerDeletionStatusAction.php
index 6fb62ffed0..86ea25c42e 100644
--- a/backend/app/Http/Actions/Organizers/GetOrganizerDeletionStatusAction.php
+++ b/backend/app/Http/Actions/Organizers/GetOrganizerDeletionStatusAction.php
@@ -13,9 +13,7 @@ class GetOrganizerDeletionStatusAction extends BaseAction
{
public function __construct(
private readonly OrganizerDeletionService $organizerDeletionService,
- )
- {
- }
+ ) {}
public function __invoke(int $organizerId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Organizers/GetOrganizerEventsAction.php b/backend/app/Http/Actions/Organizers/GetOrganizerEventsAction.php
index b5e8aed696..d1f70021d3 100644
--- a/backend/app/Http/Actions/Organizers/GetOrganizerEventsAction.php
+++ b/backend/app/Http/Actions/Organizers/GetOrganizerEventsAction.php
@@ -16,9 +16,7 @@ class GetOrganizerEventsAction extends BaseAction
{
public function __construct(
private readonly GetOrganizerEventsHandler $getOrganizerEventsHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $organizerId, Request $request): JsonResponse
{
diff --git a/backend/app/Http/Actions/Organizers/GetOrganizersAction.php b/backend/app/Http/Actions/Organizers/GetOrganizersAction.php
index f832b54e2c..28a7203ad8 100644
--- a/backend/app/Http/Actions/Organizers/GetOrganizersAction.php
+++ b/backend/app/Http/Actions/Organizers/GetOrganizersAction.php
@@ -10,9 +10,7 @@
class GetOrganizersAction extends BaseAction
{
- public function __construct(private readonly OrganizerRepositoryInterface $organizerRepository)
- {
- }
+ public function __construct(private readonly OrganizerRepositoryInterface $organizerRepository) {}
public function __invoke(): JsonResponse
{
diff --git a/backend/app/Http/Actions/Organizers/GetPublicOrganizerAction.php b/backend/app/Http/Actions/Organizers/GetPublicOrganizerAction.php
index 4b12d671cf..7797fa6492 100644
--- a/backend/app/Http/Actions/Organizers/GetPublicOrganizerAction.php
+++ b/backend/app/Http/Actions/Organizers/GetPublicOrganizerAction.php
@@ -15,17 +15,15 @@ class GetPublicOrganizerAction extends BaseAction
{
public function __construct(
private readonly GetPublicOrganizerHandler $handler,
- private readonly LoggerInterface $logger,
+ private readonly LoggerInterface $logger,
- )
- {
- }
+ ) {}
public function __invoke(int $organizerId): Response|JsonResponse
{
$organizer = $this->handler->handle($organizerId);
- if (!$this->canUserViewOrganizer($organizer)) {
+ if (! $this->canUserViewOrganizer($organizer)) {
$this->logger->debug(__('Organizer with ID :organizerId is not live and user is not authenticated', [
'organizerId' => $organizer->getId(),
]));
diff --git a/backend/app/Http/Actions/Organizers/Orders/GetOrganizerOrdersAction.php b/backend/app/Http/Actions/Organizers/Orders/GetOrganizerOrdersAction.php
index 979595b97e..697daebd1d 100644
--- a/backend/app/Http/Actions/Organizers/Orders/GetOrganizerOrdersAction.php
+++ b/backend/app/Http/Actions/Organizers/Orders/GetOrganizerOrdersAction.php
@@ -14,9 +14,7 @@ class GetOrganizerOrdersAction extends BaseAction
{
public function __construct(
private readonly GetOrganizerOrdersHandler $handler,
- )
- {
- }
+ ) {}
public function __invoke(Request $request, int $organizerId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Organizers/Public/SendOrganizerContactMessagePublicAction.php b/backend/app/Http/Actions/Organizers/Public/SendOrganizerContactMessagePublicAction.php
index d1251a3f70..55ff86f4c0 100644
--- a/backend/app/Http/Actions/Organizers/Public/SendOrganizerContactMessagePublicAction.php
+++ b/backend/app/Http/Actions/Organizers/Public/SendOrganizerContactMessagePublicAction.php
@@ -13,9 +13,7 @@ class SendOrganizerContactMessagePublicAction extends BaseAction
{
public function __construct(
private readonly SendOrganizerContactMessageHandler $handler,
- )
- {
- }
+ ) {}
/**
* @throws ValidationException
diff --git a/backend/app/Http/Actions/Organizers/Settings/GetOrganizerSettingsAction.php b/backend/app/Http/Actions/Organizers/Settings/GetOrganizerSettingsAction.php
index 33c1fd309c..cc0de34347 100644
--- a/backend/app/Http/Actions/Organizers/Settings/GetOrganizerSettingsAction.php
+++ b/backend/app/Http/Actions/Organizers/Settings/GetOrganizerSettingsAction.php
@@ -11,16 +11,14 @@
class GetOrganizerSettingsAction extends BaseAction
{
- public function __construct(private readonly OrganizerSettingsRepositoryInterface $settingsRepository)
- {
- }
+ public function __construct(private readonly OrganizerSettingsRepositoryInterface $settingsRepository) {}
public function __invoke(int $organizerId): Response|JsonResponse
{
$this->isActionAuthorized($organizerId, OrganizerDomainObject::class);
$settings = $this->settingsRepository->findFirstWhere([
- 'organizer_id' => $organizerId
+ 'organizer_id' => $organizerId,
]);
if ($settings === null) {
diff --git a/backend/app/Http/Actions/Organizers/Settings/PartialUpdateOrganizerSettingsAction.php b/backend/app/Http/Actions/Organizers/Settings/PartialUpdateOrganizerSettingsAction.php
index 9d171a4ba0..fca286c8cd 100644
--- a/backend/app/Http/Actions/Organizers/Settings/PartialUpdateOrganizerSettingsAction.php
+++ b/backend/app/Http/Actions/Organizers/Settings/PartialUpdateOrganizerSettingsAction.php
@@ -14,9 +14,7 @@ class PartialUpdateOrganizerSettingsAction extends BaseAction
{
public function __construct(
private readonly PartialUpdateOrganizerSettingsHandler $handler,
- )
- {
- }
+ ) {}
public function __invoke(PartialUpdateOrganizerSettingsRequest $request, int $organizerId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Organizers/Stats/GetOrganizerStatsAction.php b/backend/app/Http/Actions/Organizers/Stats/GetOrganizerStatsAction.php
index 474bf84cc5..e81494e5d1 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,34 @@ 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..f32ca6cb5e
--- /dev/null
+++ b/backend/app/Http/Actions/Organizers/Stripe/CopyStripeConnectAccountAction.php
@@ -0,0 +1,60 @@
+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 55%
rename from backend/app/Http/Actions/Accounts/Stripe/CreateStripeConnectAccountAction.php
rename to backend/app/Http/Actions/Organizers/Stripe/CreateStripeConnectAccountAction.php
index 224121ebfb..20cc10c599 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 +42,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..cf044e8a52
--- /dev/null
+++ b/backend/app/Http/Actions/Organizers/Stripe/GetStripeConnectAccountsAction.php
@@ -0,0 +1,42 @@
+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/UpdateOrganizerStatusAction.php b/backend/app/Http/Actions/Organizers/UpdateOrganizerStatusAction.php
index c2453b6e9c..de9f328188 100644
--- a/backend/app/Http/Actions/Organizers/UpdateOrganizerStatusAction.php
+++ b/backend/app/Http/Actions/Organizers/UpdateOrganizerStatusAction.php
@@ -17,9 +17,7 @@ class UpdateOrganizerStatusAction extends BaseAction
{
public function __construct(
private readonly UpdateOrganizerStatusHandler $updateOrganizerStatusHandler,
- )
- {
- }
+ ) {}
public function __invoke(UpdateOrganizerStatusRequest $request, int $organizerId): JsonResponse
{
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..ec8879a7a7
--- /dev/null
+++ b/backend/app/Http/Actions/Organizers/Vat/GetOrganizerVatSettingAction.php
@@ -0,0 +1,31 @@
+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..2f70810b69
--- /dev/null
+++ b/backend/app/Http/Actions/Organizers/Vat/UpsertOrganizerVatSettingAction.php
@@ -0,0 +1,49 @@
+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/Organizers/Webhooks/CreateOrganizerWebhookAction.php b/backend/app/Http/Actions/Organizers/Webhooks/CreateOrganizerWebhookAction.php
index d357b2ee04..556b653d1f 100644
--- a/backend/app/Http/Actions/Organizers/Webhooks/CreateOrganizerWebhookAction.php
+++ b/backend/app/Http/Actions/Organizers/Webhooks/CreateOrganizerWebhookAction.php
@@ -3,7 +3,6 @@
namespace HiEvents\Http\Actions\Organizers\Webhooks;
use HiEvents\DomainObjects\OrganizerDomainObject;
-use HiEvents\DomainObjects\Status\WebhookStatus;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Http\Request\Webhook\UpsertWebhookRequest;
use HiEvents\Resources\Webhook\WebhookResourceWithSecret;
@@ -15,9 +14,7 @@ class CreateOrganizerWebhookAction extends BaseAction
{
public function __construct(
private readonly CreateWebhookHandler $createWebhookHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $organizerId, UpsertWebhookRequest $request): JsonResponse
{
@@ -31,7 +28,7 @@ public function __invoke(int $organizerId, UpsertWebhookRequest $request): JsonR
organizerId: $organizerId,
userId: $this->getAuthenticatedUser()->getId(),
accountId: $this->getAuthenticatedAccountId(),
- status: WebhookStatus::fromName($request->validated('status')),
+ status: $request->getStatus(),
)
);
diff --git a/backend/app/Http/Actions/Organizers/Webhooks/DeleteOrganizerWebhookAction.php b/backend/app/Http/Actions/Organizers/Webhooks/DeleteOrganizerWebhookAction.php
index b6ca306d9a..1ababb8108 100644
--- a/backend/app/Http/Actions/Organizers/Webhooks/DeleteOrganizerWebhookAction.php
+++ b/backend/app/Http/Actions/Organizers/Webhooks/DeleteOrganizerWebhookAction.php
@@ -11,9 +11,7 @@ class DeleteOrganizerWebhookAction extends BaseAction
{
public function __construct(
private readonly DeleteWebhookHandler $deleteWebhookHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $organizerId, int $webhookId): Response
{
diff --git a/backend/app/Http/Actions/Organizers/Webhooks/EditOrganizerWebhookAction.php b/backend/app/Http/Actions/Organizers/Webhooks/EditOrganizerWebhookAction.php
index 6bc51334cb..184af8b686 100644
--- a/backend/app/Http/Actions/Organizers/Webhooks/EditOrganizerWebhookAction.php
+++ b/backend/app/Http/Actions/Organizers/Webhooks/EditOrganizerWebhookAction.php
@@ -3,7 +3,6 @@
namespace HiEvents\Http\Actions\Organizers\Webhooks;
use HiEvents\DomainObjects\OrganizerDomainObject;
-use HiEvents\DomainObjects\Status\WebhookStatus;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Http\Request\Webhook\UpsertWebhookRequest;
use HiEvents\Resources\Webhook\WebhookResource;
@@ -15,9 +14,7 @@ class EditOrganizerWebhookAction extends BaseAction
{
public function __construct(
private readonly EditWebhookHandler $editWebhookHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $organizerId, int $webhookId, UpsertWebhookRequest $request): JsonResponse
{
@@ -32,7 +29,7 @@ public function __invoke(int $organizerId, int $webhookId, UpsertWebhookRequest
organizerId: $organizerId,
userId: $this->getAuthenticatedUser()->getId(),
accountId: $this->getAuthenticatedAccountId(),
- status: WebhookStatus::fromName($request->validated('status')),
+ status: $request->getStatus(),
)
);
diff --git a/backend/app/Http/Actions/Organizers/Webhooks/GetOrganizerWebhookAction.php b/backend/app/Http/Actions/Organizers/Webhooks/GetOrganizerWebhookAction.php
index 7d3e25cf25..77b2bf93fc 100644
--- a/backend/app/Http/Actions/Organizers/Webhooks/GetOrganizerWebhookAction.php
+++ b/backend/app/Http/Actions/Organizers/Webhooks/GetOrganizerWebhookAction.php
@@ -12,9 +12,7 @@ class GetOrganizerWebhookAction extends BaseAction
{
public function __construct(
private readonly GetWebhookHandler $getWebhookHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $organizerId, int $webhookId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Organizers/Webhooks/GetOrganizerWebhookLogsAction.php b/backend/app/Http/Actions/Organizers/Webhooks/GetOrganizerWebhookLogsAction.php
index 66016aa758..793edd31da 100644
--- a/backend/app/Http/Actions/Organizers/Webhooks/GetOrganizerWebhookLogsAction.php
+++ b/backend/app/Http/Actions/Organizers/Webhooks/GetOrganizerWebhookLogsAction.php
@@ -13,9 +13,7 @@ class GetOrganizerWebhookLogsAction extends BaseAction
{
public function __construct(
private readonly GetWebhookLogsHandler $getWebhookLogsHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $organizerId, int $webhookId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Organizers/Webhooks/GetOrganizerWebhooksAction.php b/backend/app/Http/Actions/Organizers/Webhooks/GetOrganizerWebhooksAction.php
index f6e7e51ef1..70d0020fcd 100644
--- a/backend/app/Http/Actions/Organizers/Webhooks/GetOrganizerWebhooksAction.php
+++ b/backend/app/Http/Actions/Organizers/Webhooks/GetOrganizerWebhooksAction.php
@@ -12,9 +12,7 @@ class GetOrganizerWebhooksAction extends BaseAction
{
public function __construct(
private readonly GetWebhooksHandler $getWebhooksHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $organizerId): JsonResponse
{
diff --git a/backend/app/Http/Actions/ProductCategories/CreateProductCategoryAction.php b/backend/app/Http/Actions/ProductCategories/CreateProductCategoryAction.php
index d3b8460043..7a49b9bf65 100644
--- a/backend/app/Http/Actions/ProductCategories/CreateProductCategoryAction.php
+++ b/backend/app/Http/Actions/ProductCategories/CreateProductCategoryAction.php
@@ -15,9 +15,7 @@ class CreateProductCategoryAction extends BaseAction
{
public function __construct(
private readonly CreateProductCategoryHandler $handler
- )
- {
- }
+ ) {}
public function __invoke(UpsertProductCategoryRequest $request, int $eventId): JsonResponse
{
diff --git a/backend/app/Http/Actions/ProductCategories/DeleteProductCategoryAction.php b/backend/app/Http/Actions/ProductCategories/DeleteProductCategoryAction.php
index aba5e2f066..edc98eea04 100644
--- a/backend/app/Http/Actions/ProductCategories/DeleteProductCategoryAction.php
+++ b/backend/app/Http/Actions/ProductCategories/DeleteProductCategoryAction.php
@@ -14,9 +14,7 @@ class DeleteProductCategoryAction extends BaseAction
{
public function __construct(
private readonly DeleteProductCategoryHandler $deleteProductCategoryHandler,
- )
- {
- }
+ ) {}
/**
* @throws Throwable
@@ -25,8 +23,7 @@ public function __construct(
public function __invoke(
int $eventId,
int $productCategoryId,
- ): Response|JsonResponse
- {
+ ): Response|JsonResponse {
$this->isActionAuthorized($eventId, EventDomainObject::class);
try {
diff --git a/backend/app/Http/Actions/ProductCategories/EditProductCategoryAction.php b/backend/app/Http/Actions/ProductCategories/EditProductCategoryAction.php
index 56a64fb20a..a912f3a54b 100644
--- a/backend/app/Http/Actions/ProductCategories/EditProductCategoryAction.php
+++ b/backend/app/Http/Actions/ProductCategories/EditProductCategoryAction.php
@@ -14,9 +14,7 @@ class EditProductCategoryAction extends BaseAction
{
public function __construct(
private readonly EditProductCategoryHandler $editProductCategoryHandler,
- )
- {
- }
+ ) {}
public function __invoke(UpsertProductCategoryRequest $request, int $eventId, int $productCategoryId): JsonResponse
{
diff --git a/backend/app/Http/Actions/ProductCategories/GetProductCategoriesAction.php b/backend/app/Http/Actions/ProductCategories/GetProductCategoriesAction.php
index daf9512e8b..475d3ea51f 100644
--- a/backend/app/Http/Actions/ProductCategories/GetProductCategoriesAction.php
+++ b/backend/app/Http/Actions/ProductCategories/GetProductCategoriesAction.php
@@ -12,9 +12,7 @@ class GetProductCategoriesAction extends BaseAction
{
public function __construct(
private readonly GetProductCategoriesHandler $getProductCategoriesHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId): JsonResponse
{
diff --git a/backend/app/Http/Actions/ProductCategories/GetProductCategoryAction.php b/backend/app/Http/Actions/ProductCategories/GetProductCategoryAction.php
index ddec4dd581..10c2acdec3 100644
--- a/backend/app/Http/Actions/ProductCategories/GetProductCategoryAction.php
+++ b/backend/app/Http/Actions/ProductCategories/GetProductCategoryAction.php
@@ -12,9 +12,7 @@ class GetProductCategoryAction extends BaseAction
{
public function __construct(
private readonly GetProductCategoryHandler $getProductCategoryHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, int $productCategoryId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Products/EditProductAction.php b/backend/app/Http/Actions/Products/EditProductAction.php
index c9f79d1ca9..b200bc2425 100644
--- a/backend/app/Http/Actions/Products/EditProductAction.php
+++ b/backend/app/Http/Actions/Products/EditProductAction.php
@@ -20,9 +20,7 @@ class EditProductAction extends BaseAction
{
public function __construct(
private readonly EditProductHandler $editProductHandler,
- )
- {
- }
+ ) {}
/**
* @throws Throwable
diff --git a/backend/app/Http/Actions/Products/GetProductsAction.php b/backend/app/Http/Actions/Products/GetProductsAction.php
index d71e5e1c2d..0782e27832 100644
--- a/backend/app/Http/Actions/Products/GetProductsAction.php
+++ b/backend/app/Http/Actions/Products/GetProductsAction.php
@@ -16,9 +16,7 @@ class GetProductsAction extends BaseAction
{
public function __construct(
private readonly GetProductsHandler $getProductsHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, Request $request): JsonResponse
{
diff --git a/backend/app/Http/Actions/Products/SortProductsAction.php b/backend/app/Http/Actions/Products/SortProductsAction.php
index ea5f4504d5..671875ea9f 100644
--- a/backend/app/Http/Actions/Products/SortProductsAction.php
+++ b/backend/app/Http/Actions/Products/SortProductsAction.php
@@ -14,9 +14,7 @@ class SortProductsAction extends BaseAction
{
public function __construct(
private readonly SortProductsHandler $sortProductsHandler
- )
- {
- }
+ ) {}
public function __invoke(SortProductsRequest $request, int $eventId): Response|JsonResponse
{
@@ -33,5 +31,4 @@ public function __invoke(SortProductsRequest $request, int $eventId): Response|J
return $this->noContentResponse();
}
-
}
diff --git a/backend/app/Http/Actions/PromoCodes/CreatePromoCodeAction.php b/backend/app/Http/Actions/PromoCodes/CreatePromoCodeAction.php
index 0e2bc37f73..41f39d2bd0 100644
--- a/backend/app/Http/Actions/PromoCodes/CreatePromoCodeAction.php
+++ b/backend/app/Http/Actions/PromoCodes/CreatePromoCodeAction.php
@@ -2,6 +2,7 @@
namespace HiEvents\Http\Actions\PromoCodes;
+use HiEvents\DomainObjects\Enums\PromoCodeDiscountAppliesToEnum;
use HiEvents\DomainObjects\Enums\PromoCodeDiscountTypeEnum;
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\Exceptions\ResourceConflictException;
@@ -40,6 +41,9 @@ public function __invoke(CreateUpdatePromoCodeRequest $request, int $eventId): J
discount: $request->float('discount'),
expiry_date: $request->input('expiry_date'),
max_allowed_usages: $request->input('max_allowed_usages'),
+ discount_applies_to: PromoCodeDiscountAppliesToEnum::fromName(
+ $request->input('discount_applies_to', PromoCodeDiscountAppliesToEnum::EACH_PRODUCT->name)
+ ),
));
} catch (ResourceConflictException $e) {
throw ValidationException::withMessages([
diff --git a/backend/app/Http/Actions/PromoCodes/DeletePromoCodeAction.php b/backend/app/Http/Actions/PromoCodes/DeletePromoCodeAction.php
index f6bc2be6e0..0985b54ba8 100644
--- a/backend/app/Http/Actions/PromoCodes/DeletePromoCodeAction.php
+++ b/backend/app/Http/Actions/PromoCodes/DeletePromoCodeAction.php
@@ -13,9 +13,7 @@ class DeletePromoCodeAction extends BaseAction
{
public function __construct(
private readonly DeletePromoCodeHandler $deletePromoCodeHandler
- )
- {
- }
+ ) {}
public function __invoke(Request $request, int $eventId, int $promoCodeId): Response
{
diff --git a/backend/app/Http/Actions/PromoCodes/GetPromoCodePublic.php b/backend/app/Http/Actions/PromoCodes/GetPromoCodePublic.php
index f29d6e1656..c8b7763f57 100644
--- a/backend/app/Http/Actions/PromoCodes/GetPromoCodePublic.php
+++ b/backend/app/Http/Actions/PromoCodes/GetPromoCodePublic.php
@@ -12,22 +12,31 @@
class GetPromoCodePublic extends BaseAction
{
public function __construct(
- private readonly PromoCodeRepositoryInterface $promoCodeRepository,
+ private readonly PromoCodeRepositoryInterface $promoCodeRepository,
private readonly PromoCodeUsageValidationService $promoCodeUsageValidationService,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, string $promoCode, Request $request): JsonResponse
{
- // intentionally not returning a 404
$promoCode = $this->promoCodeRepository->findFirstWhere([
PromoCodeDomainObjectAbstract::CODE => strtolower(trim($promoCode)),
PromoCodeDomainObjectAbstract::EVENT_ID => $eventId,
]);
+ $isUsable = $this->promoCodeUsageValidationService->isPromoCodeUsable($promoCode);
+
+ if (! $isUsable) {
+ return $this->jsonResponse([
+ 'valid' => false,
+ ]);
+ }
+
return $this->jsonResponse([
- 'valid' => $this->promoCodeUsageValidationService->isPromoCodeUsable($promoCode),
+ 'valid' => true,
+ 'discount' => $promoCode->getDiscount(),
+ 'discount_type' => $promoCode->getDiscountType(),
+ 'discount_applies_to' => $promoCode->getDiscountAppliesTo(),
+ 'applies_to_all_products' => empty($promoCode->getApplicableProductIds()),
]);
}
}
diff --git a/backend/app/Http/Actions/PromoCodes/UpdatePromoCodeAction.php b/backend/app/Http/Actions/PromoCodes/UpdatePromoCodeAction.php
index d9ea210938..623321ed58 100644
--- a/backend/app/Http/Actions/PromoCodes/UpdatePromoCodeAction.php
+++ b/backend/app/Http/Actions/PromoCodes/UpdatePromoCodeAction.php
@@ -2,6 +2,7 @@
namespace HiEvents\Http\Actions\PromoCodes;
+use HiEvents\DomainObjects\Enums\PromoCodeDiscountAppliesToEnum;
use HiEvents\DomainObjects\Enums\PromoCodeDiscountTypeEnum;
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\Exceptions\ResourceConflictException;
@@ -40,6 +41,9 @@ public function __invoke(CreateUpdatePromoCodeRequest $request, int $eventId, in
discount: $request->float('discount'),
expiry_date: $request->input('expiry_date'),
max_allowed_usages: $request->input('max_allowed_usages'),
+ discount_applies_to: $request->has('discount_applies_to')
+ ? PromoCodeDiscountAppliesToEnum::fromName($request->input('discount_applies_to'))
+ : null,
));
} catch (ResourceConflictException $e) {
throw ValidationException::withMessages([
diff --git a/backend/app/Http/Actions/Questions/EditQuestionAnswerAction.php b/backend/app/Http/Actions/Questions/EditQuestionAnswerAction.php
index 61bc908be7..2ca5bde251 100644
--- a/backend/app/Http/Actions/Questions/EditQuestionAnswerAction.php
+++ b/backend/app/Http/Actions/Questions/EditQuestionAnswerAction.php
@@ -15,17 +15,14 @@ class EditQuestionAnswerAction extends BaseAction
{
public function __construct(
private readonly EditQuestionAnswerHandler $editQuestionAnswerHandler,
- )
- {
- }
+ ) {}
public function __invoke(
- int $eventId,
- int $questionId,
- int $questionAnswerId,
+ int $eventId,
+ int $questionId,
+ int $questionAnswerId,
EditQuestionAnswerRequest $request,
- ): Response
- {
+ ): Response {
$this->isActionAuthorized($eventId, EventDomainObject::class);
try {
diff --git a/backend/app/Http/Actions/Questions/ExportQuestionAnswersAction.php b/backend/app/Http/Actions/Questions/ExportQuestionAnswersAction.php
index 97ff0988af..42164640e3 100644
--- a/backend/app/Http/Actions/Questions/ExportQuestionAnswersAction.php
+++ b/backend/app/Http/Actions/Questions/ExportQuestionAnswersAction.php
@@ -12,9 +12,7 @@
class ExportQuestionAnswersAction extends BaseAction
{
- public function __construct(private JobPollingService $jobPollingService)
- {
- }
+ public function __construct(private JobPollingService $jobPollingService) {}
/**
* @throws Throwable
diff --git a/backend/app/Http/Actions/Questions/GetQuestionsAction.php b/backend/app/Http/Actions/Questions/GetQuestionsAction.php
index 4a234442c0..89df395aed 100644
--- a/backend/app/Http/Actions/Questions/GetQuestionsAction.php
+++ b/backend/app/Http/Actions/Questions/GetQuestionsAction.php
@@ -28,7 +28,7 @@ public function __invoke(Request $request, int $eventId): JsonResponse
$questions = $this->questionRepository
->loadRelation(
new Relationship(ProductDomainObject::class, [
- new Relationship(ProductPriceDomainObject::class)
+ new Relationship(ProductPriceDomainObject::class),
])
)
->findByEventId($eventId);
diff --git a/backend/app/Http/Actions/Questions/GetQuestionsPublicAction.php b/backend/app/Http/Actions/Questions/GetQuestionsPublicAction.php
index 224096ece8..8346566b76 100644
--- a/backend/app/Http/Actions/Questions/GetQuestionsPublicAction.php
+++ b/backend/app/Http/Actions/Questions/GetQuestionsPublicAction.php
@@ -27,7 +27,7 @@ public function __invoke(Request $request, int $eventId): JsonResponse
QuestionDomainObjectAbstract::EVENT_ID => $eventId,
QuestionDomainObjectAbstract::IS_HIDDEN => false,
])
- ->sortBy(fn(QuestionDomainObjectAbstract $question) => $question->getOrder());
+ ->sortBy(fn (QuestionDomainObjectAbstract $question) => $question->getOrder());
return $this->resourceResponse(QuestionResourcePublic::class, $questions);
}
diff --git a/backend/app/Http/Actions/Questions/SortQuestionsAction.php b/backend/app/Http/Actions/Questions/SortQuestionsAction.php
index 2394f3deac..d86103d82e 100644
--- a/backend/app/Http/Actions/Questions/SortQuestionsAction.php
+++ b/backend/app/Http/Actions/Questions/SortQuestionsAction.php
@@ -14,9 +14,7 @@ class SortQuestionsAction extends BaseAction
{
public function __construct(
private readonly SortQuestionsHandler $sortQuestionsHandler
- )
- {
- }
+ ) {}
public function __invoke(SortQuestionsRequest $request, int $eventId): Response|JsonResponse
{
diff --git a/backend/app/Http/Actions/Reports/ExportOrganizerReportAction.php b/backend/app/Http/Actions/Reports/ExportOrganizerReportAction.php
index 26688e20f0..9c3562403e 100644
--- a/backend/app/Http/Actions/Reports/ExportOrganizerReportAction.php
+++ b/backend/app/Http/Actions/Reports/ExportOrganizerReportAction.php
@@ -18,9 +18,7 @@ class ExportOrganizerReportAction extends BaseAction
{
private const MAX_EXPORT_ROWS = 15000;
- public function __construct(private readonly GetOrganizerReportHandler $reportHandler)
- {
- }
+ public function __construct(private readonly GetOrganizerReportHandler $reportHandler) {}
/**
* @throws ValidationException
@@ -31,7 +29,7 @@ public function __invoke(GetOrganizerReportRequest $request, int $organizerId, s
$this->validateDateRange($request);
- if (!in_array($reportType, OrganizerReportTypes::valuesArray(), true)) {
+ if (! in_array($reportType, OrganizerReportTypes::valuesArray(), true)) {
throw new BadRequestHttpException(__('Invalid report type.'));
}
@@ -52,7 +50,7 @@ public function __invoke(GetOrganizerReportRequest $request, int $organizerId, s
? $reportData->data
: $reportData;
- $filename = $reportType . '_' . date('Y-m-d_H-i-s') . '.csv';
+ $filename = $reportType.'_'.date('Y-m-d_H-i-s').'.csv';
return new StreamedResponse(function () use ($data, $reportType) {
$handle = fopen('php://output', 'w');
@@ -148,7 +146,7 @@ private function formatRowForReportType(object $row, string $reportType): array
$row->order_reference ?? '',
$row->amount_paid ?? 0,
$row->fee_amount ?? 0,
- $row->vat_rate !== null ? ($row->vat_rate * 100) . '%' : '',
+ $row->vat_rate !== null ? ($row->vat_rate * 100).'%' : '',
$row->vat_amount ?? 0,
$row->total_fee ?? 0,
$row->currency ?? '',
@@ -186,7 +184,7 @@ private function formatRowForReportType(object $row, string $reportType): array
$row->event_name ?? '',
$row->event_currency ?? '',
$row->tax_name ?? '',
- $row->tax_rate ? ($row->tax_rate * 100) . '%' : '',
+ $row->tax_rate ? ($row->tax_rate * 100).'%' : '',
$row->total_collected ?? 0,
$row->order_count ?? 0,
],
@@ -211,7 +209,7 @@ private function validateDateRange(GetOrganizerReportRequest $request): void
$startDate = $request->validated('start_date');
$endDate = $request->validated('end_date');
- if (!$startDate || !$endDate) {
+ if (! $startDate || ! $endDate) {
return;
}
diff --git a/backend/app/Http/Actions/Reports/GetOrganizerReportAction.php b/backend/app/Http/Actions/Reports/GetOrganizerReportAction.php
index e97a87f7d9..522cb05731 100644
--- a/backend/app/Http/Actions/Reports/GetOrganizerReportAction.php
+++ b/backend/app/Http/Actions/Reports/GetOrganizerReportAction.php
@@ -16,9 +16,7 @@
class GetOrganizerReportAction extends BaseAction
{
- public function __construct(private readonly GetOrganizerReportHandler $reportHandler)
- {
- }
+ public function __construct(private readonly GetOrganizerReportHandler $reportHandler) {}
/**
* @throws ValidationException
@@ -29,7 +27,7 @@ public function __invoke(GetOrganizerReportRequest $request, int $organizerId, s
$this->validateDateRange($request);
- if (!in_array($reportType, OrganizerReportTypes::valuesArray(), true)) {
+ if (! in_array($reportType, OrganizerReportTypes::valuesArray(), true)) {
throw new BadRequestHttpException(__('Invalid report type.'));
}
@@ -66,7 +64,7 @@ private function validateDateRange(GetOrganizerReportRequest $request): void
$startDate = $request->validated('start_date');
$endDate = $request->validated('end_date');
- if (!$startDate || !$endDate) {
+ if (! $startDate || ! $endDate) {
return;
}
diff --git a/backend/app/Http/Actions/Reports/GetReportAction.php b/backend/app/Http/Actions/Reports/GetReportAction.php
index 5fa1596ac3..a4dd166fb2 100644
--- a/backend/app/Http/Actions/Reports/GetReportAction.php
+++ b/backend/app/Http/Actions/Reports/GetReportAction.php
@@ -15,9 +15,7 @@
class GetReportAction extends BaseAction
{
- public function __construct(private readonly GetReportHandler $reportHandler)
- {
- }
+ public function __construct(private readonly GetReportHandler $reportHandler) {}
/**
* @throws ValidationException
@@ -28,8 +26,8 @@ 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.');
+ if (! in_array($reportType, ReportTypes::valuesArray(), true)) {
+ throw new BadRequestHttpException(__('Invalid report type.'));
}
$reportData = $this->reportHandler->handle(
@@ -38,6 +36,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 +54,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/EditAttendeePublicAction.php b/backend/app/Http/Actions/SelfService/EditAttendeePublicAction.php
index e1837688f1..5d5562e258 100644
--- a/backend/app/Http/Actions/SelfService/EditAttendeePublicAction.php
+++ b/backend/app/Http/Actions/SelfService/EditAttendeePublicAction.php
@@ -14,8 +14,7 @@ class EditAttendeePublicAction extends BaseAction
{
public function __construct(
private readonly EditAttendeePublicHandler $handler
- ) {
- }
+ ) {}
public function __invoke(
EditAttendeePublicRequest $request,
diff --git a/backend/app/Http/Actions/SelfService/EditOrderPublicAction.php b/backend/app/Http/Actions/SelfService/EditOrderPublicAction.php
index 6a9724b55f..9192a3f28b 100644
--- a/backend/app/Http/Actions/SelfService/EditOrderPublicAction.php
+++ b/backend/app/Http/Actions/SelfService/EditOrderPublicAction.php
@@ -14,8 +14,7 @@ class EditOrderPublicAction extends BaseAction
{
public function __construct(
private readonly EditOrderPublicHandler $handler
- ) {
- }
+ ) {}
public function __invoke(
EditOrderPublicRequest $request,
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/SelfService/ResendOrderConfirmationPublicAction.php b/backend/app/Http/Actions/SelfService/ResendOrderConfirmationPublicAction.php
index 51e23d933a..090cb5d5c5 100644
--- a/backend/app/Http/Actions/SelfService/ResendOrderConfirmationPublicAction.php
+++ b/backend/app/Http/Actions/SelfService/ResendOrderConfirmationPublicAction.php
@@ -14,8 +14,7 @@ class ResendOrderConfirmationPublicAction extends BaseAction
{
public function __construct(
private readonly ResendOrderConfirmationPublicHandler $handler
- ) {
- }
+ ) {}
public function __invoke(
Request $request,
diff --git a/backend/app/Http/Actions/Sitemap/GetSitemapEventsAction.php b/backend/app/Http/Actions/Sitemap/GetSitemapEventsAction.php
index 2e6707aece..ef4650b9df 100644
--- a/backend/app/Http/Actions/Sitemap/GetSitemapEventsAction.php
+++ b/backend/app/Http/Actions/Sitemap/GetSitemapEventsAction.php
@@ -15,9 +15,7 @@ class GetSitemapEventsAction extends BaseAction
public function __construct(
private readonly GetSitemapEventsHandler $handler,
- )
- {
- }
+ ) {}
public function __invoke(int $page): Response
{
diff --git a/backend/app/Http/Actions/Sitemap/GetSitemapIndexAction.php b/backend/app/Http/Actions/Sitemap/GetSitemapIndexAction.php
index 88a7f2ab33..78fcc8ba51 100644
--- a/backend/app/Http/Actions/Sitemap/GetSitemapIndexAction.php
+++ b/backend/app/Http/Actions/Sitemap/GetSitemapIndexAction.php
@@ -14,14 +14,12 @@ class GetSitemapIndexAction extends BaseAction
public function __construct(
private readonly GetSitemapIndexHandler $handler,
- )
- {
- }
+ ) {}
public function __invoke(): Response
{
$xml = $this->handler->handle();
- $cacheTtl = (int)config('sitemap.cache_ttl');
+ $cacheTtl = (int) config('sitemap.cache_ttl');
return $this->xmlResponse(
xmlContent: $xml,
diff --git a/backend/app/Http/Actions/Sitemap/GetSitemapOrganizersAction.php b/backend/app/Http/Actions/Sitemap/GetSitemapOrganizersAction.php
index 43949683b3..049af490a7 100644
--- a/backend/app/Http/Actions/Sitemap/GetSitemapOrganizersAction.php
+++ b/backend/app/Http/Actions/Sitemap/GetSitemapOrganizersAction.php
@@ -15,8 +15,7 @@ class GetSitemapOrganizersAction extends BaseAction
public function __construct(
private readonly GetSitemapOrganizersHandler $handler,
- ) {
- }
+ ) {}
public function __invoke(int $page): Response
{
diff --git a/backend/app/Http/Actions/TicketLookup/GetOrdersByLookupTokenAction.php b/backend/app/Http/Actions/TicketLookup/GetOrdersByLookupTokenAction.php
index daa6c6539b..769643e082 100644
--- a/backend/app/Http/Actions/TicketLookup/GetOrdersByLookupTokenAction.php
+++ b/backend/app/Http/Actions/TicketLookup/GetOrdersByLookupTokenAction.php
@@ -13,8 +13,7 @@ class GetOrdersByLookupTokenAction extends BaseAction
{
public function __construct(
private readonly GetOrdersByLookupTokenHandler $getOrdersByLookupTokenHandler,
- ) {
- }
+ ) {}
public function __invoke(string $token): JsonResponse
{
diff --git a/backend/app/Http/Actions/TicketLookup/SendTicketLookupEmailAction.php b/backend/app/Http/Actions/TicketLookup/SendTicketLookupEmailAction.php
index 0b70761bfd..8cab70d91d 100644
--- a/backend/app/Http/Actions/TicketLookup/SendTicketLookupEmailAction.php
+++ b/backend/app/Http/Actions/TicketLookup/SendTicketLookupEmailAction.php
@@ -12,8 +12,7 @@ class SendTicketLookupEmailAction extends BaseAction
{
public function __construct(
private readonly SendTicketLookupEmailHandler $sendTicketLookupEmailHandler,
- ) {
- }
+ ) {}
public function __invoke(SendTicketLookupEmailRequest $request): JsonResponse
{
diff --git a/backend/app/Http/Actions/Users/ConfirmEmailAddressAction.php b/backend/app/Http/Actions/Users/ConfirmEmailAddressAction.php
index f628674278..95d2957fee 100644
--- a/backend/app/Http/Actions/Users/ConfirmEmailAddressAction.php
+++ b/backend/app/Http/Actions/Users/ConfirmEmailAddressAction.php
@@ -16,9 +16,7 @@ class ConfirmEmailAddressAction extends BaseAction
{
public function __construct(
private readonly ConfirmEmailAddressHandler $confirmEmailAddressHandler
- )
- {
- }
+ ) {}
/**
* @throws DecryptionFailedException|Throwable
diff --git a/backend/app/Http/Actions/Users/ConfirmEmailWithCodeAction.php b/backend/app/Http/Actions/Users/ConfirmEmailWithCodeAction.php
index 9ec1ef428b..3c1e3568a7 100644
--- a/backend/app/Http/Actions/Users/ConfirmEmailWithCodeAction.php
+++ b/backend/app/Http/Actions/Users/ConfirmEmailWithCodeAction.php
@@ -14,9 +14,7 @@ class ConfirmEmailWithCodeAction extends BaseAction
{
public function __construct(
private readonly ConfirmEmailWithCodeHandler $confirmEmailWithCodeHandler
- )
- {
- }
+ ) {}
/**
* @throws ValidationException
diff --git a/backend/app/Http/Actions/Users/CreateUserAction.php b/backend/app/Http/Actions/Users/CreateUserAction.php
index 46c31ee46d..097c2e2e97 100644
--- a/backend/app/Http/Actions/Users/CreateUserAction.php
+++ b/backend/app/Http/Actions/Users/CreateUserAction.php
@@ -20,9 +20,7 @@ class CreateUserAction extends BaseAction
{
public function __construct(
private readonly CreateUserHandler $createUserHandler
- )
- {
- }
+ ) {}
/**
* @throws ValidationException|Throwable
diff --git a/backend/app/Http/Actions/Users/GetUserAction.php b/backend/app/Http/Actions/Users/GetUserAction.php
index a1d296def4..0504a38de9 100644
--- a/backend/app/Http/Actions/Users/GetUserAction.php
+++ b/backend/app/Http/Actions/Users/GetUserAction.php
@@ -26,8 +26,8 @@ public function __invoke(int $userId): JsonResponse
$user = $this->userRepository->findByIdAndAccountId($userId, $this->getAuthenticatedAccountId());
- if (!$user) {
- throw new ResourceNotFoundException();
+ if (! $user) {
+ throw new ResourceNotFoundException;
}
return $this->resourceResponse(
diff --git a/backend/app/Http/Actions/Users/ResendEmailConfirmationAction.php b/backend/app/Http/Actions/Users/ResendEmailConfirmationAction.php
index 94488b51bb..fbc1b05ea5 100644
--- a/backend/app/Http/Actions/Users/ResendEmailConfirmationAction.php
+++ b/backend/app/Http/Actions/Users/ResendEmailConfirmationAction.php
@@ -12,22 +12,20 @@ class ResendEmailConfirmationAction extends BaseAction
{
public function __construct(
private readonly ResendEmailConfirmationHandler $resendEmailConfirmationHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $userId): Response
{
$user = $this->getAuthenticatedUser();
- $cacheKey = 'resend_email_confirmation:' . $user->getId();
+ $cacheKey = 'resend_email_confirmation:'.$user->getId();
// Check if user has requested a resend within the last 30 seconds
if (Cache::has($cacheKey)) {
$remainingSeconds = Cache::get($cacheKey) - now()->timestamp;
throw new TooManyRequestsHttpException($remainingSeconds, __(
'Please wait :seconds seconds before requesting another code.', [
- 'seconds' => $remainingSeconds,
- ]));
+ 'seconds' => $remainingSeconds,
+ ]));
}
// Set the cooldown for 30 seconds
diff --git a/backend/app/Http/Actions/Users/UpdateUserAction.php b/backend/app/Http/Actions/Users/UpdateUserAction.php
index dae41bb6b2..36051d4179 100644
--- a/backend/app/Http/Actions/Users/UpdateUserAction.php
+++ b/backend/app/Http/Actions/Users/UpdateUserAction.php
@@ -37,10 +37,10 @@ public function __invoke(UpdateUserRequest $request, int $userId): JsonResponse
$authenticatedUser = $this->getAuthenticatedUser();
$userData = $request->validated() + [
- 'id' => $userId,
- 'account_id' => $this->getAuthenticatedAccountId(),
- 'updated_by_user_id' => $authenticatedUser->getId(),
- ];
+ 'id' => $userId,
+ 'account_id' => $this->getAuthenticatedAccountId(),
+ 'updated_by_user_id' => $authenticatedUser->getId(),
+ ];
try {
$user = $this->updateUserHandler->handle(UpdateUserDTO::fromArray($userData));
diff --git a/backend/app/Http/Actions/Waitlist/Organizer/CancelWaitlistEntryAction.php b/backend/app/Http/Actions/Waitlist/Organizer/CancelWaitlistEntryAction.php
index 5b75857d36..0e17b41752 100644
--- a/backend/app/Http/Actions/Waitlist/Organizer/CancelWaitlistEntryAction.php
+++ b/backend/app/Http/Actions/Waitlist/Organizer/CancelWaitlistEntryAction.php
@@ -7,6 +7,7 @@
use HiEvents\Exceptions\ResourceNotFoundException;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Services\Application\Handlers\Waitlist\CancelWaitlistEntryHandler;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
@@ -14,11 +15,9 @@ class CancelWaitlistEntryAction extends BaseAction
{
public function __construct(
private readonly CancelWaitlistEntryHandler $cancelWaitlistEntryHandler,
- )
- {
- }
+ ) {}
- public function __invoke(int $eventId, int $entryId): Response|\Illuminate\Http\JsonResponse
+ public function __invoke(int $eventId, int $entryId): Response|JsonResponse
{
$this->isActionAuthorized($eventId, EventDomainObject::class);
diff --git a/backend/app/Http/Actions/Waitlist/Organizer/GetWaitlistEntriesAction.php b/backend/app/Http/Actions/Waitlist/Organizer/GetWaitlistEntriesAction.php
index ca0f1bf1ca..4edf01727f 100644
--- a/backend/app/Http/Actions/Waitlist/Organizer/GetWaitlistEntriesAction.php
+++ b/backend/app/Http/Actions/Waitlist/Organizer/GetWaitlistEntriesAction.php
@@ -14,9 +14,7 @@ class GetWaitlistEntriesAction extends BaseAction
{
public function __construct(
private readonly GetWaitlistEntriesHandler $handler,
- )
- {
- }
+ ) {}
public function __invoke(Request $request, int $eventId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Waitlist/Organizer/GetWaitlistStatsAction.php b/backend/app/Http/Actions/Waitlist/Organizer/GetWaitlistStatsAction.php
index ef72fe63ef..d4728ebd11 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;
@@ -11,15 +12,17 @@ class GetWaitlistStatsAction extends BaseAction
{
public function __construct(
private readonly GetWaitlistStatsHandler $handler,
- )
- {
- }
+ ) {}
- 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,
@@ -28,7 +31,7 @@ public function __invoke(int $eventId): JsonResponse
'purchased' => $stats->purchased,
'cancelled' => $stats->cancelled,
'expired' => $stats->expired,
- 'products' => array_map(fn($p) => [
+ 'products' => array_map(fn ($p) => [
'product_price_id' => $p->product_price_id,
'product_title' => $p->product_title,
'waiting' => $p->waiting,
diff --git a/backend/app/Http/Actions/Waitlist/Organizer/OfferWaitlistEntryAction.php b/backend/app/Http/Actions/Waitlist/Organizer/OfferWaitlistEntryAction.php
index 43b22a3bf7..e00c3646fd 100644
--- a/backend/app/Http/Actions/Waitlist/Organizer/OfferWaitlistEntryAction.php
+++ b/backend/app/Http/Actions/Waitlist/Organizer/OfferWaitlistEntryAction.php
@@ -19,9 +19,7 @@ class OfferWaitlistEntryAction extends BaseAction
{
public function __construct(
private readonly OfferWaitlistEntryHandler $handler,
- )
- {
- }
+ ) {}
public function __invoke(OfferWaitlistEntryRequest $request, int $eventId): JsonResponse
{
@@ -33,6 +31,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/CancelWaitlistEntryActionPublic.php b/backend/app/Http/Actions/Waitlist/Public/CancelWaitlistEntryActionPublic.php
index b08ff230ab..8a339b955c 100644
--- a/backend/app/Http/Actions/Waitlist/Public/CancelWaitlistEntryActionPublic.php
+++ b/backend/app/Http/Actions/Waitlist/Public/CancelWaitlistEntryActionPublic.php
@@ -14,9 +14,7 @@ class CancelWaitlistEntryActionPublic extends BaseAction
{
public function __construct(
private readonly CancelWaitlistEntryService $cancelWaitlistEntryService,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, string $token): Response|JsonResponse
{
diff --git a/backend/app/Http/Actions/Waitlist/Public/CreateWaitlistEntryActionPublic.php b/backend/app/Http/Actions/Waitlist/Public/CreateWaitlistEntryActionPublic.php
index 5dca378c5b..f4f74677fa 100644
--- a/backend/app/Http/Actions/Waitlist/Public/CreateWaitlistEntryActionPublic.php
+++ b/backend/app/Http/Actions/Waitlist/Public/CreateWaitlistEntryActionPublic.php
@@ -16,9 +16,7 @@ class CreateWaitlistEntryActionPublic extends BaseAction
{
public function __construct(
private readonly CreateWaitlistEntryHandler $handler,
- )
- {
- }
+ ) {}
public function __invoke(CreateWaitlistEntryRequest $request, int $eventId): JsonResponse
{
@@ -30,6 +28,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/Actions/Webhooks/CreateWebhookAction.php b/backend/app/Http/Actions/Webhooks/CreateWebhookAction.php
index 7808b64dc8..eb58db318e 100644
--- a/backend/app/Http/Actions/Webhooks/CreateWebhookAction.php
+++ b/backend/app/Http/Actions/Webhooks/CreateWebhookAction.php
@@ -3,7 +3,6 @@
namespace HiEvents\Http\Actions\Webhooks;
use HiEvents\DomainObjects\EventDomainObject;
-use HiEvents\DomainObjects\Status\WebhookStatus;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Http\Request\Webhook\UpsertWebhookRequest;
use HiEvents\Resources\Webhook\WebhookResourceWithSecret;
@@ -15,9 +14,7 @@ class CreateWebhookAction extends BaseAction
{
public function __construct(
private readonly CreateWebhookHandler $createWebhookHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, UpsertWebhookRequest $request): JsonResponse
{
@@ -29,7 +26,7 @@ public function __invoke(int $eventId, UpsertWebhookRequest $request): JsonRespo
eventTypes: $request->validated('event_types'),
userId: $this->getAuthenticatedUser()->getId(),
accountId: $this->getAuthenticatedAccountId(),
- status: WebhookStatus::fromName($request->validated('status')),
+ status: $request->getStatus(),
eventId: $eventId,
)
);
diff --git a/backend/app/Http/Actions/Webhooks/DeleteWebhookAction.php b/backend/app/Http/Actions/Webhooks/DeleteWebhookAction.php
index d7c125c6f9..dba393d1ac 100644
--- a/backend/app/Http/Actions/Webhooks/DeleteWebhookAction.php
+++ b/backend/app/Http/Actions/Webhooks/DeleteWebhookAction.php
@@ -11,9 +11,7 @@ class DeleteWebhookAction extends BaseAction
{
public function __construct(
private readonly DeleteWebhookHandler $deleteWebhookHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, int $webhookId): Response
{
diff --git a/backend/app/Http/Actions/Webhooks/EditWebhookAction.php b/backend/app/Http/Actions/Webhooks/EditWebhookAction.php
index 32798d88f9..b40acce1fb 100644
--- a/backend/app/Http/Actions/Webhooks/EditWebhookAction.php
+++ b/backend/app/Http/Actions/Webhooks/EditWebhookAction.php
@@ -3,7 +3,6 @@
namespace HiEvents\Http\Actions\Webhooks;
use HiEvents\DomainObjects\EventDomainObject;
-use HiEvents\DomainObjects\Status\WebhookStatus;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Http\Request\Webhook\UpsertWebhookRequest;
use HiEvents\Resources\Webhook\WebhookResource;
@@ -15,9 +14,7 @@ class EditWebhookAction extends BaseAction
{
public function __construct(
private readonly EditWebhookHandler $editWebhookHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, int $webhookId, UpsertWebhookRequest $request): JsonResponse
{
@@ -31,7 +28,7 @@ public function __invoke(int $eventId, int $webhookId, UpsertWebhookRequest $req
eventId: $eventId,
userId: $this->getAuthenticatedUser()->getId(),
accountId: $this->getAuthenticatedAccountId(),
- status: WebhookStatus::fromName($request->validated('status')),
+ status: $request->getStatus(),
)
);
diff --git a/backend/app/Http/Actions/Webhooks/GetWebhookAction.php b/backend/app/Http/Actions/Webhooks/GetWebhookAction.php
index a57bfb7344..ac0c59bf27 100644
--- a/backend/app/Http/Actions/Webhooks/GetWebhookAction.php
+++ b/backend/app/Http/Actions/Webhooks/GetWebhookAction.php
@@ -12,9 +12,7 @@ class GetWebhookAction extends BaseAction
{
public function __construct(
private readonly GetWebhookHandler $getWebhookHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, int $webhookId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Webhooks/GetWebhookLogsAction.php b/backend/app/Http/Actions/Webhooks/GetWebhookLogsAction.php
index db20650f4f..44e8cf246a 100644
--- a/backend/app/Http/Actions/Webhooks/GetWebhookLogsAction.php
+++ b/backend/app/Http/Actions/Webhooks/GetWebhookLogsAction.php
@@ -13,9 +13,7 @@ class GetWebhookLogsAction extends BaseAction
{
public function __construct(
private readonly GetWebhookLogsHandler $getWebhookLogsHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId, int $webhookId): JsonResponse
{
diff --git a/backend/app/Http/Actions/Webhooks/GetWebhooksAction.php b/backend/app/Http/Actions/Webhooks/GetWebhooksAction.php
index 8c136ab727..262d3620a9 100644
--- a/backend/app/Http/Actions/Webhooks/GetWebhooksAction.php
+++ b/backend/app/Http/Actions/Webhooks/GetWebhooksAction.php
@@ -12,9 +12,7 @@ class GetWebhooksAction extends BaseAction
{
public function __construct(
private readonly GetWebhooksHandler $getWebhooksHandler,
- )
- {
- }
+ ) {}
public function __invoke(int $eventId): JsonResponse
{
diff --git a/backend/app/Http/DTO/FilterFieldDTO.php b/backend/app/Http/DTO/FilterFieldDTO.php
index 6457b57431..fa8cbafe39 100644
--- a/backend/app/Http/DTO/FilterFieldDTO.php
+++ b/backend/app/Http/DTO/FilterFieldDTO.php
@@ -10,7 +10,5 @@ public function __construct(
public readonly ?string $field = null,
public readonly ?string $operator = null,
public readonly ?string $value = null,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Http/DTO/GetSitemapEventsDTO.php b/backend/app/Http/DTO/GetSitemapEventsDTO.php
index af242467c4..7726be8ab1 100644
--- a/backend/app/Http/DTO/GetSitemapEventsDTO.php
+++ b/backend/app/Http/DTO/GetSitemapEventsDTO.php
@@ -8,7 +8,5 @@ class GetSitemapEventsDTO extends BaseDataObject
{
public function __construct(
public int $page,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Http/DTO/QueryParamsDTO.php b/backend/app/Http/DTO/QueryParamsDTO.php
index cc0c8d83dc..eb64529cea 100644
--- a/backend/app/Http/DTO/QueryParamsDTO.php
+++ b/backend/app/Http/DTO/QueryParamsDTO.php
@@ -8,18 +8,16 @@
class QueryParamsDTO extends BaseDTO
{
public function __construct(
- public readonly ?int $page = 1,
- public readonly ?int $per_page = 25,
- public readonly ?string $sort_by = null,
- public readonly ?string $sort_direction = null,
- public readonly ?string $query = null,
+ public readonly ?int $page = 1,
+ public readonly ?int $per_page = 25,
+ public readonly ?string $sort_by = null,
+ public readonly ?string $sort_direction = null,
+ public readonly ?string $query = null,
/** @var Collection */
public readonly ?Collection $filter_fields = null,
- public readonly ?array $includes = null,
+ public readonly ?array $includes = null,
public readonly ?Collection $query_params = null,
- )
- {
- }
+ ) {}
public static function fromArray(array $data): self
{
@@ -35,8 +33,8 @@ public static function fromArray(array $data): self
}
return new self(
- page: isset($data['page']) ? (int)$data['page'] : 1,
- per_page: isset($data['per_page']) ? (int)$data['per_page'] : 25,
+ page: isset($data['page']) ? (int) $data['page'] : 1,
+ per_page: isset($data['per_page']) ? (int) $data['per_page'] : 25,
sort_by: $data['sort_by'] ?? null,
sort_direction: $data['sort_direction'] ?? null,
query: $data['query'] ?? null,
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/HandleDeprecatedTimezones.php b/backend/app/Http/Middleware/HandleDeprecatedTimezones.php
index 1b9985be12..ebbd7f4dfd 100644
--- a/backend/app/Http/Middleware/HandleDeprecatedTimezones.php
+++ b/backend/app/Http/Middleware/HandleDeprecatedTimezones.php
@@ -17,7 +17,7 @@ public function handle(Request $request, Closure $next)
if (array_key_exists($timezone, $timezoneMapping)) {
$request->merge(['timezone' => $timezoneMapping[$timezone]]);
- } elseif (!in_array($timezone, timezone_identifiers_list(), true)) {
+ } elseif (! in_array($timezone, timezone_identifiers_list(), true)) {
Log::warning("Unexpected timezone received: $timezone");
}
}
diff --git a/backend/app/Http/Middleware/LogImpersonationMiddleware.php b/backend/app/Http/Middleware/LogImpersonationMiddleware.php
index a1fdb97bcb..e19d5c0d6a 100644
--- a/backend/app/Http/Middleware/LogImpersonationMiddleware.php
+++ b/backend/app/Http/Middleware/LogImpersonationMiddleware.php
@@ -12,10 +12,8 @@ class LogImpersonationMiddleware
{
public function __construct(
private readonly LoggerInterface $logger,
- private readonly AuthManager $authManager,
- )
- {
- }
+ private readonly AuthManager $authManager,
+ ) {}
public function handle(Request $request, Closure $next)
{
@@ -23,7 +21,7 @@ public function handle(Request $request, Closure $next)
$isImpersonating = false;
try {
- $isImpersonating = (bool)$this->authManager->payload()->get('is_impersonating', false);
+ $isImpersonating = (bool) $this->authManager->payload()->get('is_impersonating', false);
} catch (Exception) {
// Not authenticated or no JWT token
}
@@ -32,7 +30,7 @@ public function handle(Request $request, Closure $next)
&& $isImpersonating
&& in_array($request->method(), $mutateMethods, true)
) {
- $this->logger->info('Impersonation action by user ID ' . $this->authManager->payload()->get('impersonator_id'), [
+ $this->logger->info('Impersonation action by user ID '.$this->authManager->payload()->get('impersonator_id'), [
'impersonator_id' => $this->authManager->payload()->get('impersonator_id'),
'impersonated_user_id' => $this->authManager->user()->id,
'account_id' => $this->authManager->payload()->get('account_id'),
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/Middleware/PreventRequestsDuringMaintenance.php b/backend/app/Http/Middleware/PreventRequestsDuringMaintenance.php
index bc4ab4461a..6d58161419 100644
--- a/backend/app/Http/Middleware/PreventRequestsDuringMaintenance.php
+++ b/backend/app/Http/Middleware/PreventRequestsDuringMaintenance.php
@@ -12,6 +12,7 @@ class PreventRequestsDuringMaintenance extends Middleware
* @var array
*/
protected $except = [
- //
+ 'up',
+ '/up',
];
}
diff --git a/backend/app/Http/Middleware/RedirectIfAuthenticated.php b/backend/app/Http/Middleware/RedirectIfAuthenticated.php
index 61c9de969c..5341b640f5 100644
--- a/backend/app/Http/Middleware/RedirectIfAuthenticated.php
+++ b/backend/app/Http/Middleware/RedirectIfAuthenticated.php
@@ -13,7 +13,7 @@ class RedirectIfAuthenticated
/**
* Handle an incoming request.
*
- * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
+ * @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next, string ...$guards): Response
{
diff --git a/backend/app/Http/Middleware/SetUserLocaleMiddleware.php b/backend/app/Http/Middleware/SetUserLocaleMiddleware.php
index 4fc4b50a2d..5f039ae926 100644
--- a/backend/app/Http/Middleware/SetUserLocaleMiddleware.php
+++ b/backend/app/Http/Middleware/SetUserLocaleMiddleware.php
@@ -11,9 +11,7 @@
class SetUserLocaleMiddleware
{
- public function __construct(private readonly LocaleService $localeService)
- {
- }
+ public function __construct(private readonly LocaleService $localeService) {}
public function handle(Request $request, Closure $next)
{
@@ -40,6 +38,7 @@ protected function setLocaleFromCookie(Request $request): bool
{
if ($locale = $request->cookie('locale')) {
App::setLocale($this->localeService->getLocaleOrDefault($locale));
+
return true;
}
@@ -52,6 +51,7 @@ protected function setLocaleFromUser(): bool
/** @var UserDomainObject $user */
$user = UserDomainObject::hydrateFromModel(Auth::user());
App::setLocale($user->getLocale());
+
return true;
}
@@ -62,6 +62,7 @@ protected function setLocaleFromAcceptLanguage(Request $request): bool
{
if ($request->hasHeader('Accept-Language')) {
App::setLocale($this->localeService->getLocaleOrDefault($request->getPreferredLanguage()));
+
return true;
}
diff --git a/backend/app/Http/Request/Account/CreateAccountRequest.php b/backend/app/Http/Request/Account/CreateAccountRequest.php
index 83045d3b0c..5079508904 100644
--- a/backend/app/Http/Request/Account/CreateAccountRequest.php
+++ b/backend/app/Http/Request/Account/CreateAccountRequest.php
@@ -14,7 +14,7 @@ class CreateAccountRequest extends BaseRequest
{
public function rules(): array
{
- $currencies = include __DIR__ . '/../../../../data/currencies.php';
+ $currencies = include __DIR__.'/../../../../data/currencies.php';
return [
'first_name' => RulesHelper::REQUIRED_STRING,
diff --git a/backend/app/Http/Request/Account/UpdateAccountRequest.php b/backend/app/Http/Request/Account/UpdateAccountRequest.php
index cdfc30f466..9a4e27fc40 100644
--- a/backend/app/Http/Request/Account/UpdateAccountRequest.php
+++ b/backend/app/Http/Request/Account/UpdateAccountRequest.php
@@ -9,7 +9,7 @@ class UpdateAccountRequest extends FormRequest
{
public function rules(): array
{
- $currencies = include __DIR__ . '/../../../../data/currencies.php';
+ $currencies = include __DIR__.'/../../../../data/currencies.php';
return [
'name' => 'required|string',
diff --git a/backend/app/Http/Request/Affiliate/CreateUpdateAffiliateRequest.php b/backend/app/Http/Request/Affiliate/CreateUpdateAffiliateRequest.php
index f2a825656b..68e6599d31 100644
--- a/backend/app/Http/Request/Affiliate/CreateUpdateAffiliateRequest.php
+++ b/backend/app/Http/Request/Affiliate/CreateUpdateAffiliateRequest.php
@@ -13,4 +13,4 @@ public function rules(): array
{
return AffiliateRules::createRules();
}
-}
\ No newline at end of file
+}
diff --git a/backend/app/Http/Request/Affiliate/UpdateAffiliateRequest.php b/backend/app/Http/Request/Affiliate/UpdateAffiliateRequest.php
index 4a95c26655..afaf29fd33 100644
--- a/backend/app/Http/Request/Affiliate/UpdateAffiliateRequest.php
+++ b/backend/app/Http/Request/Affiliate/UpdateAffiliateRequest.php
@@ -13,4 +13,4 @@ public function rules(): array
{
return AffiliateRules::updateRules();
}
-}
\ No newline at end of file
+}
diff --git a/backend/app/Http/Request/Attendee/CreateAttendeeRequest.php b/backend/app/Http/Request/Attendee/CreateAttendeeRequest.php
index c73fb80acf..727d08364e 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,7 @@ 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())],
+ 'override_capacity' => ['boolean', 'sometimes'],
];
}
}
diff --git a/backend/app/Http/Request/BaseRequest.php b/backend/app/Http/Request/BaseRequest.php
index d00261c452..ac0e42b6cf 100644
--- a/backend/app/Http/Request/BaseRequest.php
+++ b/backend/app/Http/Request/BaseRequest.php
@@ -6,6 +6,4 @@
use Illuminate\Foundation\Http\FormRequest;
-abstract class BaseRequest extends FormRequest
-{
-}
+abstract class BaseRequest extends FormRequest {}
diff --git a/backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php b/backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php
index 06372e6760..979fa24715 100644
--- a/backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php
+++ b/backend/app/Http/Request/CheckInList/UpsertCheckInListRequest.php
@@ -4,17 +4,30 @@
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'],
+ '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 +45,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..3720bdd8e3
--- /dev/null
+++ b/backend/app/Http/Request/EventOccurrence/BulkUpdateOccurrencesRequest.php
@@ -0,0 +1,69 @@
+ ['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'],
+ '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', 'max:10080'],
+ '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..1dfe957c56
--- /dev/null
+++ b/backend/app/Http/Request/EventOccurrence/GenerateOccurrencesRequest.php
@@ -0,0 +1,66 @@
+ ['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;
+ }
+
+ 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..1c32533967
--- /dev/null
+++ b/backend/app/Http/Request/EventOccurrence/UpsertEventOccurrenceRequest.php
@@ -0,0 +1,39 @@
+ ['required', 'date'],
+ 'end_date' => ['nullable', 'date', 'after:start_date'],
+ 'capacity' => ['nullable', 'integer', 'min:0'],
+ 'label' => ['nullable', 'string', 'max:255'],
+ 'show_available_capacity' => ['nullable', 'boolean'],
+ '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 d99533f0c1..1ca4a475c0 100644
--- a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php
+++ b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php
@@ -18,15 +18,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],
@@ -39,18 +39,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'],
@@ -60,12 +48,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
@@ -109,6 +95,10 @@ public function rules(): array
// Self-service settings
'allow_attendee_self_edit' => ['boolean'],
+ // Occurrence display
+ 'show_available_occurrence_capacity' => ['boolean'],
+ 'hide_sold_out_occurrences' => ['boolean'],
+
// Waitlist settings
'waitlist_auto_process' => ['boolean'],
'waitlist_offer_timeout_minutes' => ['nullable', 'integer', 'min:1', 'max:10080'],
@@ -127,11 +117,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/Image/CreateImageRequest.php b/backend/app/Http/Request/Image/CreateImageRequest.php
index a97fe2ca70..497a9ff008 100644
--- a/backend/app/Http/Request/Image/CreateImageRequest.php
+++ b/backend/app/Http/Request/Image/CreateImageRequest.php
@@ -20,13 +20,13 @@ public function rules(): array
'image' => [
'required',
'image',
- 'max:8192', //8mb
- 'dimensions:min_width=' . $minWidth . ',min_height=' . $minHeight . ',max_width=4000,max_height=4000',
+ 'max:8192', // 8mb
+ 'dimensions:min_width='.$minWidth.',min_height='.$minHeight.',max_width=4000,max_height=4000',
'mimes:jpeg,png,jpg,webp',
],
'image_type' => [
Rule::in(ImageType::valuesArray()),
- 'required_with:entity_id'
+ 'required_with:entity_id',
],
'entity_id' => ['integer', 'required_with:image_type'],
];
diff --git a/backend/app/Http/Request/Location/UpsertLocationRequest.php b/backend/app/Http/Request/Location/UpsertLocationRequest.php
new file mode 100644
index 0000000000..5fbe383f9a
--- /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', 'size: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..a6d57a20b0 100644
--- a/backend/app/Http/Request/Message/SendMessageRequest.php
+++ b/backend/app/Http/Request/Message/SendMessageRequest.php
@@ -5,37 +5,67 @@
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'),
+ ],
+ '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 a86e345eb6..866bfe153c 100644
--- a/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php
+++ b/backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php
@@ -18,7 +18,7 @@ public function after(): array
return [
function ($validator) {
$pixels = $this->input('tracking_pixels', []);
- if (!is_array($pixels)) {
+ if (! is_array($pixels)) {
return;
}
@@ -34,11 +34,12 @@ function ($validator) {
"tracking_pixels.{$index}.provider",
__('Google Tag Manager is not available on hosted plans for security reasons.')
);
+
continue;
}
if ($provider && $pixelId !== '') {
- if (!preg_match($provider->pixelIdPattern(), $pixelId)) {
+ if (! preg_match($provider->pixelIdPattern(), $pixelId)) {
$validator->errors()->add(
"tracking_pixels.{$index}.pixel_id",
$provider->pixelIdFormatDescription()
@@ -47,8 +48,8 @@ function ($validator) {
}
}
- $enabledPixels = collect($pixels)->filter(fn ($p) => !empty($p['enabled']));
- if ($enabledPixels->isNotEmpty() && !$this->input('tracking_consent_acknowledged')) {
+ $enabledPixels = collect($pixels)->filter(fn ($p) => ! empty($p['enabled']));
+ if ($enabledPixels->isNotEmpty() && ! $this->input('tracking_consent_acknowledged')) {
$validator->errors()->add(
'tracking_consent_acknowledged',
__('You must acknowledge your data controller responsibilities before enabling tracking pixels.')
@@ -92,16 +93,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/Organizer/UpdateOrganizerStatusRequest.php b/backend/app/Http/Request/Organizer/UpdateOrganizerStatusRequest.php
index b83fd849ef..500c3a0024 100644
--- a/backend/app/Http/Request/Organizer/UpdateOrganizerStatusRequest.php
+++ b/backend/app/Http/Request/Organizer/UpdateOrganizerStatusRequest.php
@@ -14,4 +14,4 @@ public function rules(): array
'status' => ['required', Rule::in(OrganizerStatus::valuesArray())],
];
}
-}
\ No newline at end of file
+}
diff --git a/backend/app/Http/Request/Organizer/UpsertOrganizerRequest.php b/backend/app/Http/Request/Organizer/UpsertOrganizerRequest.php
index 00a9656d6f..e284288347 100644
--- a/backend/app/Http/Request/Organizer/UpsertOrganizerRequest.php
+++ b/backend/app/Http/Request/Organizer/UpsertOrganizerRequest.php
@@ -9,12 +9,12 @@ class UpsertOrganizerRequest extends BaseRequest
{
public function rules(): array
{
- $currencies = include __DIR__ . '/../../../../data/currencies.php';
+ $currencies = include __DIR__.'/../../../../data/currencies.php';
return [
'name' => ['required', 'string', 'max:100'],
'email' => ['email', 'required'],
- 'phone' => ['string', "nullable", 'max:25'],
+ 'phone' => ['string', 'nullable', 'max:25'],
'website' => ['url', 'nullable', 'max:255'],
'description' => ['string', 'nullable', 'max:1200'],
'timezone' => ['timezone', 'required'],
diff --git a/backend/app/Http/Request/Product/UpsertProductRequest.php b/backend/app/Http/Request/Product/UpsertProductRequest.php
index adb485cf88..1543a726db 100644
--- a/backend/app/Http/Request/Product/UpsertProductRequest.php
+++ b/backend/app/Http/Request/Product/UpsertProductRequest.php
@@ -23,7 +23,7 @@ public function rules(): array
'max_per_order' => 'integer|nullable',
'prices' => ['required', 'array'],
'prices.*.price' => [...RulesHelper::MONEY, 'required'],
- 'prices.*.label' => ['nullable', ...RulesHelper::STRING, 'required_if:type,' . ProductPriceType::TIERED->name],
+ 'prices.*.label' => ['nullable', ...RulesHelper::STRING, 'required_if:type,'.ProductPriceType::TIERED->name],
'prices.*.sale_start_date' => ['date', 'nullable', 'after:sale_start_date'],
'prices.*.sale_end_date' => 'date|nullable|after:prices.*.sale_start_date',
'prices.*.initial_quantity_available' => ['integer', 'nullable', 'min:0'],
diff --git a/backend/app/Http/Request/PromoCode/CreateUpdatePromoCodeRequest.php b/backend/app/Http/Request/PromoCode/CreateUpdatePromoCodeRequest.php
index 5e381f6120..90359eedcf 100644
--- a/backend/app/Http/Request/PromoCode/CreateUpdatePromoCodeRequest.php
+++ b/backend/app/Http/Request/PromoCode/CreateUpdatePromoCodeRequest.php
@@ -2,6 +2,7 @@
namespace HiEvents\Http\Request\PromoCode;
+use HiEvents\DomainObjects\Enums\PromoCodeDiscountAppliesToEnum;
use HiEvents\DomainObjects\Enums\PromoCodeDiscountTypeEnum;
use HiEvents\Http\Request\BaseRequest;
use Illuminate\Validation\Rule;
@@ -27,7 +28,11 @@ function ($attribute, $value, $fail) {
'max_allowed_usages' => 'nullable|gte:1|max:9999999',
'discount_type' => [
'required',
- Rule::in(PromoCodeDiscountTypeEnum::valuesArray())
+ Rule::in(PromoCodeDiscountTypeEnum::valuesArray()),
+ ],
+ 'discount_applies_to' => [
+ 'sometimes',
+ Rule::in(PromoCodeDiscountAppliesToEnum::valuesArray()),
],
];
}
diff --git a/backend/app/Http/Request/Questions/EditQuestionAnswerRequest.php b/backend/app/Http/Request/Questions/EditQuestionAnswerRequest.php
index 3949c2f616..acc6255ab8 100644
--- a/backend/app/Http/Request/Questions/EditQuestionAnswerRequest.php
+++ b/backend/app/Http/Request/Questions/EditQuestionAnswerRequest.php
@@ -12,10 +12,10 @@ public function rules(): array
'answer' => [
'nullable',
function ($attribute, $value, $fail) {
- if (!is_string($value) && !is_array($value)) {
+ if (! is_string($value) && ! is_array($value)) {
$fail("The {$attribute} must be a string or an array.");
}
- }
+ },
],
];
}
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/User/UpdateMeRequest.php b/backend/app/Http/Request/User/UpdateMeRequest.php
index 639af6edfc..5d0dd9ef15 100644
--- a/backend/app/Http/Request/User/UpdateMeRequest.php
+++ b/backend/app/Http/Request/User/UpdateMeRequest.php
@@ -19,7 +19,7 @@ public function rules(): array
'last_name' => 'required_without_all:current_password,password,password_confirmation|min:1',
'email' => 'required_without_all:current_password,password,password_confirmation|email',
'timezone' => 'required_without_all:current_password,password,password_confirmation|timezone',
- 'locale' => 'required_without_all:current_password,password,password_confirmation|in:' . implode(',', Locale::getSupportedLocales()),
+ 'locale' => 'required_without_all:current_password,password,password_confirmation|in:'.implode(',', Locale::getSupportedLocales()),
'current_password' => [
'required_with:password,password_confirmation',
@@ -28,7 +28,7 @@ public function rules(): array
'password' => [
'required_with:current_password',
'confirmed',
- Password::min(8)
+ Password::min(8),
],
'marketing_opt_in' => 'boolean|nullable',
];
diff --git a/backend/app/Http/Request/User/UpdateUserRequest.php b/backend/app/Http/Request/User/UpdateUserRequest.php
index 6aa6035e25..1789a08118 100644
--- a/backend/app/Http/Request/User/UpdateUserRequest.php
+++ b/backend/app/Http/Request/User/UpdateUserRequest.php
@@ -16,7 +16,7 @@ public function rules(): array
'first_name' => RulesHelper::STRING,
'last_name' => RulesHelper::STRING,
'status' => Rule::in([UserStatus::INACTIVE->name, UserStatus::ACTIVE->name]), // don't allow INVITED
- 'role' => Rule::in(Role::getAssignableRoles())
+ 'role' => Rule::in(Role::getAssignableRoles()),
];
}
}
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/Http/Request/Webhook/UpsertWebhookRequest.php b/backend/app/Http/Request/Webhook/UpsertWebhookRequest.php
index dace41652c..c3630e5c21 100644
--- a/backend/app/Http/Request/Webhook/UpsertWebhookRequest.php
+++ b/backend/app/Http/Request/Webhook/UpsertWebhookRequest.php
@@ -13,9 +13,14 @@ class UpsertWebhookRequest extends BaseRequest
public function rules(): array
{
return [
- 'url' => ['required', 'url', new NoInternalUrlRule()],
+ 'url' => ['required', 'url', new NoInternalUrlRule],
'event_types.*' => ['required', Rule::in(DomainEventType::valuesArray())],
'status' => ['nullable', Rule::in(WebhookStatus::valuesArray())],
];
}
+
+ public function getStatus(): WebhookStatus
+ {
+ return WebhookStatus::fromName($this->validated('status') ?? WebhookStatus::ENABLED->name);
+ }
}
diff --git a/backend/app/Http/Resources/Admin/AdminFailedJobResource.php b/backend/app/Http/Resources/Admin/AdminFailedJobResource.php
index 548dd94664..13914ae374 100644
--- a/backend/app/Http/Resources/Admin/AdminFailedJobResource.php
+++ b/backend/app/Http/Resources/Admin/AdminFailedJobResource.php
@@ -31,6 +31,7 @@ public function toArray(Request $request): array
private function getExceptionSummary(): string
{
$lines = explode("\n", $this->exception);
+
return $lines[0] ?? 'Unknown error';
}
}
diff --git a/backend/app/Http/Resources/Admin/AdminMessageResource.php b/backend/app/Http/Resources/Admin/AdminMessageResource.php
index c5200f1dfd..14dcf40670 100644
--- a/backend/app/Http/Resources/Admin/AdminMessageResource.php
+++ b/backend/app/Http/Resources/Admin/AdminMessageResource.php
@@ -20,8 +20,8 @@ public function toArray(Request $request): array
'message' => $this->message,
'type' => $this->type,
'status' => $this->status,
- 'recipients_count' => (int)$this->recipients_count,
- 'sent_by' => trim(($this->sent_by_first_name ?? '') . ' ' . ($this->sent_by_last_name ?? '')),
+ 'recipients_count' => (int) $this->recipients_count,
+ 'sent_by' => trim(($this->sent_by_first_name ?? '').' '.($this->sent_by_last_name ?? '')),
'sent_at' => $this->sent_at,
'created_at' => $this->created_at,
'eligibility_failures' => $this->eligibility_failures,
diff --git a/backend/app/Http/ResponseCodes.php b/backend/app/Http/ResponseCodes.php
index 040b8b48da..bb60a79add 100644
--- a/backend/app/Http/ResponseCodes.php
+++ b/backend/app/Http/ResponseCodes.php
@@ -5,71 +5,133 @@
class ResponseCodes
{
const HTTP_CONTINUE = 100;
+
const HTTP_SWITCHING_PROTOCOLS = 101;
+
const HTTP_PROCESSING = 102; // RFC2518
+
const HTTP_EARLY_HINTS = 103; // RFC8297
+
const HTTP_OK = 200;
+
const HTTP_CREATED = 201;
+
const HTTP_ACCEPTED = 202;
+
const HTTP_NON_AUTHORITATIVE_INFORMATION = 203;
+
const HTTP_NO_CONTENT = 204;
+
const HTTP_RESET_CONTENT = 205;
+
const HTTP_PARTIAL_CONTENT = 206;
+
const HTTP_MULTI_STATUS = 207; // RFC4918
+
const HTTP_ALREADY_REPORTED = 208; // RFC5842
+
const HTTP_IM_USED = 226; // RFC3229
+
const HTTP_MULTIPLE_CHOICES = 300;
+
const HTTP_MOVED_PERMANENTLY = 301;
+
const HTTP_FOUND = 302;
+
const HTTP_SEE_OTHER = 303;
+
const HTTP_NOT_MODIFIED = 304;
+
const HTTP_USE_PROXY = 305;
+
const HTTP_RESERVED = 306;
+
const HTTP_TEMPORARY_REDIRECT = 307;
+
const HTTP_PERMANENTLY_REDIRECT = 308; // RFC7238
+
const HTTP_BAD_REQUEST = 400;
+
const HTTP_UNAUTHORIZED = 401;
+
const HTTP_PAYMENT_REQUIRED = 402;
+
const HTTP_FORBIDDEN = 403;
+
const HTTP_NOT_FOUND = 404;
+
const HTTP_METHOD_NOT_ALLOWED = 405;
+
const HTTP_NOT_ACCEPTABLE = 406;
+
const HTTP_PROXY_AUTHENTICATION_REQUIRED = 407;
+
const HTTP_REQUEST_TIMEOUT = 408;
+
const HTTP_CONFLICT = 409;
+
const HTTP_GONE = 410;
+
const HTTP_LENGTH_REQUIRED = 411;
+
const HTTP_PRECONDITION_FAILED = 412;
+
const HTTP_REQUEST_ENTITY_TOO_LARGE = 413;
+
const HTTP_REQUEST_URI_TOO_LONG = 414;
+
const HTTP_UNSUPPORTED_MEDIA_TYPE = 415;
+
const HTTP_REQUESTED_RANGE_NOT_SATISFIABLE = 416;
+
const HTTP_EXPECTATION_FAILED = 417;
+
const HTTP_I_AM_A_TEAPOT = 418; // RFC2324
+
const HTTP_MISDIRECTED_REQUEST = 421; // RFC7540
+
const HTTP_UNPROCESSABLE_ENTITY = 422; // RFC4918
+
const HTTP_LOCKED = 423; // RFC4918
+
const HTTP_FAILED_DEPENDENCY = 424; // RFC4918
/**
* @deprecated
*/
const HTTP_RESERVED_FOR_WEBDAV_ADVANCED_COLLECTIONS_EXPIRED_PROPOSAL = 425; // RFC2817
+
const HTTP_TOO_EARLY = 425; // RFC-ietf-httpbis-replay-04
+
const HTTP_UPGRADE_REQUIRED = 426; // RFC2817
+
const HTTP_PRECONDITION_REQUIRED = 428; // RFC6585
+
const HTTP_TOO_MANY_REQUESTS = 429; // RFC6585
+
const HTTP_REQUEST_HEADER_FIELDS_TOO_LARGE = 431; // RFC6585
+
const HTTP_UNAVAILABLE_FOR_LEGAL_REASONS = 451;
+
const HTTP_INTERNAL_SERVER_ERROR = 500;
+
const HTTP_NOT_IMPLEMENTED = 501;
+
const HTTP_BAD_GATEWAY = 502;
+
const HTTP_SERVICE_UNAVAILABLE = 503;
+
const HTTP_GATEWAY_TIMEOUT = 504;
+
const HTTP_VERSION_NOT_SUPPORTED = 505;
+
const HTTP_VARIANT_ALSO_NEGOTIATES_EXPERIMENTAL = 506; // RFC2295
+
const HTTP_INSUFFICIENT_STORAGE = 507; // RFC4918
+
const HTTP_LOOP_DETECTED = 508; // RFC5842
+
const HTTP_NOT_EXTENDED = 510; // RFC2774
+
const HTTP_NETWORK_AUTHENTICATION_REQUIRED = 511; // RFC6585
}
diff --git a/backend/app/Jobs/Event/SendEventEmailJob.php b/backend/app/Jobs/Event/SendEventEmailJob.php
index af6b259ffb..aa9e036979 100644
--- a/backend/app/Jobs/Event/SendEventEmailJob.php
+++ b/backend/app/Jobs/Event/SendEventEmailJob.php
@@ -20,22 +20,19 @@ class SendEventEmailJob implements ShouldQueue
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
- private readonly string $email,
- private readonly string $toName,
- private readonly EventMessage $eventMessage,
+ private readonly string $email,
+ private readonly string $toName,
+ private readonly EventMessage $eventMessage,
private readonly SendMessageDTO $messageData,
- )
- {
- }
+ ) {}
/**
* @throws Throwable
*/
public function handle(
- Mailer $mailer,
+ Mailer $mailer,
OutgoingMessageRepositoryInterface $outgoingMessageRepository,
- ): void
- {
+ ): void {
try {
$mailer
->to($this->email, $this->toName)
@@ -52,12 +49,20 @@ public function handle(
throw $exception;
}
- $outgoingMessageRepository->create([
- OutgoingMessageDomainObjectAbstract::MESSAGE_ID => $this->messageData->id,
- OutgoingMessageDomainObjectAbstract::EVENT_ID => $this->messageData->event_id,
- OutgoingMessageDomainObjectAbstract::STATUS => OutgoingMessageStatus::SENT->name,
- OutgoingMessageDomainObjectAbstract::RECIPIENT => $this->email,
- OutgoingMessageDomainObjectAbstract::SUBJECT => $this->messageData->subject,
- ]);
+ try {
+ $outgoingMessageRepository->create([
+ OutgoingMessageDomainObjectAbstract::MESSAGE_ID => $this->messageData->id,
+ OutgoingMessageDomainObjectAbstract::EVENT_ID => $this->messageData->event_id,
+ OutgoingMessageDomainObjectAbstract::STATUS => OutgoingMessageStatus::SENT->name,
+ OutgoingMessageDomainObjectAbstract::RECIPIENT => $this->email,
+ OutgoingMessageDomainObjectAbstract::SUBJECT => $this->messageData->subject,
+ ]);
+ } catch (Throwable $exception) {
+ logger()?->error('Failed to record sent event email', [
+ 'message_id' => $this->messageData->id,
+ 'recipient' => $this->email,
+ 'error' => $exception->getMessage(),
+ ]);
+ }
}
}
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/Event/UpdateEventPageViewsJob.php b/backend/app/Jobs/Event/UpdateEventPageViewsJob.php
index 65172e4e52..3243de5171 100644
--- a/backend/app/Jobs/Event/UpdateEventPageViewsJob.php
+++ b/backend/app/Jobs/Event/UpdateEventPageViewsJob.php
@@ -2,10 +2,13 @@
namespace HiEvents\Jobs\Event;
+use Carbon\Carbon;
use Exception;
+use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface;
use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
@@ -16,6 +19,7 @@ class UpdateEventPageViewsJob implements ShouldQueue
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private int $eventId;
+
private int $amount;
public function __construct(int $eventId, int $amount)
@@ -26,15 +30,39 @@ public function __construct(int $eventId, int $amount)
public function handle(
EventStatisticRepositoryInterface $eventStatisticsRepository,
- LoggerInterface $logger,
- ): void
- {
+ EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository,
+ LoggerInterface $logger,
+ ): void {
try {
$eventStatisticsRepository->incrementWhere(
where: ['event_id' => $this->eventId],
column: 'total_views',
amount: $this->amount,
);
+
+ $date = Carbon::now('UTC')->format('Y-m-d');
+
+ $incremented = $eventDailyStatisticRepository->incrementWhere(
+ where: ['event_id' => $this->eventId, 'date' => $date],
+ column: 'total_views',
+ amount: $this->amount,
+ );
+
+ if ($incremented === 0) {
+ try {
+ $eventDailyStatisticRepository->create([
+ 'event_id' => $this->eventId,
+ 'date' => $date,
+ 'total_views' => $this->amount,
+ ]);
+ } catch (UniqueConstraintViolationException) {
+ $eventDailyStatisticRepository->incrementWhere(
+ where: ['event_id' => $this->eventId, 'date' => $date],
+ column: 'total_views',
+ amount: $this->amount,
+ );
+ }
+ }
} catch (Exception $e) {
$logger->error('Failed to update event page views', [
'event_id' => $this->eventId,
diff --git a/backend/app/Jobs/Event/Webhook/DispatchEventWebhookJob.php b/backend/app/Jobs/Event/Webhook/DispatchEventWebhookJob.php
index 0f73e76e8f..0c89fae138 100644
--- a/backend/app/Jobs/Event/Webhook/DispatchEventWebhookJob.php
+++ b/backend/app/Jobs/Event/Webhook/DispatchEventWebhookJob.php
@@ -15,11 +15,9 @@ class DispatchEventWebhookJob implements ShouldQueue
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
- public int $eventId,
+ public int $eventId,
public DomainEventType $eventType,
- )
- {
- }
+ ) {}
public function handle(WebhookDispatchService $webhookDispatchService): void
{
diff --git a/backend/app/Jobs/Message/MessagePendingReviewJob.php b/backend/app/Jobs/Message/MessagePendingReviewJob.php
index fcd5e39650..a485e0d528 100644
--- a/backend/app/Jobs/Message/MessagePendingReviewJob.php
+++ b/backend/app/Jobs/Message/MessagePendingReviewJob.php
@@ -23,20 +23,17 @@ class MessagePendingReviewJob implements ShouldQueue
public int $tries = 3;
public function __construct(
- private readonly int $messageId,
+ private readonly int $messageId,
private readonly array $failures
- )
- {
- }
+ ) {}
public function handle(
MessageRepositoryInterface $messageRepository,
- EventRepositoryInterface $eventRepository,
+ EventRepositoryInterface $eventRepository,
AccountRepositoryInterface $accountRepository,
- Mailer $mailer,
- Repository $config
- ): void
- {
+ Mailer $mailer,
+ Repository $config
+ ): void {
/** @var MessageDomainObject $message */
$message = $messageRepository->findById($this->messageId);
diff --git a/backend/app/Jobs/Message/SendScheduledMessagesJob.php b/backend/app/Jobs/Message/SendScheduledMessagesJob.php
index a7f5a02412..821d98a7d3 100644
--- a/backend/app/Jobs/Message/SendScheduledMessagesJob.php
+++ b/backend/app/Jobs/Message/SendScheduledMessagesJob.php
@@ -22,9 +22,8 @@ class SendScheduledMessagesJob implements ShouldQueue
public function handle(
MessageRepositoryInterface $messageRepository,
- MessageDispatchService $messageDispatchService,
- ): void
- {
+ MessageDispatchService $messageDispatchService,
+ ): void {
$messages = $messageRepository->findWhere([
'status' => MessageStatus::SCHEDULED->name,
['scheduled_at', '<=', Carbon::now()->toDateTimeString()],
diff --git a/backend/app/Jobs/Occurrence/BulkCancelOccurrencesJob.php b/backend/app/Jobs/Occurrence/BulkCancelOccurrencesJob.php
new file mode 100644
index 0000000000..d0cf0712fa
--- /dev/null
+++ b/backend/app/Jobs/Occurrence/BulkCancelOccurrencesJob.php
@@ -0,0 +1,139 @@
+onQueue(config('queue.occurrences_queue_name'));
+ }
+ }
+
+ public function handle(
+ EventOccurrenceRepositoryInterface $occurrenceRepository,
+ RecurrenceRuleExclusionService $exclusionService,
+ CancelOccurrenceAttendeesService $cancelAttendeesService,
+ ): void {
+ $cancelledStartDates = [];
+ $failedIds = [];
+
+ foreach ($this->occurrenceIds as $occurrenceId) {
+ try {
+ $cancelResult = DB::transaction(function () use ($occurrenceRepository, $exclusionService, $cancelAttendeesService, $occurrenceId) {
+ $occurrence = $occurrenceRepository->findByIdLocked($occurrenceId);
+
+ if (! $occurrence || $occurrence->getEventId() !== $this->eventId) {
+ return null;
+ }
+
+ if ($occurrence->getStatus() === EventOccurrenceStatus::CANCELLED->name) {
+ return null;
+ }
+
+ $cancelResult = $cancelAttendeesService->cancelForOccurrence($this->eventId, $occurrenceId);
+
+ $occurrenceRepository->updateWhere(
+ attributes: [
+ EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::CANCELLED->name,
+ EventOccurrenceDomainObjectAbstract::CANCELLED_ATTENDEES_COUNT => $cancelResult['sales_backed_count'],
+ ],
+ where: [EventOccurrenceDomainObjectAbstract::ID => $occurrenceId],
+ );
+
+ $exclusionService->addExclusions($this->eventId, [$occurrence->getStartDate()]);
+
+ return [
+ 'start_date' => $occurrence->getStartDate(),
+ 'cancelled_attendee_ids' => $cancelResult['attendee_ids'],
+ ];
+ });
+
+ if ($cancelResult === null) {
+ continue;
+ }
+
+ SendOccurrenceCancellationEmailJob::dispatchChunked(
+ $this->eventId,
+ $occurrenceId,
+ $cancelResult['cancelled_attendee_ids'],
+ $this->refundOrders,
+ );
+
+ event(new OccurrenceCancelledEvent(
+ eventId: $this->eventId,
+ occurrenceId: $occurrenceId,
+ refundOrders: $this->refundOrders,
+ ));
+
+ event(new OccurrenceEvent(
+ type: DomainEventType::OCCURRENCE_CANCELLED,
+ occurrenceId: $occurrenceId,
+ ));
+
+ $cancelledStartDates[] = $cancelResult['start_date'];
+ } catch (Throwable $e) {
+ $failedIds[] = $occurrenceId;
+ Log::error('Failed to cancel occurrence', [
+ 'event_id' => $this->eventId,
+ 'occurrence_id' => $occurrenceId,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ $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..d5c834a411
--- /dev/null
+++ b/backend/app/Jobs/Occurrence/RefundOccurrenceOrdersJob.php
@@ -0,0 +1,169 @@
+onQueue(config('queue.occurrences_queue_name'));
+ }
+ }
+
+ 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;
+ }
+
+ $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,
+ ]);
+
+ $this->recordAuditLog($auditLogRepository, OrderAuditAction::AUTOMATIC_REFUND_SKIPPED, $order->id, [
+ 'cancelled_occurrence_id' => $this->occurrenceId,
+ 'reason' => 'order spans multiple occurrences',
+ ]);
+
+ 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 (RefundNotPossibleException $e) {
+ Log::warning('Skipping automatic refund for order that is not refundable', [
+ 'order_id' => $order->id,
+ 'event_id' => $this->eventId,
+ 'occurrence_id' => $this->occurrenceId,
+ 'reason' => $e->getMessage(),
+ ]);
+
+ $this->recordAuditLog($auditLogRepository, OrderAuditAction::AUTOMATIC_REFUND_SKIPPED, $order->id, [
+ 'cancelled_occurrence_id' => $this->occurrenceId,
+ 'reason' => $e->getMessage(),
+ ]);
+ } 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(),
+ ]);
+
+ $this->recordAuditLog($auditLogRepository, OrderAuditAction::AUTOMATIC_REFUND_FAILED, $order->id, [
+ 'cancelled_occurrence_id' => $this->occurrenceId,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+ }
+
+ private function recordAuditLog(
+ OrderAuditLogRepositoryInterface $auditLogRepository,
+ OrderAuditAction $action,
+ int $orderId,
+ array $newValues,
+ ): void {
+ try {
+ $auditLogRepository->create([
+ 'event_id' => $this->eventId,
+ 'order_id' => $orderId,
+ 'attendee_id' => null,
+ 'action' => $action->value,
+ 'old_values' => null,
+ 'new_values' => $newValues,
+ 'changed_fields' => null,
+ 'ip_address' => null,
+ 'user_agent' => null,
+ ]);
+ } catch (Throwable $auditError) {
+ Log::error('Failed to write refund audit log entry', [
+ 'order_id' => $orderId,
+ 'event_id' => $this->eventId,
+ 'action' => $action->value,
+ '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..147a725e7a
--- /dev/null
+++ b/backend/app/Jobs/Occurrence/SendOccurrenceCancellationEmailJob.php
@@ -0,0 +1,123 @@
+onQueue(config('queue.occurrences_queue_name'));
+ }
+ }
+
+ public static function dispatchChunked(int $eventId, int $occurrenceId, array $attendeeIds, bool $refundOrders): void
+ {
+ foreach (array_chunk($attendeeIds, self::DISPATCH_CHUNK_SIZE) as $attendeeIdChunk) {
+ self::dispatch($eventId, $occurrenceId, $attendeeIdChunk, $refundOrders);
+ }
+ }
+
+ public function handle(
+ EventRepositoryInterface $eventRepository,
+ EventOccurrenceRepositoryInterface $occurrenceRepository,
+ AttendeeRepositoryInterface $attendeeRepository,
+ Mailer $mailer,
+ MailBuilderService $mailBuilderService,
+ ): void {
+ if ($this->attendeeIds === []) {
+ return;
+ }
+
+ $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);
+
+ $sentEmails = [];
+
+ foreach (array_chunk($this->attendeeIds, self::ATTENDEE_CHUNK_SIZE) as $attendeeIdChunk) {
+ $attendees = $attendeeRepository->findWhereIn('id', $attendeeIdChunk);
+
+ $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/SendOrderDetailsEmailJob.php b/backend/app/Jobs/Order/SendOrderDetailsEmailJob.php
index c6fb63f948..009b9446c3 100644
--- a/backend/app/Jobs/Order/SendOrderDetailsEmailJob.php
+++ b/backend/app/Jobs/Order/SendOrderDetailsEmailJob.php
@@ -16,9 +16,7 @@ class SendOrderDetailsEmailJob implements ShouldQueue
public int $tries = 3;
- public function __construct(private readonly OrderDomainObject $order)
- {
- }
+ public function __construct(private readonly OrderDomainObject $order) {}
public function handle(SendOrderDetailsService $service): void
{
diff --git a/backend/app/Jobs/Order/Webhook/DispatchAttendeeWebhookJob.php b/backend/app/Jobs/Order/Webhook/DispatchAttendeeWebhookJob.php
index e7c308047c..76d53b194a 100644
--- a/backend/app/Jobs/Order/Webhook/DispatchAttendeeWebhookJob.php
+++ b/backend/app/Jobs/Order/Webhook/DispatchAttendeeWebhookJob.php
@@ -14,11 +14,9 @@ class DispatchAttendeeWebhookJob
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
- public int $attendeeId,
+ public int $attendeeId,
public DomainEventType $eventType,
- )
- {
- }
+ ) {}
public function handle(WebhookDispatchService $webhookDispatchService): void
{
diff --git a/backend/app/Jobs/Order/Webhook/DispatchCheckInWebhookJob.php b/backend/app/Jobs/Order/Webhook/DispatchCheckInWebhookJob.php
index 02db366898..056894bfbf 100644
--- a/backend/app/Jobs/Order/Webhook/DispatchCheckInWebhookJob.php
+++ b/backend/app/Jobs/Order/Webhook/DispatchCheckInWebhookJob.php
@@ -14,11 +14,9 @@ class DispatchCheckInWebhookJob
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
- public int $attendeeCheckInId,
+ public int $attendeeCheckInId,
public DomainEventType $eventType,
- )
- {
- }
+ ) {}
public function handle(WebhookDispatchService $webhookDispatchService): void
{
diff --git a/backend/app/Jobs/Order/Webhook/DispatchOccurrenceWebhookJob.php b/backend/app/Jobs/Order/Webhook/DispatchOccurrenceWebhookJob.php
new file mode 100644
index 0000000000..08d833e0d7
--- /dev/null
+++ b/backend/app/Jobs/Order/Webhook/DispatchOccurrenceWebhookJob.php
@@ -0,0 +1,28 @@
+dispatchOccurrenceWebhook(
+ eventType: $this->eventType,
+ occurrenceId: $this->occurrenceId,
+ );
+ }
+}
diff --git a/backend/app/Jobs/Order/Webhook/DispatchOrderWebhookJob.php b/backend/app/Jobs/Order/Webhook/DispatchOrderWebhookJob.php
index e0bca0737e..f81242762c 100644
--- a/backend/app/Jobs/Order/Webhook/DispatchOrderWebhookJob.php
+++ b/backend/app/Jobs/Order/Webhook/DispatchOrderWebhookJob.php
@@ -14,11 +14,9 @@ class DispatchOrderWebhookJob
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
- public int $orderId,
+ public int $orderId,
public DomainEventType $eventType,
- )
- {
- }
+ ) {}
public function handle(WebhookDispatchService $webhookDispatchService): void
{
diff --git a/backend/app/Jobs/Order/Webhook/DispatchProductWebhookJob.php b/backend/app/Jobs/Order/Webhook/DispatchProductWebhookJob.php
index 978b877a89..2d2d7c0a00 100644
--- a/backend/app/Jobs/Order/Webhook/DispatchProductWebhookJob.php
+++ b/backend/app/Jobs/Order/Webhook/DispatchProductWebhookJob.php
@@ -14,11 +14,9 @@ class DispatchProductWebhookJob
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
- public int $productId,
+ public int $productId,
public DomainEventType $eventType,
- )
- {
- }
+ ) {}
public function handle(WebhookDispatchService $webhookDispatchService): void
{
diff --git a/backend/app/Jobs/Question/ExportAnswersJob.php b/backend/app/Jobs/Question/ExportAnswersJob.php
index 61c79094e4..bbe7d36484 100644
--- a/backend/app/Jobs/Question/ExportAnswersJob.php
+++ b/backend/app/Jobs/Question/ExportAnswersJob.php
@@ -14,13 +14,11 @@
class ExportAnswersJob implements ShouldQueue
{
- use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, Batchable;
+ use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
private readonly int $eventId,
- )
- {
- }
+ ) {}
public function handle(ExportAnswersHandler $exportAnswersHandler, AnswersExport $export): void
{
diff --git a/backend/app/Jobs/Vat/ValidateVatNumberJob.php b/backend/app/Jobs/Vat/ValidateVatNumberJob.php
index 2d97a8917d..4e85140cbb 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,37 +27,45 @@ 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,
+ 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, [
+ if (! $this->vatNumberIsCurrent($repository, $logger)) {
+ return;
+ }
+
+ $repository->updateFromArray($this->vatSettingId, [
'vat_validation_status' => VatValidationStatus::VALIDATING->value,
'vat_validation_attempts' => $this->attempts(),
]);
$result = $viesService->validateVatNumber($this->vatNumber);
+ if (! $this->vatNumberIsCurrent($repository, $logger)) {
+ return;
+ }
+
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 +81,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 +99,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 +116,21 @@ 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, [
+ if (! $this->vatNumberIsCurrent($repository, $logger)) {
+ return;
+ }
+
+ $repository->updateFromArray($this->vatSettingId, [
'vat_validated' => false,
'vat_validation_status' => VatValidationStatus::FAILED->value,
'vat_validation_error' => __('Validation failed after multiple attempts: :error', [
@@ -128,7 +140,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 +149,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,
];
}
@@ -168,6 +166,25 @@ private function calculateBackoff(): int
return $backoffs[$attempt] ?? end($backoffs);
}
+ private function vatNumberIsCurrent(
+ OrganizerVatSettingRepositoryInterface $repository,
+ LoggerInterface $logger,
+ ): bool {
+ $current = $repository->findFirstWhere(['id' => $this->vatSettingId]);
+
+ if ($current !== null && $current->getVatNumber() === $this->vatNumber) {
+ return true;
+ }
+
+ $logger->info('VAT validation result discarded - VAT number changed since validation started', [
+ 'organizer_vat_setting_id' => $this->vatSettingId,
+ 'vat_number' => $this->maskVatNumber($this->vatNumber),
+ 'attempt' => $this->attempts(),
+ ]);
+
+ return false;
+ }
+
private function maskVatNumber(string $vatNumber): string
{
$length = strlen($vatNumber);
@@ -175,6 +192,6 @@ private function maskVatNumber(string $vatNumber): string
return $vatNumber;
}
- return substr($vatNumber, 0, 2) . str_repeat('*', $length - 4) . substr($vatNumber, -2);
+ return substr($vatNumber, 0, 2).str_repeat('*', $length - 4).substr($vatNumber, -2);
}
}
diff --git a/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php b/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php
index 1c17ce4976..bd888b6262 100644
--- a/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php
+++ b/backend/app/Jobs/Waitlist/ProcessExpiredWaitlistOffersJob.php
@@ -2,12 +2,13 @@
namespace HiEvents\Jobs\Waitlist;
+use HiEvents\DomainObjects\Enums\CapacityChangeDirection;
use HiEvents\DomainObjects\Status\OrderStatus;
use HiEvents\DomainObjects\Status\WaitlistEntryStatus;
-use HiEvents\DomainObjects\Enums\CapacityChangeDirection;
use HiEvents\Events\CapacityChangedEvent;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface;
+use HiEvents\Repository\Interfaces\StripePaymentsRepositoryInterface;
use HiEvents\Repository\Interfaces\WaitlistEntryRepositoryInterface;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -24,11 +25,11 @@ class ProcessExpiredWaitlistOffersJob implements ShouldQueue
public function handle(
WaitlistEntryRepositoryInterface $repository,
- OrderRepositoryInterface $orderRepository,
- ProductPriceRepositoryInterface $productPriceRepository,
- DatabaseManager $databaseManager,
- ): void
- {
+ OrderRepositoryInterface $orderRepository,
+ ProductPriceRepositoryInterface $productPriceRepository,
+ DatabaseManager $databaseManager,
+ StripePaymentsRepositoryInterface $stripePaymentsRepository,
+ ): void {
$expiredEntries = $repository->findWhere([
'status' => WaitlistEntryStatus::OFFERED->name,
['offer_expires_at', '<=', now()->toDateTimeString()],
@@ -37,7 +38,7 @@ public function handle(
foreach ($expiredEntries as $entry) {
try {
- $databaseManager->transaction(function () use ($entry, $repository, $orderRepository) {
+ $databaseManager->transaction(function () use ($entry, $repository, $orderRepository, $stripePaymentsRepository) {
$lockedEntry = $repository->findByIdLocked($entry->getId());
if ($lockedEntry === null || $lockedEntry->getStatus() !== WaitlistEntryStatus::OFFERED->name) {
@@ -45,10 +46,24 @@ public function handle(
}
if ($lockedEntry->getOrderId() !== null) {
- $orderRepository->deleteWhere([
- 'id' => $lockedEntry->getOrderId(),
- 'status' => OrderStatus::RESERVED->name,
- ]);
+ $orderHasStripePayment = $stripePaymentsRepository->countWhere([
+ 'order_id' => $lockedEntry->getOrderId(),
+ ]) > 0;
+
+ if ($orderHasStripePayment) {
+ $orderRepository->updateWhere(
+ attributes: ['status' => OrderStatus::ABANDONED->name],
+ where: [
+ 'id' => $lockedEntry->getOrderId(),
+ 'status' => OrderStatus::RESERVED->name,
+ ],
+ );
+ } else {
+ $orderRepository->deleteWhere([
+ 'id' => $lockedEntry->getOrderId(),
+ 'status' => OrderStatus::RESERVED->name,
+ ]);
+ }
}
$repository->updateWhere(
@@ -78,6 +93,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/Event/UpdateEventStatsListener.php b/backend/app/Listeners/Event/UpdateEventStatsListener.php
index bc2dcff93f..fb33021301 100644
--- a/backend/app/Listeners/Event/UpdateEventStatsListener.php
+++ b/backend/app/Listeners/Event/UpdateEventStatsListener.php
@@ -9,7 +9,7 @@ class UpdateEventStatsListener
{
public function handle(OrderStatusChangedEvent $changedEvent): void
{
- if (!$changedEvent->order->isOrderCompleted()) {
+ if (! $changedEvent->order->isOrderCompleted()) {
return;
}
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/Order/CreateInvoiceListener.php b/backend/app/Listeners/Order/CreateInvoiceListener.php
index 827cb84f4a..ecf258a9ba 100644
--- a/backend/app/Listeners/Order/CreateInvoiceListener.php
+++ b/backend/app/Listeners/Order/CreateInvoiceListener.php
@@ -4,21 +4,15 @@
use HiEvents\DomainObjects\Status\OrderStatus;
use HiEvents\Events\OrderStatusChangedEvent;
-use HiEvents\Exceptions\ResourceConflictException;
use HiEvents\Services\Domain\Invoice\InvoiceCreateService;
class CreateInvoiceListener
{
- public function __construct(private readonly InvoiceCreateService $invoiceCreateService)
- {
- }
+ public function __construct(private readonly InvoiceCreateService $invoiceCreateService) {}
- /**
- * @throws ResourceConflictException
- */
public function handle(OrderStatusChangedEvent $event): void
{
- if (!$event->createInvoice) {
+ if (! $event->createInvoice) {
return;
}
diff --git a/backend/app/Listeners/Order/SendOrderDetailsEmailListener.php b/backend/app/Listeners/Order/SendOrderDetailsEmailListener.php
index 85ee2256de..55bfa80cdb 100644
--- a/backend/app/Listeners/Order/SendOrderDetailsEmailListener.php
+++ b/backend/app/Listeners/Order/SendOrderDetailsEmailListener.php
@@ -9,7 +9,7 @@ class SendOrderDetailsEmailListener
{
public function handle(OrderStatusChangedEvent $changedEvent): void
{
- if (!$changedEvent->sendEmails) {
+ if (! $changedEvent->sendEmails) {
return;
}
diff --git a/backend/app/Listeners/Waitlist/CancelWaitlistEntriesOnOccurrenceCancelledListener.php b/backend/app/Listeners/Waitlist/CancelWaitlistEntriesOnOccurrenceCancelledListener.php
new file mode 100644
index 0000000000..1695726dc0
--- /dev/null
+++ b/backend/app/Listeners/Waitlist/CancelWaitlistEntriesOnOccurrenceCancelledListener.php
@@ -0,0 +1,47 @@
+waitlistEntryRepository->updateWhere(
+ attributes: [
+ 'status' => WaitlistEntryStatus::CANCELLED->name,
+ 'cancelled_at' => now(),
+ ],
+ where: [
+ 'event_id' => $event->eventId,
+ 'event_occurrence_id' => $event->occurrenceId,
+ 'status' => WaitlistEntryStatus::WAITING->name,
+ ],
+ );
+
+ $offeredEntries = $this->waitlistEntryRepository->findWhere([
+ 'event_id' => $event->eventId,
+ 'event_occurrence_id' => $event->occurrenceId,
+ 'status' => WaitlistEntryStatus::OFFERED->name,
+ ]);
+
+ $offeredEntries->each(function (WaitlistEntryDomainObject $entry) {
+ try {
+ $this->cancelWaitlistEntryService->cancelEntry($entry);
+ } catch (ResourceConflictException) {
+ }
+ });
+ }
+}
diff --git a/backend/app/Listeners/Waitlist/ProcessWaitlistOnCapacityAvailableListener.php b/backend/app/Listeners/Waitlist/ProcessWaitlistOnCapacityAvailableListener.php
index 6ee1809634..1f77f98ad8 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,18 @@ 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) {
+ 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..b45a2a272d 100644
--- a/backend/app/Listeners/Waitlist/ResolveWaitlistEntryOnOrderCompletedListener.php
+++ b/backend/app/Listeners/Waitlist/ResolveWaitlistEntryOnOrderCompletedListener.php
@@ -7,9 +7,6 @@
use HiEvents\DomainObjects\Status\WaitlistEntryStatus;
use HiEvents\DomainObjects\WaitlistEntryDomainObject;
use HiEvents\Events\OrderStatusChangedEvent;
-use HiEvents\DomainObjects\Enums\CapacityChangeDirection;
-use HiEvents\Events\CapacityChangedEvent;
-use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface;
use HiEvents\Repository\Interfaces\WaitlistEntryRepositoryInterface;
use Illuminate\Database\DatabaseManager;
@@ -17,24 +14,18 @@ class ResolveWaitlistEntryOnOrderCompletedListener
{
public function __construct(
private readonly WaitlistEntryRepositoryInterface $waitlistEntryRepository,
- private readonly ProductPriceRepositoryInterface $productPriceRepository,
- private readonly DatabaseManager $databaseManager,
- )
- {
- }
+ private readonly DatabaseManager $databaseManager,
+ ) {}
public function handle(OrderStatusChangedEvent $event): void
{
$order = $event->order;
- if ($order->getStatus() === OrderStatus::COMPLETED->name) {
- $this->resolveByOrderId($order->getId());
+ if ($order->getStatus() !== OrderStatus::COMPLETED->name) {
return;
}
- if ($order->getStatus() === OrderStatus::CANCELLED->name) {
- $this->revertOfferedEntriesByOrderId($order->getId());
- }
+ $this->resolveByOrderId($order->getId());
}
private function resolveByOrderId(int $orderId): void
@@ -51,34 +42,6 @@ private function resolveByOrderId(int $orderId): void
});
}
- private function revertOfferedEntriesByOrderId(int $orderId): void
- {
- $capacityEvents = [];
-
- $this->databaseManager->transaction(function () use ($orderId, &$capacityEvents) {
- $entries = $this->waitlistEntryRepository->findWhere([
- 'order_id' => $orderId,
- ['status', 'in', [WaitlistEntryStatus::OFFERED->name]],
- ]);
-
- foreach ($entries as $entry) {
- $this->revertToWaiting($entry);
-
- $productPrice = $this->productPriceRepository->findById($entry->getProductPriceId());
- $capacityEvents[] = new CapacityChangedEvent(
- eventId: $entry->getEventId(),
- direction: CapacityChangeDirection::INCREASED,
- productId: $productPrice->getProductId(),
- productPriceId: $entry->getProductPriceId(),
- );
- }
- });
-
- foreach ($capacityEvents as $capacityEvent) {
- event($capacityEvent);
- }
- }
-
private function markAsPurchased(WaitlistEntryDomainObject $entry): void
{
$this->waitlistEntryRepository->updateWhere(
@@ -92,21 +55,4 @@ private function markAsPurchased(WaitlistEntryDomainObject $entry): void
],
);
}
-
- private function revertToWaiting(WaitlistEntryDomainObject $entry): void
- {
- $this->waitlistEntryRepository->updateWhere(
- attributes: [
- 'status' => WaitlistEntryStatus::WAITING->name,
- 'order_id' => null,
- 'offered_at' => null,
- 'offer_expires_at' => null,
- 'offer_token' => null,
- ],
- where: [
- 'id' => $entry->getId(),
- 'status' => WaitlistEntryStatus::OFFERED->name,
- ],
- );
- }
}
diff --git a/backend/app/Listeners/Webhook/WebhookCallEventListener.php b/backend/app/Listeners/Webhook/WebhookCallEventListener.php
index 0e7d09b15c..5dba7491bb 100644
--- a/backend/app/Listeners/Webhook/WebhookCallEventListener.php
+++ b/backend/app/Listeners/Webhook/WebhookCallEventListener.php
@@ -10,9 +10,7 @@ abstract class WebhookCallEventListener
{
public function __construct(
private readonly WebhookResponseHandlerService $webhookResponseHandlerService,
- )
- {
- }
+ ) {}
protected function handleEvent(WebhookCallEvent $event): void
{
diff --git a/backend/app/Listeners/Webhook/WebhookEventListener.php b/backend/app/Listeners/Webhook/WebhookEventListener.php
index c3f93cfd43..608f54ed6f 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;
@@ -17,9 +19,7 @@ class WebhookEventListener
{
public function __construct(
private readonly Repository $config,
- )
- {
- }
+ ) {}
public function handle(BaseDomainEvent $event): void
{
@@ -50,6 +50,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/Admin/MessagePendingReviewMail.php b/backend/app/Mail/Admin/MessagePendingReviewMail.php
index 55ae5fe1a5..8c68d0bbf9 100644
--- a/backend/app/Mail/Admin/MessagePendingReviewMail.php
+++ b/backend/app/Mail/Admin/MessagePendingReviewMail.php
@@ -27,7 +27,7 @@ public function envelope(): Envelope
{
return new Envelope(
subject: __('[Action Required] Message Pending Review - :subject', [
- 'subject' => $this->message->getSubject()
+ 'subject' => $this->message->getSubject(),
]),
);
}
@@ -41,7 +41,7 @@ public function content(): Content
'event' => $this->event,
'account' => $this->account,
'failures' => $this->failures,
- 'reviewUrl' => config('app.frontend_url') . '/admin/messages?status=PENDING_REVIEW',
+ 'reviewUrl' => config('app.frontend_url').'/admin/messages?status=PENDING_REVIEW',
]
);
}
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/Event/EventMessage.php b/backend/app/Mail/Event/EventMessage.php
index f7807374d0..b283f0d727 100644
--- a/backend/app/Mail/Event/EventMessage.php
+++ b/backend/app/Mail/Event/EventMessage.php
@@ -18,8 +18,7 @@ public function __construct(
private readonly EventDomainObject $event,
private readonly EventSettingDomainObject $eventSettings,
private readonly SendMessageDTO $messageData
- )
- {
+ ) {
parent::__construct();
}
diff --git a/backend/app/Mail/Occurrence/OccurrenceCancellationMail.php b/backend/app/Mail/Occurrence/OccurrenceCancellationMail.php
new file mode 100644
index 0000000000..cbfe6605da
--- /dev/null
+++ b/backend/app/Mail/Occurrence/OccurrenceCancellationMail.php
@@ -0,0 +1,75 @@
+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/OrderCancelled.php b/backend/app/Mail/Order/OrderCancelled.php
index f7aa31f298..621308beb6 100644
--- a/backend/app/Mail/Order/OrderCancelled.php
+++ b/backend/app/Mail/Order/OrderCancelled.php
@@ -17,12 +17,11 @@
class OrderCancelled extends BaseMail
{
public function __construct(
- private readonly OrderDomainObject $order,
- private readonly EventDomainObject $event,
- private readonly OrganizerDomainObject $organizer,
+ private readonly OrderDomainObject $order,
+ private readonly EventDomainObject $event,
+ private readonly OrganizerDomainObject $organizer,
private readonly EventSettingDomainObject $eventSettings,
- )
- {
+ ) {
parent::__construct();
}
@@ -47,7 +46,7 @@ public function content(): Content
Url::getFrontEndUrlFromConfig(Url::EVENT_HOMEPAGE),
$this->event->getId(),
$this->event->getSlug(),
- )
+ ),
]
);
}
diff --git a/backend/app/Mail/Order/OrderFailed.php b/backend/app/Mail/Order/OrderFailed.php
index 0fb97d4d9e..e4e32d6252 100644
--- a/backend/app/Mail/Order/OrderFailed.php
+++ b/backend/app/Mail/Order/OrderFailed.php
@@ -17,12 +17,11 @@
class OrderFailed extends BaseMail
{
public function __construct(
- private readonly OrderDomainObject $order,
- private readonly EventDomainObject $event,
- private readonly OrganizerDomainObject $organizer,
+ private readonly OrderDomainObject $order,
+ private readonly EventDomainObject $event,
+ private readonly OrganizerDomainObject $organizer,
private readonly EventSettingDomainObject $eventSettings,
- )
- {
+ ) {
parent::__construct();
}
@@ -43,11 +42,12 @@ public function content(): Content
'order' => $this->order,
'organizer' => $this->organizer,
'eventSettings' => $this->eventSettings,
+ 'supportEmail' => $this->eventSettings->getSupportEmail() ?: $this->organizer->getEmail(),
'eventUrl' => sprintf(
Url::getFrontEndUrlFromConfig(Url::EVENT_HOMEPAGE),
$this->event->getId(),
$this->event->getSlug(),
- )
+ ),
]
);
}
diff --git a/backend/app/Mail/Order/OrderRefunded.php b/backend/app/Mail/Order/OrderRefunded.php
index 37868bc527..1f5c8a0553 100644
--- a/backend/app/Mail/Order/OrderRefunded.php
+++ b/backend/app/Mail/Order/OrderRefunded.php
@@ -17,13 +17,12 @@
class OrderRefunded extends BaseMail
{
public function __construct(
- private readonly OrderDomainObject $order,
- private readonly EventDomainObject $event,
- private readonly OrganizerDomainObject $organizer,
+ private readonly OrderDomainObject $order,
+ private readonly EventDomainObject $event,
+ private readonly OrganizerDomainObject $organizer,
private readonly EventSettingDomainObject $eventSettings,
- private readonly MoneyValue $refundAmount,
- )
- {
+ private readonly MoneyValue $refundAmount,
+ ) {
parent::__construct();
}
diff --git a/backend/app/Mail/Order/OrderSummary.php b/backend/app/Mail/Order/OrderSummary.php
index 4e6f1b838d..cdd0c6b9aa 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,14 +24,14 @@ 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 OrderDomainObject $order,
+ private readonly EventDomainObject $event,
+ private readonly OrganizerDomainObject $organizer,
private readonly EventSettingDomainObject $eventSettings,
- private readonly ?InvoiceDomainObject $invoice,
- ?RenderedEmailTemplateDTO $renderedTemplate = null,
- )
- {
+ private readonly ?InvoiceDomainObject $invoice,
+ private readonly ?EventOccurrenceDomainObject $occurrence = null,
+ ?RenderedEmailTemplateDTO $renderedTemplate = null,
+ ) {
$this->renderedTemplate = $renderedTemplate;
parent::__construct();
@@ -38,7 +39,7 @@ public function __construct(
public function envelope(): Envelope
{
- $subject = $this->renderedTemplate?->subject ?? __('Your Order is Confirmed!') . ' 🎉';
+ $subject = $this->renderedTemplate?->subject ?? __('Your Order is Confirmed!').' 🎉';
return new Envelope(
replyTo: $this->eventSettings->getSupportEmail(),
@@ -67,11 +68,12 @@ 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(),
$this->order->getShortId(),
- )
+ ),
]
);
}
@@ -92,7 +94,7 @@ public function attachments(): array
return [
Attachment::fromData(
- static fn() => $invoice->output(),
+ static fn () => $invoice->output(),
'invoice.pdf',
)->withMime('application/pdf'),
];
diff --git a/backend/app/Mail/Order/PaymentSuccessButOrderExpiredMail.php b/backend/app/Mail/Order/PaymentSuccessButOrderExpiredMail.php
index 581437fb86..b9183b4104 100644
--- a/backend/app/Mail/Order/PaymentSuccessButOrderExpiredMail.php
+++ b/backend/app/Mail/Order/PaymentSuccessButOrderExpiredMail.php
@@ -16,12 +16,11 @@
class PaymentSuccessButOrderExpiredMail extends BaseMail
{
public function __construct(
- private readonly OrderDomainObject $order,
- private readonly EventDomainObject $event,
+ private readonly OrderDomainObject $order,
+ private readonly EventDomainObject $event,
private readonly EventSettingDomainObject $eventSettings,
- private readonly OrganizerDomainObject $organizer,
- )
- {
+ private readonly OrganizerDomainObject $organizer,
+ ) {
parent::__construct();
}
diff --git a/backend/app/Mail/Organizer/OrderSummaryForOrganizer.php b/backend/app/Mail/Organizer/OrderSummaryForOrganizer.php
index 7101f98c72..03b46913a8 100644
--- a/backend/app/Mail/Organizer/OrderSummaryForOrganizer.php
+++ b/backend/app/Mail/Organizer/OrderSummaryForOrganizer.php
@@ -32,8 +32,8 @@ public function envelope(): Envelope
{
$subject = $this->order->getTotalGross() > 0
? __('New order for :amount for :event 🎉', [
- 'amount' => Currency::format($this->order->getTotalGross(), $this->event->getCurrency()),
- 'event' => Str::limit($this->event->getTitle(), 75)]
+ 'amount' => Currency::format($this->order->getTotalGross(), $this->event->getCurrency()),
+ 'event' => Str::limit($this->event->getTitle(), 75)]
)
: __('New order for :event 🎉', ['event' => Str::limit($this->event->getTitle(), 75)]);
@@ -53,7 +53,7 @@ public function content(): Content
Url::getFrontEndUrlFromConfig(Url::ORGANIZER_ORDER_SUMMARY),
$this->event->getId(),
$this->order->getId(),
- )
+ ),
]
);
}
diff --git a/backend/app/Mail/Organizer/OrganizerContactEmail.php b/backend/app/Mail/Organizer/OrganizerContactEmail.php
index e866916d9b..08d903db53 100644
--- a/backend/app/Mail/Organizer/OrganizerContactEmail.php
+++ b/backend/app/Mail/Organizer/OrganizerContactEmail.php
@@ -12,11 +12,10 @@ class OrganizerContactEmail extends BaseMail
{
public function __construct(
private readonly OrganizerDomainObject $organizer,
- private readonly string $senderName,
- private readonly string $senderEmail,
- private readonly string $messageContent,
- )
- {
+ private readonly string $senderName,
+ private readonly string $senderEmail,
+ private readonly string $messageContent,
+ ) {
parent::__construct();
}
diff --git a/backend/app/Mail/User/UserInvited.php b/backend/app/Mail/User/UserInvited.php
index 20296f7f72..01daa2459d 100644
--- a/backend/app/Mail/User/UserInvited.php
+++ b/backend/app/Mail/User/UserInvited.php
@@ -22,8 +22,7 @@ public function __construct(
UserDomainObject $invitedUser,
string $appName,
string $inviteLink
- )
- {
+ ) {
parent::__construct();
$this->invitedUser = $invitedUser;
@@ -45,7 +44,7 @@ public function content(): Content
with: [
'invitedUser' => $this->invitedUser,
'appName' => $this->appName,
- 'inviteLink' => $this->inviteLink
+ 'inviteLink' => $this->inviteLink,
]
);
}
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..3aaec82b64 100644
--- a/backend/app/Models/Account.php
+++ b/backend/app/Models/Account.php
@@ -14,8 +14,8 @@
class Account extends BaseModel
{
- use SoftDeletes;
use HasFactory;
+ use SoftDeletes;
public function users(): BelongsToMany
{
@@ -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/AccountVatSetting.php b/backend/app/Models/AccountVatSetting.php
index 8315314ef2..890957910f 100644
--- a/backend/app/Models/AccountVatSetting.php
+++ b/backend/app/Models/AccountVatSetting.php
@@ -24,8 +24,8 @@
*/
class AccountVatSetting extends BaseModel
{
- use SoftDeletes;
use HasFactory;
+ use SoftDeletes;
protected function getFillableFields(): array
{
diff --git a/backend/app/Models/Attendee.php b/backend/app/Models/Attendee.php
index 9888de64c4..f6b350ce3c 100644
--- a/backend/app/Models/Attendee.php
+++ b/backend/app/Models/Attendee.php
@@ -6,7 +6,6 @@
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
-use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
class Attendee extends BaseModel
@@ -28,6 +27,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/BaseModel.php b/backend/app/Models/BaseModel.php
index 8be38197f1..ca02b1f835 100644
--- a/backend/app/Models/BaseModel.php
+++ b/backend/app/Models/BaseModel.php
@@ -29,7 +29,7 @@ protected function getCastMap(): array
return [];
}
- protected function getFillableFields() : array
+ protected function getFillableFields(): array
{
return [];
}
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/OrderApplicationFee.php b/backend/app/Models/OrderApplicationFee.php
index aca83e6c8a..32d8e9ea54 100644
--- a/backend/app/Models/OrderApplicationFee.php
+++ b/backend/app/Models/OrderApplicationFee.php
@@ -13,6 +13,7 @@ protected function getCastMap(): array
{
return [
'metadata' => 'array',
+ 'amount' => 'float',
];
}
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/OrderRefund.php b/backend/app/Models/OrderRefund.php
index 5e0036f136..8cf801ce0a 100644
--- a/backend/app/Models/OrderRefund.php
+++ b/backend/app/Models/OrderRefund.php
@@ -15,5 +15,4 @@ protected function getCastMap(): array
'amount' => 'float',
];
}
-
}
diff --git a/backend/app/Models/Organizer.php b/backend/app/Models/Organizer.php
index 9eb57f2bdc..8b2f383b0f 100644
--- a/backend/app/Models/Organizer.php
+++ b/backend/app/Models/Organizer.php
@@ -3,14 +3,15 @@
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;
class Organizer extends BaseModel
{
- use SoftDeletes;
use HasImages;
+ use SoftDeletes;
public function events(): HasMany
{
@@ -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..e2a7b4e080
--- /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..287aa2b774
--- /dev/null
+++ b/backend/app/Models/ProductPriceOccurrenceOverride.php
@@ -0,0 +1,29 @@
+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/PromoCode.php b/backend/app/Models/PromoCode.php
index 6dcaa790f1..08ba375fb9 100644
--- a/backend/app/Models/PromoCode.php
+++ b/backend/app/Models/PromoCode.php
@@ -24,6 +24,7 @@ protected function getFillableFields(): array
PromoCodeDomainObjectAbstract::CODE,
PromoCodeDomainObjectAbstract::DISCOUNT,
PromoCodeDomainObjectAbstract::DISCOUNT_TYPE,
+ PromoCodeDomainObjectAbstract::DISCOUNT_APPLIES_TO,
PromoCodeDomainObjectAbstract::APPLICABLE_PRODUCT_IDS,
PromoCodeDomainObjectAbstract::EXPIRY_DATE,
PromoCodeDomainObjectAbstract::EVENT_ID,
diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php
index 70fdd4fe63..441fa28b4c 100644
--- a/backend/app/Models/User.php
+++ b/backend/app/Models/User.php
@@ -28,14 +28,14 @@
*/
class User extends BaseModel implements AuthenticatableContract, AuthorizableContract, CanResetPasswordContract, JWTSubject
{
- use SoftDeletes;
- use Notifiable;
use Authenticatable;
use Authorizable;
use CanResetPassword;
- use MustVerifyEmail;
use HasFactory;
use Impersonate;
+ use MustVerifyEmail;
+ use Notifiable;
+ use SoftDeletes;
/** @var array */
protected $guarded = [];
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..8b829ce555 100644
--- a/backend/app/Providers/AppServiceProvider.php
+++ b/backend/app/Providers/AppServiceProvider.php
@@ -12,15 +12,20 @@
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 HiEvents\Services\Infrastructure\Stripe\StripeClientFactory;
+use HiEvents\Services\Infrastructure\Stripe\StripeConfigurationService;
+use Illuminate\Contracts\Cache\Repository;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
+use Illuminate\Http\Client\Factory as HttpClient;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Stripe\StripeClient;
-use HiEvents\Services\Infrastructure\Stripe\StripeConfigurationService;
-use HiEvents\Services\Infrastructure\Stripe\StripeClientFactory;
class AppServiceProvider extends ServiceProvider
{
@@ -29,6 +34,7 @@ public function register(): void
$this->bindDoctrineConnection();
$this->bindStripeServices();
$this->bindCurrencyConversionClient();
+ $this->bindGeoProvider();
}
/**
@@ -54,7 +60,7 @@ private function bindDoctrineConnection(): void
$this->app->bind(
AbstractSchemaManager::class,
function () {
- $config = new Configuration();
+ $config = new Configuration;
$connectionParams = [
'dbname' => config('database.connections.pgsql.database'),
@@ -73,29 +79,27 @@ private function bindStripeServices(): void
{
$this->app->singleton(StripeConfigurationService::class);
$this->app->singleton(StripeClientFactory::class);
-
- if (!config('services.stripe.secret_key')) {
+
+ if (! config('services.stripe.secret_key')) {
logger()?->debug('Stripe secret key is not set in the configuration file. Payment processing will not work.');
+
return;
}
$this->app->bind(
StripeClient::class,
- fn() => new StripeClient(config('services.stripe.secret_key'))
+ fn () => new StripeClient(config('services.stripe.secret_key'))
);
}
- /**
- * @return void
- */
private function handleQueryLogging(): void
{
- if (env('APP_DEBUG') === true && env('APP_LOG_QUERIES') === true && !app()->isProduction()) {
+ if (env('APP_DEBUG') === true && env('APP_LOG_QUERIES') === true && ! app()->isProduction()) {
DB::listen(
static function ($query) {
File::append(
storage_path('/logs/query.log'),
- $query->sql . ' [' . implode(', ', $query->bindings) . ']' . PHP_EOL
+ $query->sql.' ['.implode(', ', $query->bindings).']'.PHP_EOL
);
}
);
@@ -120,7 +124,7 @@ private function registerMorphMaps(): void
private function disableLazyLoading(): void
{
- Model::preventLazyLoading(!app()->isProduction());
+ Model::preventLazyLoading(! app()->isProduction());
}
private function bindCurrencyConversionClient(): void
@@ -143,4 +147,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(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..bc3b7c7917 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\EventOccurrenceDailyStatisticRepository;
+use HiEvents\Repository\Eloquent\EventOccurrenceRepository;
+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;
@@ -30,12 +33,17 @@
use HiEvents\Repository\Eloquent\OrderPaymentPlatformFeeRepository;
use HiEvents\Repository\Eloquent\OrderRefundRepository;
use HiEvents\Repository\Eloquent\OrderRepository;
+use HiEvents\Repository\Eloquent\OrganizerConfigurationRepository;
use HiEvents\Repository\Eloquent\OrganizerRepository;
use HiEvents\Repository\Eloquent\OrganizerSettingsRepository;
+use HiEvents\Repository\Eloquent\OrganizerStripePlatformRepository;
+use HiEvents\Repository\Eloquent\OrganizerVatSettingRepository;
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\EventOccurrenceDailyStatisticRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
+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;
@@ -77,12 +88,17 @@
use HiEvents\Repository\Interfaces\OrderPaymentPlatformFeeRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRefundRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrganizerConfigurationRepositoryInterface;
use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface;
use HiEvents\Repository\Interfaces\OrganizerSettingsRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrganizerStripePlatformRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrganizerVatSettingRepositoryInterface;
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..4f3a25ee7e
--- /dev/null
+++ b/backend/app/Repository/DTO/CheckInListProductStatDTO.php
@@ -0,0 +1,15 @@
+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/AffiliateRepository.php b/backend/app/Repository/Eloquent/AffiliateRepository.php
index bc89068a18..c4f4620e42 100644
--- a/backend/app/Repository/Eloquent/AffiliateRepository.php
+++ b/backend/app/Repository/Eloquent/AffiliateRepository.php
@@ -31,15 +31,15 @@ public function getDomainObject(): string
public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator
{
$where = [
- [AffiliateDomainObjectAbstract::EVENT_ID, '=', $eventId]
+ [AffiliateDomainObjectAbstract::EVENT_ID, '=', $eventId],
];
if ($params->query) {
$where[] = static function (Builder $builder) use ($params) {
$builder
- ->orWhere(AffiliateDomainObjectAbstract::NAME, 'ilike', '%' . $params->query . '%')
- ->orWhere(AffiliateDomainObjectAbstract::CODE, 'ilike', '%' . $params->query . '%')
- ->orWhere(AffiliateDomainObjectAbstract::EMAIL, 'ilike', '%' . $params->query . '%');
+ ->orWhere(AffiliateDomainObjectAbstract::NAME, 'ilike', '%'.$params->query.'%')
+ ->orWhere(AffiliateDomainObjectAbstract::CODE, 'ilike', '%'.$params->query.'%')
+ ->orWhere(AffiliateDomainObjectAbstract::EMAIL, 'ilike', '%'.$params->query.'%');
};
}
@@ -66,9 +66,18 @@ public function findByCodeAndEventId(string $code, int $eventId): ?AffiliateDoma
public function incrementSales(int $affiliateId, float $amount): void
{
- $this->model->where('id', $affiliateId)
+ $this->runQuery(fn () => $this->model->where('id', $affiliateId)
->increment('total_sales', 1, [
- 'total_sales_gross' => $this->db->raw('total_sales_gross + ' . $amount)
- ]);
+ 'total_sales_gross' => $this->db->raw('total_sales_gross + '.$amount),
+ ]));
+ }
+
+ public function decrementSales(int $affiliateId, float $amount): void
+ {
+ $this->runQuery(fn () => $this->model->where('id', $affiliateId)
+ ->update([
+ 'total_sales' => $this->db->raw('GREATEST(0, total_sales - 1)'),
+ 'total_sales_gross' => $this->db->raw('GREATEST(0, total_sales_gross - '.$amount.')'),
+ ]));
}
}
diff --git a/backend/app/Repository/Eloquent/AttendeeRepository.php b/backend/app/Repository/Eloquent/AttendeeRepository.php
index 8f2ce62ff0..1c160ba34d 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,51 @@ 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.'%');
};
}
$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..e7e415abfd 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,97 @@ 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) {
+ 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 +251,100 @@ 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;
+ 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 +369,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 +412,35 @@ protected function initModel(?string $model = null): Model
return $this->app->make($model ?: $this->getModel());
}
+ /**
+ * @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 = [];
+ }
+
+ 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 +463,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 +476,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 +500,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 +524,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 +539,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 +570,7 @@ private function hydrateDomainObjectFromModel(
var_export($value, true),
$e->getMessage()
),
- (int)$e->getCode(),
+ (int) $e->getCode(),
$e
);
}
@@ -554,24 +586,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 +618,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/CapacityAssignmentRepository.php b/backend/app/Repository/Eloquent/CapacityAssignmentRepository.php
index 74e82b46aa..5088197377 100644
--- a/backend/app/Repository/Eloquent/CapacityAssignmentRepository.php
+++ b/backend/app/Repository/Eloquent/CapacityAssignmentRepository.php
@@ -28,13 +28,13 @@ public function getDomainObject(): string
public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator
{
$where = [
- [CapacityAssignmentDomainObjectAbstract::EVENT_ID, '=', $eventId]
+ [CapacityAssignmentDomainObjectAbstract::EVENT_ID, '=', $eventId],
];
- if (!empty($params->query)) {
+ if (! empty($params->query)) {
$where[] = static function (Builder $builder) use ($params) {
$builder
- ->where(CapacityAssignmentDomainObjectAbstract::NAME, 'ilike', '%' . $params->query . '%');
+ ->where(CapacityAssignmentDomainObjectAbstract::NAME, 'ilike', '%'.$params->query.'%');
};
}
diff --git a/backend/app/Repository/Eloquent/CheckInListRepository.php b/backend/app/Repository/Eloquent/CheckInListRepository.php
index 46b37358da..91debfbce5 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,45 @@ 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);
+
$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 +87,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 +99,60 @@ public function getCheckedInAttendeeCountById(int $checkInListId): CheckedInAtte
);
}
+ 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],
+ ];
+ }
+
+ 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), '?'));
$sql = <<db->select($sql, array_merge($checkInListIds, $checkInListIds, $checkInListIds));
return collect($query)->map(
- static fn($item) => new CheckedInAttendeesCountDTO(
+ static fn ($item) => new CheckedInAttendeesCountDTO(
checkInListId: $item->check_in_list_id,
checkedInCount: $item->checked_in_attendees,
totalAttendeesCount: $item->total_attendees,
@@ -126,16 +182,148 @@ public function getCheckedInAttendeeCountByIds(array $checkInListIds): Collectio
);
}
+ public function getPerProductCheckInStatsById(
+ int $checkInListId,
+ ?int $eventOccurrenceIdOverride = null,
+ ): Collection {
+ $clause = $this->buildOccurrenceFilterClauses($eventOccurrenceIdOverride);
+
+ $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 = [
- [CheckInListDomainObjectAbstract::EVENT_ID, '=', $eventId]
+ [CheckInListDomainObjectAbstract::EVENT_ID, '=', $eventId],
];
- if (!empty($params->query)) {
+ if (! empty($params->query)) {
$where[] = static function (Builder $builder) use ($params) {
$builder
- ->where(CapacityAssignmentDomainObjectAbstract::NAME, 'ilike', '%' . $params->query . '%');
+ ->where(CapacityAssignmentDomainObjectAbstract::NAME, 'ilike', '%'.$params->query.'%');
};
}
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..3171121a94
--- /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->whereRaw('COALESCE(end_date, start_date) >= ?', [$now]);
+ } elseif ($timePeriod->value === 'past') {
+ $this->model = $this->model->whereRaw('COALESCE(end_date, 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..11fe369a84 100644
--- a/backend/app/Repository/Eloquent/EventRepository.php
+++ b/backend/app/Repository/Eloquent/EventRepository.php
@@ -6,6 +6,7 @@
use HiEvents\DomainObjects\AccountDomainObject;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventStatisticDomainObject;
use HiEvents\DomainObjects\Generated\EventDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\EventSettingDomainObjectAbstract;
@@ -17,6 +18,7 @@
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
+use Illuminate\Support\Facades\DB;
/**
* @extends BaseRepository
@@ -50,34 +52,61 @@ public function findEventsForOrganizer(int $organizerId, int $accountId, QueryPa
public function findEvents(array $where, QueryParamsDTO $params): LengthAwarePaginator
{
- if (!empty($params->query)) {
+ if (! empty($params->query)) {
$where[] = static function (Builder $builder) use ($params) {
$builder
- ->where(EventDomainObjectAbstract::TITLE, 'ilike', '%' . $params->query . '%');
+ ->where(EventDomainObjectAbstract::TITLE, 'ilike', '%'.$params->query.'%');
};
}
$upcomingEventsFilter = $params->query_params->get('eventsStatus') === 'upcoming';
+ $endedEventsFilter = $params->query_params->get('eventsStatus') === 'ended';
- if (!empty($params->filter_fields) && !$upcomingEventsFilter) {
+ if (! empty($params->filter_fields)) {
$this->applyFilterFields($params, EventDomainObject::getAllowedFilterFields());
}
- // Apply custom filter for upcoming events, as it keeps things less complex on the front-end
if ($upcomingEventsFilter) {
$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());
+ ->where(function (Builder $eventQuery) {
+ $eventQuery
+ ->whereNotExists(function ($query) {
+ $query->select(DB::raw(1))
+ ->from('event_occurrences')
+ ->whereColumn('event_occurrences.event_id', 'events.id')
+ ->whereNull('event_occurrences.deleted_at');
+ })
+ ->orWhereExists(function ($query) {
+ $query->select(DB::raw(1))
+ ->from('event_occurrences')
+ ->whereColumn('event_occurrences.event_id', 'events.id')
+ ->whereNull('event_occurrences.deleted_at')
+ ->whereRaw('COALESCE(event_occurrences.end_date, event_occurrences.start_date) >= ?', [now()]);
+ });
});
};
+ }
- $organizerId = $params->filter_fields->first(fn($filter) => $filter->field === EventDomainObjectAbstract::ORGANIZER_ID)?->value;
- if ($organizerId) {
- $this->model = $this->model->where(EventDomainObjectAbstract::ORGANIZER_ID, $organizerId);
- }
+ if ($endedEventsFilter) {
+ $where[] = static function (Builder $builder) {
+ $builder
+ ->where(EventDomainObjectAbstract::STATUS, '!=', EventStatus::ARCHIVED->getName())
+ ->whereExists(function ($query) {
+ $query->select(DB::raw(1))
+ ->from('event_occurrences')
+ ->whereColumn('event_occurrences.event_id', 'events.id')
+ ->whereNull('event_occurrences.deleted_at');
+ })
+ ->whereNotExists(function ($query) {
+ $query->select(DB::raw(1))
+ ->from('event_occurrences')
+ ->whereColumn('event_occurrences.event_id', 'events.id')
+ ->whereNull('event_occurrences.deleted_at')
+ ->whereRaw('COALESCE(event_occurrences.end_date, event_occurrences.start_date) >= ?', [now()]);
+ });
+ };
}
$this->model = $this->model->orderBy(
@@ -97,22 +126,33 @@ public function getUpcomingEventsForAdmin(int $perPage): LengthAwarePaginator
$now = now();
$next24Hours = now()->addDay();
- return $this->handleResults($this->model
+ $this->model = $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')
- ->paginate($perPage));
+ ->orderBy(EventDomainObjectAbstract::CREATED_AT, 'desc');
+
+ $this->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer'));
+ $this->loadRelation(new Relationship(AccountDomainObject::class, name: 'account'));
+ $this->loadRelation(new Relationship(EventOccurrenceDomainObject::class));
+
+ return $this->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
@@ -121,22 +161,33 @@ public function getAllEventsForAdmin(
if ($search) {
$this->model = $this->model->where(function ($q) use ($search) {
- $q->where(EventDomainObjectAbstract::TITLE, 'ilike', '%' . $search . '%')
+ $q->where(EventDomainObjectAbstract::TITLE, 'ilike', '%'.$search.'%')
->orWhereHas('organizer', function ($orgQuery) use ($search) {
- $orgQuery->where('name', 'ilike', '%' . $search . '%');
+ $orgQuery->where('name', 'ilike', '%'.$search.'%');
});
});
}
- $allowedSortColumns = ['start_date', 'end_date', 'title', 'created_at'];
- $sortColumn = in_array($sortBy, $allowedSortColumns, true) ? $sortBy : 'start_date';
- $sortDir = in_array(strtolower($sortDirection), ['asc', 'desc']) ? $sortDirection : 'desc';
-
- $this->model = $this->model->orderBy($sortColumn, $sortDir);
+ $allowedSortColumns = ['title', 'created_at', 'updated_at', 'start_date', 'end_date'];
+ $sortColumn = in_array($sortBy, $allowedSortColumns, true) ? $sortBy : 'created_at';
+ $sortDir = in_array(strtolower((string) $sortDirection), ['asc', 'desc'], true) ? strtolower($sortDirection) : 'desc';
+
+ if ($sortColumn === 'start_date') {
+ $this->model = $this->model->orderByRaw(
+ "(SELECT MIN(eo.start_date) FROM event_occurrences eo WHERE eo.event_id = events.id AND eo.deleted_at IS NULL) {$sortDir} NULLS LAST"
+ );
+ } elseif ($sortColumn === 'end_date') {
+ $this->model = $this->model->orderByRaw(
+ "(SELECT MAX(COALESCE(eo.end_date, eo.start_date)) FROM event_occurrences eo WHERE eo.event_id = events.id AND eo.deleted_at IS NULL) {$sortDir} NULLS LAST"
+ );
+ } else {
+ $this->model = $this->model->orderBy($sortColumn, $sortDir);
+ }
$this->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer'));
$this->loadRelation(new Relationship(AccountDomainObject::class, name: 'account'));
$this->loadRelation(new Relationship(EventStatisticDomainObject::class, name: 'event_statistics'));
+ $this->loadRelation(new Relationship(EventOccurrenceDomainObject::class));
return $this->paginate($perPage);
}
@@ -145,27 +196,36 @@ public function getSitemapEvents(int $page, int $perPage): LengthAwarePaginator
{
return $this->handleResults($this->model
->select([
- 'events.' . EventDomainObjectAbstract::ID,
- 'events.' . EventDomainObjectAbstract::TITLE,
- 'events.' . EventDomainObjectAbstract::UPDATED_AT,
- 'events.' . EventDomainObjectAbstract::START_DATE,
+ 'events.'.EventDomainObjectAbstract::ID,
+ 'events.'.EventDomainObjectAbstract::TITLE,
+ 'events.'.EventDomainObjectAbstract::UPDATED_AT,
])
->join('event_settings', 'events.id', '=', 'event_settings.event_id')
- ->where('events.' . EventDomainObjectAbstract::STATUS, EventStatus::LIVE->name)
- ->where('event_settings.' . EventSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true)
- ->whereNull('events.' . EventDomainObjectAbstract::DELETED_AT)
- ->orderBy('events.' . EventDomainObjectAbstract::ID)
+ ->where('events.'.EventDomainObjectAbstract::STATUS, EventStatus::LIVE->name)
+ ->where('event_settings.'.EventSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true)
+ ->whereNull('events.'.EventDomainObjectAbstract::DELETED_AT)
+ ->orderBy('events.'.EventDomainObjectAbstract::ID)
->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
->newQuery()
->join('event_settings', 'events.id', '=', 'event_settings.event_id')
- ->where('events.' . EventDomainObjectAbstract::STATUS, EventStatus::LIVE->name)
- ->where('event_settings.' . EventSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true)
- ->whereNull('events.' . EventDomainObjectAbstract::DELETED_AT)
+ ->where('events.'.EventDomainObjectAbstract::STATUS, EventStatus::LIVE->name)
+ ->where('event_settings.'.EventSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true)
+ ->whereNull('events.'.EventDomainObjectAbstract::DELETED_AT)
->count();
}
}
diff --git a/backend/app/Repository/Eloquent/InvoiceRepository.php b/backend/app/Repository/Eloquent/InvoiceRepository.php
index 3103751eb0..3861f7ab95 100644
--- a/backend/app/Repository/Eloquent/InvoiceRepository.php
+++ b/backend/app/Repository/Eloquent/InvoiceRepository.php
@@ -23,7 +23,7 @@ public function getDomainObject(): string
public function findLatestInvoiceForEvent(int $eventId): ?InvoiceDomainObject
{
- $invoice = $this->model
+ $invoice = $this->model
->whereHas('order', function ($query) use ($eventId) {
$query->where('event_id', $eventId);
})
@@ -35,7 +35,7 @@ public function findLatestInvoiceForEvent(int $eventId): ?InvoiceDomainObject
public function findLatestInvoiceForOrder(int $orderId): ?InvoiceDomainObject
{
- $invoice = $this->model
+ $invoice = $this->model
->where('order_id', $orderId)
->orderBy('id', 'desc')
->first();
diff --git a/backend/app/Repository/Eloquent/LocationRepository.php b/backend/app/Repository/Eloquent/LocationRepository.php
new file mode 100644
index 0000000000..57e74879ce
--- /dev/null
+++ b/backend/app/Repository/Eloquent/LocationRepository.php
@@ -0,0 +1,89 @@
+
+ */
+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
+ {
+ return $this->runQuery(function () use ($locationId) {
+ $liveEventLocationCount = DB::table('event_locations')
+ ->where('event_locations.location_id', $locationId)
+ ->whereNull('event_locations.deleted_at')
+ ->whereExists(function ($subQuery) {
+ $subQuery->select(DB::raw(1))
+ ->from('events')
+ ->whereColumn('events.id', 'event_locations.event_id')
+ ->whereNull('events.deleted_at');
+ })
+ ->count();
+
+ if ($liveEventLocationCount > 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/MessageRepository.php b/backend/app/Repository/Eloquent/MessageRepository.php
index c38216f208..dddf6e5daa 100644
--- a/backend/app/Repository/Eloquent/MessageRepository.php
+++ b/backend/app/Repository/Eloquent/MessageRepository.php
@@ -30,14 +30,14 @@ public function getDomainObject(): string
public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator
{
$where = [
- [MessageDomainObjectAbstract::EVENT_ID, '=', $eventId]
+ [MessageDomainObjectAbstract::EVENT_ID, '=', $eventId],
];
if ($params->query) {
$where[] = static function (Builder $builder) use ($params) {
$builder
- ->where(MessageDomainObjectAbstract::SUBJECT, 'ilike', '%' . $params->query . '%')
- ->orWhere(MessageDomainObjectAbstract::MESSAGE, 'ilike', '%' . $params->query . '%');
+ ->where(MessageDomainObjectAbstract::SUBJECT, 'ilike', '%'.$params->query.'%')
+ ->orWhere(MessageDomainObjectAbstract::MESSAGE, 'ilike', '%'.$params->query.'%');
};
}
diff --git a/backend/app/Repository/Eloquent/OrderItemRepository.php b/backend/app/Repository/Eloquent/OrderItemRepository.php
index 72384aa8b3..0147150592 100644
--- a/backend/app/Repository/Eloquent/OrderItemRepository.php
+++ b/backend/app/Repository/Eloquent/OrderItemRepository.php
@@ -2,7 +2,9 @@
namespace HiEvents\Repository\Eloquent;
+use HiEvents\DomainObjects\Enums\ProductType;
use HiEvents\DomainObjects\OrderItemDomainObject;
+use HiEvents\DomainObjects\Status\OrderStatus;
use HiEvents\Models\OrderItem;
use HiEvents\Repository\Interfaces\OrderItemRepositoryInterface;
@@ -20,4 +22,16 @@ public function getDomainObject(): string
{
return OrderItemDomainObject::class;
}
+
+ public function getReservedQuantityForOccurrence(int $occurrenceId): int
+ {
+ return $this->runQuery(fn () => (int) OrderItem::query()
+ ->join('orders', 'orders.id', '=', 'order_items.order_id')
+ ->where('order_items.event_occurrence_id', $occurrenceId)
+ ->where('order_items.product_type', ProductType::TICKET->name)
+ ->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 3130d9adb2..7448288776 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)
@@ -218,11 +240,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.'%');
});
}
@@ -233,10 +255,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..50f126c50f
--- /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/ProductCategoryRepository.php b/backend/app/Repository/Eloquent/ProductCategoryRepository.php
index 0e39778c95..76c61eeada 100644
--- a/backend/app/Repository/Eloquent/ProductCategoryRepository.php
+++ b/backend/app/Repository/Eloquent/ProductCategoryRepository.php
@@ -30,7 +30,7 @@ public function findByEventId(int $eventId, QueryParamsDTO $queryParamsDTO): Col
->with(['products']);
// Apply filters from QueryParamsDTO, if needed
- if (!empty($queryParamsDTO->filter_fields)) {
+ if (! empty($queryParamsDTO->filter_fields)) {
foreach ($queryParamsDTO->filter_fields as $filter) {
$query->where($filter->field, $filter->operator ?? '=', $filter->value);
}
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 @@
+query)) {
+ if (! empty($params->query)) {
$where[] = static function (Builder $builder) use ($params) {
$builder
- ->where(ProductDomainObjectAbstract::TITLE, 'ilike', '%' . $params->query . '%');
+ ->where(ProductDomainObjectAbstract::TITLE, 'ilike', '%'.$params->query.'%');
};
}
@@ -52,14 +53,9 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware
);
}
- /**
- * @param int $productId
- * @param int $productPriceId
- * @return int
- */
public function getQuantityRemainingForProductPrice(int $productId, int $productPriceId): int
{
- $query = <<db->selectOne($query, [
'productPriceId' => $productPriceId,
- 'productId' => $productId
+ 'productId' => $productId,
]);
if ($result === null) {
@@ -93,12 +89,12 @@ public function getQuantityRemainingForProductPrice(int $productId, int $product
return Constants::INFINITE;
}
- return (int)$result->quantity_remaining;
+ return (int) $result->quantity_remaining;
}
public function getTaxesByProductId(int $productId): Collection
{
- $query = <<db->select($query, [
- 'productId' => $productId
+ 'productId' => $productId,
]);
return $this->handleResults($taxAndFees, TaxAndFeesDomainObject::class);
@@ -115,7 +111,7 @@ public function getTaxesByProductId(int $productId): Collection
public function getProductsByTaxId(int $taxId): Collection
{
- $query = <<model->select($query, [
- 'taxAndFeeId' => $taxId
+ 'taxAndFeeId' => $taxId,
]);
return $this->handleResults($products, ProductDomainObject::class);
@@ -208,22 +204,22 @@ public function bulkUpdateProductsAndCategories(int $eventId, array $productUpda
$productParameters = [
'eventId' => $eventId,
- 'productIds' => '{' . implode(',', $productIds) . '}',
- 'productOrders' => '{' . implode(',', $productOrders) . '}',
- 'productCategoryIds' => '{' . implode(',', $productCategoryIds) . '}',
+ 'productIds' => '{'.implode(',', $productIds).'}',
+ 'productOrders' => '{'.implode(',', $productOrders).'}',
+ 'productCategoryIds' => '{'.implode(',', $productCategoryIds).'}',
];
- $productUpdateQuery = "WITH new_order AS (
+ $productUpdateQuery = 'WITH new_order AS (
SELECT unnest(:productIds::bigint[]) AS product_id,
unnest(:productOrders::int[]) AS order,
unnest(:productCategoryIds::bigint[]) AS category_id
)
UPDATE products
- SET \"order\" = new_order.order,
+ SET "order" = new_order.order,
product_category_id = new_order.category_id,
updated_at = NOW()
FROM new_order
- WHERE products.id = new_order.product_id AND products.event_id = :eventId";
+ WHERE products.id = new_order.product_id AND products.event_id = :eventId';
$this->db->update($productUpdateQuery, $productParameters);
@@ -232,19 +228,19 @@ public function bulkUpdateProductsAndCategories(int $eventId, array $productUpda
$categoryParameters = [
'eventId' => $eventId,
- 'categoryIds' => '{' . implode(',', $categoryIds) . '}',
- 'categoryOrders' => '{' . implode(',', $categoryOrders) . '}',
+ 'categoryIds' => '{'.implode(',', $categoryIds).'}',
+ 'categoryOrders' => '{'.implode(',', $categoryOrders).'}',
];
- $categoryUpdateQuery = "WITH new_category_order AS (
+ $categoryUpdateQuery = 'WITH new_category_order AS (
SELECT unnest(:categoryIds::bigint[]) AS category_id,
unnest(:categoryOrders::int[]) AS order
)
UPDATE product_categories
- SET \"order\" = new_category_order.order,
+ SET "order" = new_category_order.order,
updated_at = NOW()
FROM new_category_order
- WHERE product_categories.id = new_category_order.category_id AND product_categories.event_id = :eventId";
+ WHERE product_categories.id = new_category_order.category_id AND product_categories.event_id = :eventId';
$this->db->update($categoryUpdateQuery, $categoryParameters);
@@ -257,15 +253,26 @@ public function bulkUpdateProductsAndCategories(int $eventId, array $productUpda
public function hasAssociatedOrders(int $productId): bool
{
- return $this->db->table('order_items')
- ->join('orders', 'order_items.order_id', '=', 'orders.id')
- ->whereIn('orders.status', [
- OrderStatus::COMPLETED->name,
- OrderStatus::CANCELLED->name,
- OrderStatus::AWAITING_OFFLINE_PAYMENT->name,
- ])
- ->where('order_items.product_id', $productId)
- ->exists();
+ return $this->runQuery(
+ fn () => $this->db->table('order_items')
+ ->join('orders', 'order_items.order_id', '=', 'orders.id')
+ ->where('order_items.product_id', $productId)
+ ->where(static function (QueryBuilder $query) {
+ $query
+ ->whereIn('orders.status', [
+ OrderStatus::COMPLETED->name,
+ OrderStatus::CANCELLED->name,
+ OrderStatus::AWAITING_OFFLINE_PAYMENT->name,
+ ])
+ ->orWhere(static function (QueryBuilder $reserved) {
+ $reserved
+ ->where('orders.status', OrderStatus::RESERVED->name)
+ ->where('orders.reserved_until', '>', now())
+ ->whereNull('orders.deleted_at');
+ });
+ })
+ ->exists()
+ );
}
public function getModel(): string
diff --git a/backend/app/Repository/Eloquent/PromoCodeRepository.php b/backend/app/Repository/Eloquent/PromoCodeRepository.php
index 3a93e91a8b..876342dc30 100644
--- a/backend/app/Repository/Eloquent/PromoCodeRepository.php
+++ b/backend/app/Repository/Eloquent/PromoCodeRepository.php
@@ -28,13 +28,13 @@ public function getDomainObject(): string
public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator
{
$where = [
- [PromoCodeDomainObjectAbstract::EVENT_ID, '=', $eventId]
+ [PromoCodeDomainObjectAbstract::EVENT_ID, '=', $eventId],
];
if ($params->query) {
$where[] = static function (Builder $builder) use ($params) {
$builder
- ->orWhere(PromoCodeDomainObjectAbstract::CODE, 'ilike', '%' . $params->query . '%');
+ ->orWhere(PromoCodeDomainObjectAbstract::CODE, 'ilike', '%'.$params->query.'%');
};
}
diff --git a/backend/app/Repository/Eloquent/QuestionAnswerRepository.php b/backend/app/Repository/Eloquent/QuestionAnswerRepository.php
index 3c6a168539..cc764e33c9 100644
--- a/backend/app/Repository/Eloquent/QuestionAnswerRepository.php
+++ b/backend/app/Repository/Eloquent/QuestionAnswerRepository.php
@@ -2,7 +2,6 @@
namespace HiEvents\Repository\Eloquent;
-
use HiEvents\DomainObjects\QuestionAnswerDomainObject;
use HiEvents\Models\QuestionAnswer;
use HiEvents\Repository\Interfaces\QuestionAnswerRepositoryInterface;
diff --git a/backend/app/Repository/Eloquent/QuestionRepository.php b/backend/app/Repository/Eloquent/QuestionRepository.php
index 94f9d547a2..0683f7aab1 100644
--- a/backend/app/Repository/Eloquent/QuestionRepository.php
+++ b/backend/app/Repository/Eloquent/QuestionRepository.php
@@ -4,10 +4,10 @@
use HiEvents\DomainObjects\Generated\QuestionDomainObjectAbstract;
use HiEvents\DomainObjects\QuestionDomainObject;
-use HiEvents\Models\Question;
use HiEvents\Models\ProductQuestion;
-use HiEvents\Repository\Interfaces\QuestionRepositoryInterface;
+use HiEvents\Models\Question;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
+use HiEvents\Repository\Interfaces\QuestionRepositoryInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Foundation\Application;
use Illuminate\Support\Collection;
@@ -41,7 +41,7 @@ public function create(array $attributes, array $productIds = []): QuestionDomai
$question = parent::create($attributes);
foreach ($productIds as $productId) {
- $productQuestion = new ProductQuestion();
+ $productQuestion = new ProductQuestion;
$productQuestion->create([
'product_id' => $productId,
'question_id' => $question->getId(),
@@ -63,7 +63,7 @@ public function updateQuestion(int $questionId, int $eventId, array $attributes,
ProductQuestion::where('question_id', $questionId)->delete();
foreach ($productIds as $productId) {
- $productQuestion = new ProductQuestion();
+ $productQuestion = new ProductQuestion;
$productQuestion->create([
'product_id' => $productId,
'question_id' => $questionId,
@@ -76,25 +76,25 @@ public function findByEventId(int $eventId): Collection
return $this
->findWhere([
QuestionDomainObjectAbstract::EVENT_ID => $eventId,
- ])->sortBy((fn(QuestionDomainObject $question) => $question->getOrder()));
+ ])->sortBy((fn (QuestionDomainObject $question) => $question->getOrder()));
}
public function sortQuestions(int $eventId, array $orderedQuestionIds): void
{
$parameters = [
'eventId' => $eventId,
- 'questionIds' => '{' . implode(',', $orderedQuestionIds) . '}',
- 'orders' => '{' . implode(',', range(1, count($orderedQuestionIds))) . '}',
+ 'questionIds' => '{'.implode(',', $orderedQuestionIds).'}',
+ 'orders' => '{'.implode(',', range(1, count($orderedQuestionIds))).'}',
];
- $query = "WITH new_order AS (
+ $query = 'WITH new_order AS (
SELECT unnest(:questionIds::bigint[]) AS question_id,
unnest(:orders::int[]) AS order
)
UPDATE questions
- SET \"order\" = new_order.order
+ SET "order" = new_order.order
FROM new_order
- WHERE questions.id = new_order.question_id AND questions.event_id = :eventId";
+ WHERE questions.id = new_order.question_id AND questions.event_id = :eventId';
$this->db->update($query, $parameters);
}
diff --git a/backend/app/Repository/Eloquent/StripePayoutsRepository.php b/backend/app/Repository/Eloquent/StripePayoutsRepository.php
index 923b0571dd..a4eee2aa6d 100644
--- a/backend/app/Repository/Eloquent/StripePayoutsRepository.php
+++ b/backend/app/Repository/Eloquent/StripePayoutsRepository.php
@@ -2,9 +2,9 @@
namespace HiEvents\Repository\Eloquent;
-use HiEvents\Repository\Interfaces\StripePayoutsRepositoryInterface;
-use HiEvents\Models\StripePayout;
use HiEvents\DomainObjects\StripePayoutDomainObject;
+use HiEvents\Models\StripePayout;
+use HiEvents\Repository\Interfaces\StripePayoutsRepositoryInterface;
/**
* @extends BaseRepository
diff --git a/backend/app/Repository/Eloquent/UserRepository.php b/backend/app/Repository/Eloquent/UserRepository.php
index b1a8c64546..324bf0fe1f 100644
--- a/backend/app/Repository/Eloquent/UserRepository.php
+++ b/backend/app/Repository/Eloquent/UserRepository.php
@@ -33,7 +33,7 @@ public function findByIdAndAccountId(int $userId, int $accountId): UserDomainObj
{
$accountUser = AccountUser::where('user_id', $userId)->where('account_id', $accountId)->first();
- if (!$accountUser) {
+ if (! $accountUser) {
throw new ResourceNotFoundException(__('User not found in this account'));
}
@@ -59,7 +59,7 @@ public function findUsersByAccountId(int $accountId): ?Collection
$users = $this->handleResults($users);
- return $users->sortByDesc(fn(UserDomainObject $user) => $user->getUpdatedAt());
+ return $users->sortByDesc(fn (UserDomainObject $user) => $user->getUpdatedAt());
}
public function getAllUsersWithAccounts(?string $search, int $perPage): LengthAwarePaginator
diff --git a/backend/app/Repository/Eloquent/Value/OrderAndDirection.php b/backend/app/Repository/Eloquent/Value/OrderAndDirection.php
index 4a6058efaa..efe65494d9 100644
--- a/backend/app/Repository/Eloquent/Value/OrderAndDirection.php
+++ b/backend/app/Repository/Eloquent/Value/OrderAndDirection.php
@@ -7,13 +7,13 @@
class OrderAndDirection
{
public const DIRECTION_ASC = 'asc';
+
public const DIRECTION_DESC = 'desc';
public function __construct(
private readonly string $order,
private readonly string $direction = self::DIRECTION_ASC,
- )
- {
+ ) {
$this->validate();
}
@@ -29,7 +29,7 @@ public function getDirection(): string
private function validate(): void
{
- if (!in_array($this->direction, ['asc', 'desc'])) {
+ if (! in_array($this->direction, ['asc', 'desc'])) {
throw new InvalidArgumentException(__('Invalid direction. Must be either asc or desc'));
}
}
diff --git a/backend/app/Repository/Eloquent/Value/Relationship.php b/backend/app/Repository/Eloquent/Value/Relationship.php
index 6dfcb7613c..e9581c1a42 100644
--- a/backend/app/Repository/Eloquent/Value/Relationship.php
+++ b/backend/app/Repository/Eloquent/Value/Relationship.php
@@ -8,20 +8,19 @@
class Relationship
{
public function __construct(
- private readonly string $domainObject,
+ private readonly string $domainObject,
/**
* @var Relationship[]|null
*/
- private readonly ?array $nested = [],
+ private readonly ?array $nested = [],
private readonly ?string $name = null,
/**
* @var OrderAndDirection[]
*/
- private readonly array $orderAndDirections = [],
- )
- {
+ private readonly array $orderAndDirections = [],
+ ) {
$this->validate();
}
@@ -48,7 +47,7 @@ public function getOrderAndDirections(): array
public function buildLaravelEagerLoadArray(): array
{
$results = [
- $this->getName() => $this->buildOrderAndDirectionEloquentCallback()
+ $this->getName() => $this->buildOrderAndDirectionEloquentCallback(),
];
// If there are nested relationships, build them and merge into the results array
@@ -65,8 +64,8 @@ private function buildNested(Relationship $relationship, string $prefix): array
if ($relationship->nested) {
foreach ($relationship->nested as $nested) {
- $nestedPrefix = $prefix === '' ? $relationship->getName() : $prefix . '.' . $relationship->getName();
- $results[$nestedPrefix . '.' . $nested->getName()] = $nested->buildOrderAndDirectionEloquentCallback();
+ $nestedPrefix = $prefix === '' ? $relationship->getName() : $prefix.'.'.$relationship->getName();
+ $results[$nestedPrefix.'.'.$nested->getName()] = $nested->buildOrderAndDirectionEloquentCallback();
$results = array_merge($results, $this->buildNested($nested, $nestedPrefix));
}
}
@@ -89,7 +88,7 @@ private function buildOrderAndDirectionEloquentCallback(): callable|array
private function validate(): void
{
- if (!is_subclass_of($this->domainObject, DomainObjectInterface::class)) {
+ if (! is_subclass_of($this->domainObject, DomainObjectInterface::class)) {
throw new InvalidArgumentException(
__('DomainObject must be a valid :interface.', [
'interface' => DomainObjectInterface::class,
@@ -98,7 +97,7 @@ private function validate(): void
}
foreach ($this->nested as $nested) {
- if (!is_a($nested, __CLASS__)) {
+ if (! is_a($nested, __CLASS__)) {
throw new InvalidArgumentException(
__('Nested relationships must be an array of Relationship objects.'),
);
@@ -106,7 +105,7 @@ private function validate(): void
}
foreach ($this->orderAndDirections as $orderAndDirection) {
- if (!is_a($orderAndDirection, OrderAndDirection::class)) {
+ if (! is_a($orderAndDirection, OrderAndDirection::class)) {
throw new InvalidArgumentException(
__('OrderAndDirections must be an array of OrderAndDirection objects.'),
);
diff --git a/backend/app/Repository/Eloquent/WaitlistEntryRepository.php b/backend/app/Repository/Eloquent/WaitlistEntryRepository.php
index 25af52903c..f508a6594c 100644
--- a/backend/app/Repository/Eloquent/WaitlistEntryRepository.php
+++ b/backend/app/Repository/Eloquent/WaitlistEntryRepository.php
@@ -2,6 +2,7 @@
namespace HiEvents\Repository\Eloquent;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\ProductPriceDomainObjectAbstract;
@@ -18,6 +19,7 @@
use HiEvents\Services\Application\Handlers\Waitlist\DTO\WaitlistStatsDTO;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class WaitlistEntryRepository extends BaseRepository implements WaitlistEntryRepositoryInterface
@@ -32,17 +34,17 @@ public function getDomainObject(): string
return WaitlistEntryDomainObject::class;
}
- public function getStatsByEventId(int $eventId): WaitlistStatsDTO
+ public function getStatsByEventId(int $eventId, ?int $eventOccurrenceId = null): WaitlistStatsDTO
{
- $stats = DB::table('waitlist_entries')
- ->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 +52,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 +68,9 @@ public function getStatsByEventId(int $eventId): WaitlistStatsDTO
);
}
- public function getProductStatsByEventId(int $eventId): \Illuminate\Support\Collection
+ public function getProductStatsByEventId(int $eventId, ?int $eventOccurrenceId = null): 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 +90,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
+ * @return Collection
*/
- public function getNextWaitingEntries(int $productPriceId, int $limit): \Illuminate\Support\Collection
+ public function getNextWaitingEntries(int $productPriceId, ?int $limit = null, ?int $eventOccurrenceId = null): 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 +165,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 +181,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 +194,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/AccountConfigurationRepositoryInterface.php b/backend/app/Repository/Interfaces/AccountConfigurationRepositoryInterface.php
index 98b2add2c0..fc3d76b0f3 100644
--- a/backend/app/Repository/Interfaces/AccountConfigurationRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/AccountConfigurationRepositoryInterface.php
@@ -7,7 +7,4 @@
/**
* @extends RepositoryInterface
*/
-interface AccountConfigurationRepositoryInterface extends RepositoryInterface
-{
-
-}
+interface AccountConfigurationRepositoryInterface extends RepositoryInterface {}
diff --git a/backend/app/Repository/Interfaces/AccountMessagingTierRepositoryInterface.php b/backend/app/Repository/Interfaces/AccountMessagingTierRepositoryInterface.php
index e2d175c9fe..c2ce1509b8 100644
--- a/backend/app/Repository/Interfaces/AccountMessagingTierRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/AccountMessagingTierRepositoryInterface.php
@@ -9,6 +9,4 @@
/**
* @extends RepositoryInterface
*/
-interface AccountMessagingTierRepositoryInterface extends RepositoryInterface
-{
-}
+interface AccountMessagingTierRepositoryInterface extends RepositoryInterface {}
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/AccountUserRepositoryInterface.php b/backend/app/Repository/Interfaces/AccountUserRepositoryInterface.php
index dbcebd1338..d2656b244c 100644
--- a/backend/app/Repository/Interfaces/AccountUserRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/AccountUserRepositoryInterface.php
@@ -9,6 +9,4 @@
/**
* @extends RepositoryInterface
*/
-interface AccountUserRepositoryInterface extends RepositoryInterface
-{
-}
+interface AccountUserRepositoryInterface extends RepositoryInterface {}
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/AffiliateRepositoryInterface.php b/backend/app/Repository/Interfaces/AffiliateRepositoryInterface.php
index b89362c0e7..b06f5bd99a 100644
--- a/backend/app/Repository/Interfaces/AffiliateRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/AffiliateRepositoryInterface.php
@@ -16,4 +16,6 @@ interface AffiliateRepositoryInterface extends RepositoryInterface
public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator;
public function incrementSales(int $affiliateId, float $amount): void;
+
+ public function decrementSales(int $affiliateId, float $amount): void;
}
diff --git a/backend/app/Repository/Interfaces/AttendeeCheckInRepositoryInterface.php b/backend/app/Repository/Interfaces/AttendeeCheckInRepositoryInterface.php
index 758265f2a0..c168bedc68 100644
--- a/backend/app/Repository/Interfaces/AttendeeCheckInRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/AttendeeCheckInRepositoryInterface.php
@@ -7,7 +7,4 @@
/**
* @extends RepositoryInterface
*/
-interface AttendeeCheckInRepositoryInterface extends RepositoryInterface
-{
-
-}
+interface AttendeeCheckInRepositoryInterface extends RepositoryInterface {}
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..fed07f8a97 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,31 @@ interface CheckInListRepositoryInterface extends RepositoryInterface
{
public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator;
- public function getCheckedInAttendeeCountById(int $checkInListId): CheckedInAttendeesCountDTO;
+ public function getCheckedInAttendeeCountById(
+ int $checkInListId,
+ ?int $eventOccurrenceIdOverride = null,
+ ): CheckedInAttendeesCountDTO;
/**
- * @param array $checkInListIds
- *
+ * @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/EmailTemplateRepositoryInterface.php b/backend/app/Repository/Interfaces/EmailTemplateRepositoryInterface.php
index 8f7de9c702..4e8cc4e1cd 100644
--- a/backend/app/Repository/Interfaces/EmailTemplateRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/EmailTemplateRepositoryInterface.php
@@ -41,4 +41,4 @@ public function findByTypeAndScope(
?int $eventId = null,
?int $organizerId = null
): ?EmailTemplateDomainObject;
-}
\ No newline at end of file
+}
diff --git a/backend/app/Repository/Interfaces/EventDailyStatisticRepositoryInterface.php b/backend/app/Repository/Interfaces/EventDailyStatisticRepositoryInterface.php
index e2e611980c..f1f80cb905 100644
--- a/backend/app/Repository/Interfaces/EventDailyStatisticRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/EventDailyStatisticRepositoryInterface.php
@@ -7,7 +7,4 @@
/**
* @extends RepositoryInterface
*/
-interface EventDailyStatisticRepositoryInterface extends RepositoryInterface
-{
-
-}
+interface EventDailyStatisticRepositoryInterface extends RepositoryInterface {}
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..6ae8a2d143
--- /dev/null
+++ b/backend/app/Repository/Interfaces/EventOccurrenceDailyStatisticRepositoryInterface.php
@@ -0,0 +1,10 @@
+
+ */
+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..fab4af6b4e
--- /dev/null
+++ b/backend/app/Repository/Interfaces/EventOccurrenceRepositoryInterface.php
@@ -0,0 +1,17 @@
+
+ */
+interface EventOccurrenceRepositoryInterface extends RepositoryInterface
+{
+ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator;
+
+ 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..ce3cc2b6d2
--- /dev/null
+++ b/backend/app/Repository/Interfaces/EventOccurrenceStatisticRepositoryInterface.php
@@ -0,0 +1,10 @@
+
+ */
+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/EventSettingsRepositoryInterface.php b/backend/app/Repository/Interfaces/EventSettingsRepositoryInterface.php
index c350fb8fca..ad5426279d 100644
--- a/backend/app/Repository/Interfaces/EventSettingsRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/EventSettingsRepositoryInterface.php
@@ -7,7 +7,4 @@
/**
* @extends RepositoryInterface
*/
-interface EventSettingsRepositoryInterface extends RepositoryInterface
-{
-
-}
+interface EventSettingsRepositoryInterface extends RepositoryInterface {}
diff --git a/backend/app/Repository/Interfaces/EventStatisticRepositoryInterface.php b/backend/app/Repository/Interfaces/EventStatisticRepositoryInterface.php
index c389929bb0..8ddcf8cbe1 100644
--- a/backend/app/Repository/Interfaces/EventStatisticRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/EventStatisticRepositoryInterface.php
@@ -7,7 +7,4 @@
/**
* @extends RepositoryInterface
*/
-interface EventStatisticRepositoryInterface extends RepositoryInterface
-{
-
-}
+interface EventStatisticRepositoryInterface extends RepositoryInterface {}
diff --git a/backend/app/Repository/Interfaces/ImageRepositoryInterface.php b/backend/app/Repository/Interfaces/ImageRepositoryInterface.php
index 04edc73e33..5b35bef640 100644
--- a/backend/app/Repository/Interfaces/ImageRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/ImageRepositoryInterface.php
@@ -7,7 +7,4 @@
/**
* @extends RepositoryInterface
*/
-interface ImageRepositoryInterface extends RepositoryInterface
-{
-
-}
+interface ImageRepositoryInterface extends RepositoryInterface {}
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/OrderApplicationFeeRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderApplicationFeeRepositoryInterface.php
index 82aa340a92..dc346e1dc5 100644
--- a/backend/app/Repository/Interfaces/OrderApplicationFeeRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/OrderApplicationFeeRepositoryInterface.php
@@ -7,7 +7,4 @@
/**
* @extends RepositoryInterface
*/
-interface OrderApplicationFeeRepositoryInterface extends RepositoryInterface
-{
-
-}
+interface OrderApplicationFeeRepositoryInterface extends RepositoryInterface {}
diff --git a/backend/app/Repository/Interfaces/OrderAuditLogRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderAuditLogRepositoryInterface.php
index b3a5bddecc..c49e88e034 100644
--- a/backend/app/Repository/Interfaces/OrderAuditLogRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/OrderAuditLogRepositoryInterface.php
@@ -9,7 +9,4 @@
/**
* @extends RepositoryInterface
*/
-interface OrderAuditLogRepositoryInterface extends RepositoryInterface
-{
-
-}
+interface OrderAuditLogRepositoryInterface extends RepositoryInterface {}
diff --git a/backend/app/Repository/Interfaces/OrderItemRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderItemRepositoryInterface.php
index 6360d893e9..6c3b623f0d 100644
--- a/backend/app/Repository/Interfaces/OrderItemRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/OrderItemRepositoryInterface.php
@@ -9,4 +9,5 @@
*/
interface OrderItemRepositoryInterface extends RepositoryInterface
{
+ public function getReservedQuantityForOccurrence(int $occurrenceId): int;
}
diff --git a/backend/app/Repository/Interfaces/OrderPaymentPlatformFeeRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderPaymentPlatformFeeRepositoryInterface.php
index 4f9cb1d4f6..038849fba8 100644
--- a/backend/app/Repository/Interfaces/OrderPaymentPlatformFeeRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/OrderPaymentPlatformFeeRepositoryInterface.php
@@ -7,7 +7,4 @@
/**
* @extends RepositoryInterface
*/
-interface OrderPaymentPlatformFeeRepositoryInterface extends RepositoryInterface
-{
-
-}
+interface OrderPaymentPlatformFeeRepositoryInterface extends RepositoryInterface {}
diff --git a/backend/app/Repository/Interfaces/OrderRefundRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderRefundRepositoryInterface.php
index 0c475af43e..2b2a2d3699 100644
--- a/backend/app/Repository/Interfaces/OrderRefundRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/OrderRefundRepositoryInterface.php
@@ -7,7 +7,4 @@
/**
* @extends RepositoryInterface
*/
-interface OrderRefundRepositoryInterface extends RepositoryInterface
-{
-
-}
+interface OrderRefundRepositoryInterface extends RepositoryInterface {}
diff --git a/backend/app/Repository/Interfaces/OrderRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderRepositoryInterface.php
index 0f6de621d2..f866063b61 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 countActivePromoCodeUsage(int $promoCodeId): int;
diff --git a/backend/app/Repository/Interfaces/OrganizerConfigurationRepositoryInterface.php b/backend/app/Repository/Interfaces/OrganizerConfigurationRepositoryInterface.php
new file mode 100644
index 0000000000..95b5abeec6
--- /dev/null
+++ b/backend/app/Repository/Interfaces/OrganizerConfigurationRepositoryInterface.php
@@ -0,0 +1,10 @@
+
+ */
+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/OrganizerSettingsRepositoryInterface.php b/backend/app/Repository/Interfaces/OrganizerSettingsRepositoryInterface.php
index dbb5301399..90d4577f59 100644
--- a/backend/app/Repository/Interfaces/OrganizerSettingsRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/OrganizerSettingsRepositoryInterface.php
@@ -7,7 +7,4 @@
/**
* @extends RepositoryInterface
*/
-interface OrganizerSettingsRepositoryInterface extends RepositoryInterface
-{
-
-}
+interface OrganizerSettingsRepositoryInterface extends RepositoryInterface {}
diff --git a/backend/app/Repository/Interfaces/OrganizerStripePlatformRepositoryInterface.php b/backend/app/Repository/Interfaces/OrganizerStripePlatformRepositoryInterface.php
new file mode 100644
index 0000000000..1c8d54c390
--- /dev/null
+++ b/backend/app/Repository/Interfaces/OrganizerStripePlatformRepositoryInterface.php
@@ -0,0 +1,19 @@
+
+ */
+interface OrganizerStripePlatformRepositoryInterface extends RepositoryInterface
+{
+ /**
+ * @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/OutgoingMessageRepositoryInterface.php b/backend/app/Repository/Interfaces/OutgoingMessageRepositoryInterface.php
index 21155af660..8be017406f 100644
--- a/backend/app/Repository/Interfaces/OutgoingMessageRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/OutgoingMessageRepositoryInterface.php
@@ -7,7 +7,4 @@
/**
* @extends RepositoryInterface
*/
-interface OutgoingMessageRepositoryInterface extends RepositoryInterface
-{
-
-}
+interface OutgoingMessageRepositoryInterface extends RepositoryInterface {}
diff --git a/backend/app/Repository/Interfaces/PasswordResetRepositoryInterface.php b/backend/app/Repository/Interfaces/PasswordResetRepositoryInterface.php
index a206ca5db8..e977352a25 100644
--- a/backend/app/Repository/Interfaces/PasswordResetRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/PasswordResetRepositoryInterface.php
@@ -7,7 +7,4 @@
/**
* @extends RepositoryInterface
*/
-interface PasswordResetRepositoryInterface extends RepositoryInterface
-{
-
-}
+interface PasswordResetRepositoryInterface extends RepositoryInterface {}
diff --git a/backend/app/Repository/Interfaces/PasswordResetTokenRepositoryInterface.php b/backend/app/Repository/Interfaces/PasswordResetTokenRepositoryInterface.php
index c8564fee5e..e78a524f9e 100644
--- a/backend/app/Repository/Interfaces/PasswordResetTokenRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/PasswordResetTokenRepositoryInterface.php
@@ -7,7 +7,4 @@
/**
* @extends RepositoryInterface
*/
-interface PasswordResetTokenRepositoryInterface extends RepositoryInterface
-{
-
-}
+interface PasswordResetTokenRepositoryInterface extends RepositoryInterface {}
diff --git a/backend/app/Repository/Interfaces/ProductOccurrenceVisibilityRepositoryInterface.php b/backend/app/Repository/Interfaces/ProductOccurrenceVisibilityRepositoryInterface.php
new file mode 100644
index 0000000000..817721d529
--- /dev/null
+++ b/backend/app/Repository/Interfaces/ProductOccurrenceVisibilityRepositoryInterface.php
@@ -0,0 +1,10 @@
+
+ */
+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..3c4f3f8089
--- /dev/null
+++ b/backend/app/Repository/Interfaces/ProductPriceOccurrenceOverrideRepositoryInterface.php
@@ -0,0 +1,10 @@
+
+ */
+interface ProductPriceOccurrenceOverrideRepositoryInterface extends RepositoryInterface {}
diff --git a/backend/app/Repository/Interfaces/ProductPriceRepositoryInterface.php b/backend/app/Repository/Interfaces/ProductPriceRepositoryInterface.php
index 55b74f91c9..4af87a3088 100644
--- a/backend/app/Repository/Interfaces/ProductPriceRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/ProductPriceRepositoryInterface.php
@@ -9,6 +9,4 @@
/**
* @extends RepositoryInterface
*/
-interface ProductPriceRepositoryInterface extends RepositoryInterface
-{
-}
+interface ProductPriceRepositoryInterface extends RepositoryInterface {}
diff --git a/backend/app/Repository/Interfaces/ProductRepositoryInterface.php b/backend/app/Repository/Interfaces/ProductRepositoryInterface.php
index 557ee19b56..97bc1be000 100644
--- a/backend/app/Repository/Interfaces/ProductRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/ProductRepositoryInterface.php
@@ -14,78 +14,26 @@
*/
interface ProductRepositoryInterface extends RepositoryInterface
{
- /**
- * @param int $eventId
- * @param QueryParamsDTO $params
- * @return LengthAwarePaginator
- */
public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator;
- /**
- * @param int $productId
- * @param int $productPriceId
- * @return int
- */
public function getQuantityRemainingForProductPrice(int $productId, int $productPriceId): int;
- /**
- * @param int $productId
- * @return Collection
- */
public function getTaxesByProductId(int $productId): Collection;
- /**
- * @param int $taxId
- * @return Collection
- */
public function getProductsByTaxId(int $taxId): Collection;
- /**
- * @param int $productId
- * @return Collection
- */
public function getCapacityAssignmentsByProductId(int $productId): Collection;
- /**
- * @param int $productId
- * @param array $taxIds
- * @return void
- */
public function addTaxesAndFeesToProduct(int $productId, array $taxIds): void;
- /**
- * @param array $productIds
- * @param int $capacityAssignmentId
- * @return void
- */
public function addCapacityAssignmentToProducts(int $capacityAssignmentId, array $productIds): void;
- /**
- * @param int $checkInListId
- * @param array $productIds
- * @return void
- */
public function addCheckInListToProducts(int $checkInListId, array $productIds): void;
- /**
- * @param int $checkInListId
- * @return void
- */
public function removeCheckInListFromProducts(int $checkInListId): void;
- /**
- * @param int $capacityAssignmentId
- * @return void
- */
public function removeCapacityAssignmentFromProducts(int $capacityAssignmentId): void;
-
- /**
- * @param int $eventId
- * @param array $productUpdates
- * @param array $categoryUpdates
- * @return void
- */
public function bulkUpdateProductsAndCategories(int $eventId, array $productUpdates, array $categoryUpdates): void;
public function hasAssociatedOrders(int $productId): bool;
diff --git a/backend/app/Repository/Interfaces/QuestionAndAnswerViewRepositoryInterface.php b/backend/app/Repository/Interfaces/QuestionAndAnswerViewRepositoryInterface.php
index bf8bc70012..6143fd9472 100644
--- a/backend/app/Repository/Interfaces/QuestionAndAnswerViewRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/QuestionAndAnswerViewRepositoryInterface.php
@@ -7,7 +7,4 @@
/**
* @extends RepositoryInterface
*/
-interface QuestionAndAnswerViewRepositoryInterface extends RepositoryInterface
-{
-
-}
+interface QuestionAndAnswerViewRepositoryInterface extends RepositoryInterface {}
diff --git a/backend/app/Repository/Interfaces/QuestionAnswerRepositoryInterface.php b/backend/app/Repository/Interfaces/QuestionAnswerRepositoryInterface.php
index 704996fc08..7e34c83a80 100644
--- a/backend/app/Repository/Interfaces/QuestionAnswerRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/QuestionAnswerRepositoryInterface.php
@@ -7,6 +7,4 @@
/**
* @extends RepositoryInterface
*/
-interface QuestionAnswerRepositoryInterface extends RepositoryInterface
-{
-}
+interface QuestionAnswerRepositoryInterface 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/StripeCustomerRepositoryInterface.php b/backend/app/Repository/Interfaces/StripeCustomerRepositoryInterface.php
index 2d586933b5..a2433435b2 100644
--- a/backend/app/Repository/Interfaces/StripeCustomerRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/StripeCustomerRepositoryInterface.php
@@ -7,7 +7,4 @@
/**
* @extends RepositoryInterface
*/
-interface StripeCustomerRepositoryInterface extends RepositoryInterface
-{
-
-}
+interface StripeCustomerRepositoryInterface extends RepositoryInterface {}
diff --git a/backend/app/Repository/Interfaces/StripePaymentsRepositoryInterface.php b/backend/app/Repository/Interfaces/StripePaymentsRepositoryInterface.php
index 8ad2e9a453..f7ed6bddfd 100644
--- a/backend/app/Repository/Interfaces/StripePaymentsRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/StripePaymentsRepositoryInterface.php
@@ -7,6 +7,4 @@
/**
* @extends RepositoryInterface
*/
-interface StripePaymentsRepositoryInterface extends RepositoryInterface
-{
-}
+interface StripePaymentsRepositoryInterface extends RepositoryInterface {}
diff --git a/backend/app/Repository/Interfaces/StripePayoutsRepositoryInterface.php b/backend/app/Repository/Interfaces/StripePayoutsRepositoryInterface.php
index 88655c2712..30f2234e82 100644
--- a/backend/app/Repository/Interfaces/StripePayoutsRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/StripePayoutsRepositoryInterface.php
@@ -7,6 +7,4 @@
/**
* @extends RepositoryInterface
*/
-interface StripePayoutsRepositoryInterface extends RepositoryInterface
-{
-}
+interface StripePayoutsRepositoryInterface extends RepositoryInterface {}
diff --git a/backend/app/Repository/Interfaces/TaxAndFeeRepositoryInterface.php b/backend/app/Repository/Interfaces/TaxAndFeeRepositoryInterface.php
index 4098248507..073e45d0b0 100644
--- a/backend/app/Repository/Interfaces/TaxAndFeeRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/TaxAndFeeRepositoryInterface.php
@@ -7,6 +7,4 @@
/**
* @extends RepositoryInterface
*/
-interface TaxAndFeeRepositoryInterface extends RepositoryInterface
-{
-}
+interface TaxAndFeeRepositoryInterface extends RepositoryInterface {}
diff --git a/backend/app/Repository/Interfaces/TicketLookupTokenRepositoryInterface.php b/backend/app/Repository/Interfaces/TicketLookupTokenRepositoryInterface.php
index 67f631b622..9ebd428e15 100644
--- a/backend/app/Repository/Interfaces/TicketLookupTokenRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/TicketLookupTokenRepositoryInterface.php
@@ -7,6 +7,4 @@
/**
* @extends RepositoryInterface
*/
-interface TicketLookupTokenRepositoryInterface extends RepositoryInterface
-{
-}
+interface TicketLookupTokenRepositoryInterface extends RepositoryInterface {}
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..4d73626072 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,21 +22,7 @@ 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(),
+ '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..aabbe42b6f 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..fbbb1672e6
--- /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(),
+ '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['email'] = $attendee->getEmail();
+ $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..fd87c58e24 100644
--- a/backend/app/Resources/Attendee/AttendeeResource.php
+++ b/backend/app/Resources/Attendee/AttendeeResource.php
@@ -5,9 +5,10 @@
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;
+use HiEvents\Resources\Question\QuestionAnswerViewResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -30,30 +31,34 @@ 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()),
+ ! is_null($this->getProduct()),
+ fn () => new ProductResource($this->getProduct()),
),
'check_ins' => $this->when(
condition: $this->getCheckIns() !== null,
- value: fn() => AttendeeCheckInResource::collection($this->getCheckIns()),
+ value: fn () => AttendeeCheckInResource::collection($this->getCheckIns()),
),
'order' => $this->when(
- condition: !is_null($this->getOrder()),
- value: fn() => new OrderResource($this->getOrder())
+ condition: ! is_null($this->getOrder()),
+ value: fn () => new OrderResource($this->getOrder())
),
'question_answers' => $this->when(
condition: $this->getQuestionAndAnswerViews() !== null,
- value: fn() => QuestionAnswerViewResource::collection(
+ value: fn () => QuestionAnswerViewResource::collection(
$this->getQuestionAndAnswerViews()
- ?->filter(fn($qav) => $qav->getBelongsTo() === QuestionBelongsTo::PRODUCT->name)
+ ?->filter(fn ($qav) => $qav->getBelongsTo() === QuestionBelongsTo::PRODUCT->name)
)
),
'created_at' => $this->getCreatedAt(),
'updated_at' => $this->getUpdatedAt(),
];
}
-
}
diff --git a/backend/app/Resources/Attendee/AttendeeResourcePublic.php b/backend/app/Resources/Attendee/AttendeeResourcePublic.php
index 1f4ffc1a36..00dfc5687b 100644
--- a/backend/app/Resources/Attendee/AttendeeResourcePublic.php
+++ b/backend/app/Resources/Attendee/AttendeeResourcePublic.php
@@ -3,6 +3,8 @@
namespace HiEvents\Resources\Attendee;
use HiEvents\DomainObjects\AttendeeDomainObject;
+use HiEvents\DomainObjects\Status\AttendeeStatus;
+use HiEvents\Resources\EventOccurrence\EventOccurrenceResourcePublic;
use HiEvents\Resources\Product\ProductMinimalResourcePublic;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -12,8 +14,16 @@
*/
class AttendeeResourcePublic extends JsonResource
{
+ public function __construct($resource, private readonly ?bool $includeOnlineConnectionDetails = null)
+ {
+ parent::__construct($resource);
+ }
+
public function toArray(Request $request): array
{
+ $includeOnlineConnectionDetails = $this->includeOnlineConnectionDetails
+ ?? ($this->getStatus() === AttendeeStatus::ACTIVE->name);
+
return [
'id' => $this->getId(),
'email' => $this->getEmail(),
@@ -24,7 +34,15 @@ public function toArray(Request $request): array
'short_id' => $this->getShortId(),
'product_id' => $this->getProductId(),
'product_price_id' => $this->getProductPriceId(),
- 'product' => $this->when((bool)$this->getProduct(), fn() => new ProductMinimalResourcePublic($this->getProduct())),
+ '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(),
+ includeOnlineConnectionDetails: $includeOnlineConnectionDetails,
+ ),
+ ),
'locale' => $this->getLocale(),
];
}
diff --git a/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php b/backend/app/Resources/Attendee/AttendeeWithCheckInPublicResource.php
index 4bca6bd835..805081fdc6 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;
@@ -24,6 +25,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/CapacityAssignment/CapacityAssignmentResource.php b/backend/app/Resources/CapacityAssignment/CapacityAssignmentResource.php
index c35c6ef3b3..37badb36fd 100644
--- a/backend/app/Resources/CapacityAssignment/CapacityAssignmentResource.php
+++ b/backend/app/Resources/CapacityAssignment/CapacityAssignmentResource.php
@@ -27,7 +27,7 @@ public function toArray(Request $request): array
$this->mergeWhen(
condition: $this->getProducts() !== null && $this->getAppliesTo() === CapacityAssignmentAppliesTo::PRODUCTS->name,
value: [
- 'products' => $this->getProducts()?->map(fn(ProductDomainObject $product) => [
+ 'products' => $this->getProducts()?->map(fn (ProductDomainObject $product) => [
'id' => $product->getId(),
'title' => $product->getTitle(),
]),
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..0337f03c1c 100644
--- a/backend/app/Resources/CheckInList/AttendeeCheckInResource.php
+++ b/backend/app/Resources/CheckInList/AttendeeCheckInResource.php
@@ -19,15 +19,16 @@ 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(
- !is_null($this->getCheckInList()),
- fn() => (new CheckInListResource($this->getCheckInList()))->toArray($request)
+ ! is_null($this->getCheckInList()),
+ fn () => (new CheckInListResource($this->getCheckInList()))->toArray($request)
),
'attendee' => $this->when(
- !is_null($this->getAttendee()),
- fn() => (new AttendeeResource($this->getAttendee()))->toArray($request)
+ ! is_null($this->getAttendee()),
+ fn () => (new AttendeeResource($this->getAttendee()))->toArray($request)
),
];
}
diff --git a/backend/app/Resources/CheckInList/CheckInListResource.php b/backend/app/Resources/CheckInList/CheckInListResource.php
index 744f947c70..9688b9c10d 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,13 +21,22 @@ 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(),
- $this->mergeWhen($this->getEvent() !== null, fn() => [
+ '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()),
]),
- $this->mergeWhen($this->getProducts() !== null, fn() => [
+ '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..b1f0b7a063 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,17 +20,33 @@ 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(),
- $this->mergeWhen($this->getEvent() !== null, fn() => [
+ '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()),
+ '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() => [
+ $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..87ab2e0c2c
--- /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..af10e71787 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,7 @@ class EventResourcePublic extends BaseResource
public function __construct(
mixed $resource,
mixed $includePostCheckoutData = false,
- )
- {
- // This is a hacky workaround to handle when this resource is instantiated
- // internally within Laravel the second param is the collection key (numeric)
- // When called normally, second param is includePostCheckoutData (boolean)
+ ) {
$this->includePostCheckoutData = is_bool($includePostCheckoutData)
? $includePostCheckoutData
: false;
@@ -34,6 +34,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 +44,58 @@ public function toArray(Request $request): array
'description_preview' => $this->getDescriptionPreview(),
'start_date' => $this->getStartDate(),
'end_date' => $this->getEndDate(),
+ 'next_occurrence_start_date' => $this->getNextOccurrenceStartDate(),
+ 'upcoming_occurrences_sold_out' => $this->getUpcomingOccurrencesSoldOut(),
+ 'last_occurrence_date' => $this->when($isRecurring, fn () => $this->getLastOccurrenceStartDate()),
+ 'occurrences_month' => $this->when($isRecurring, fn () => $this->getOccurrencesMonth()),
+ 'type' => $this->getType(),
'currency' => $this->getCurrency(),
'slug' => $this->getSlug(),
'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(),
+ value: function () use ($isRecurring) {
+ $showCapacity = $this->getEventSettings()?->getShowAvailableOccurrenceCapacity() ?? false;
+
+ return $this->getEventOccurrences()
+ ->filter(fn (EventOccurrenceDomainObject $occ) => ! $occ->isCancelled() && (! $isRecurring || ! $occ->isPast()))
+ ->sortBy(fn (EventOccurrenceDomainObject $occ) => $occ->getStartDate())
+ ->values()
+ ->map(fn (EventOccurrenceDomainObject $occ) => new EventOccurrenceResourcePublic($occ, $showCapacity));
+ },
),
];
}
diff --git a/backend/app/Resources/Event/EventSettingsResource.php b/backend/app/Resources/Event/EventSettingsResource.php
index c10b065be9..f54a52084b 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(),
@@ -83,6 +78,10 @@ public function toArray($request): array
// Self-service settings
'allow_attendee_self_edit' => $this->getAllowAttendeeSelfEdit(),
+ // Occurrence display
+ 'show_available_occurrence_capacity' => $this->getShowAvailableOccurrenceCapacity(),
+ 'hide_sold_out_occurrences' => $this->getHideSoldOutOccurrences(),
+
// Waitlist settings
'waitlist_auto_process' => $this->getWaitlistAutoProcess(),
'waitlist_offer_timeout_minutes' => $this->getWaitlistOfferTimeoutMinutes(),
diff --git a/backend/app/Resources/Event/EventSettingsResourcePublic.php b/backend/app/Resources/Event/EventSettingsResourcePublic.php
index 822d6a0cc3..0ee00a04a8 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(),
@@ -89,6 +84,10 @@ public function toArray($request): array
// Self-service settings
'allow_attendee_self_edit' => $this->getAllowAttendeeSelfEdit(),
+ // Occurrence display
+ 'show_available_occurrence_capacity' => $this->getShowAvailableOccurrenceCapacity(),
+ 'hide_sold_out_occurrences' => $this->getHideSoldOutOccurrences(),
+
// Waitlist settings
'waitlist_auto_process' => $this->getWaitlistAutoProcess(),
'waitlist_offer_timeout_minutes' => $this->getWaitlistOfferTimeoutMinutes(),
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..bee9a78f30
--- /dev/null
+++ b/backend/app/Resources/EventLocation/EventLocationResourcePublic.php
@@ -0,0 +1,44 @@
+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..868c556678
--- /dev/null
+++ b/backend/app/Resources/EventOccurrence/EventOccurrenceResource.php
@@ -0,0 +1,52 @@
+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(),
+ 'show_available_capacity' => $this->getShowAvailableCapacity(),
+ '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..131b32a819
--- /dev/null
+++ b/backend/app/Resources/EventOccurrence/EventOccurrenceResourcePublic.php
@@ -0,0 +1,47 @@
+shouldShowAvailableCapacity($this->eventLevelShowCapacity);
+
+ return [
+ 'id' => $this->getId(),
+ 'event_id' => $this->getEventId(),
+ 'start_date' => $this->getStartDate(),
+ 'end_date' => $this->getEndDate(),
+ 'status' => $this->getStatus(),
+ $this->mergeWhen($showCapacity, fn () => [
+ '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(), $this->includeOnlineConnectionDetails),
+ ),
+ ];
+ }
+}
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/GeoPlaceResource.php b/backend/app/Resources/Location/GeoPlaceResource.php
new file mode 100644
index 0000000000..f0c6c9d7da
--- /dev/null
+++ b/backend/app/Resources/Location/GeoPlaceResource.php
@@ -0,0 +1,24 @@
+ $this->provider,
+ 'provider_place_id' => $this->provider_place_id,
+ 'address' => $this->address->toArray(),
+ 'latitude' => $this->latitude,
+ 'longitude' => $this->longitude,
+ 'display_name' => $this->display_name,
+ ];
+ }
+}
diff --git a/backend/app/Resources/Location/GeoSuggestionResource.php b/backend/app/Resources/Location/GeoSuggestionResource.php
new file mode 100644
index 0000000000..cc3606bc0c
--- /dev/null
+++ b/backend/app/Resources/Location/GeoSuggestionResource.php
@@ -0,0 +1,21 @@
+ $this->provider_place_id,
+ 'primary_text' => $this->primary_text,
+ 'secondary_text' => $this->secondary_text,
+ ];
+ }
+}
diff --git a/backend/app/Resources/Location/LocationPublicResource.php b/backend/app/Resources/Location/LocationPublicResource.php
new file mode 100644
index 0000000000..6de4712b43
--- /dev/null
+++ b/backend/app/Resources/Location/LocationPublicResource.php
@@ -0,0 +1,25 @@
+ $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/Message/MessageResource.php b/backend/app/Resources/Message/MessageResource.php
index c4d28de423..359d030614 100644
--- a/backend/app/Resources/Message/MessageResource.php
+++ b/backend/app/Resources/Message/MessageResource.php
@@ -27,7 +27,7 @@ public function toArray(Request $request): array
'status' => $this->getStatus(),
'scheduled_at' => $this->getScheduledAt(),
'message_preview' => $this->getMessagePreview(),
- $this->mergeWhen(!is_null($this->getSentByUser()), fn() => [
+ $this->mergeWhen(! is_null($this->getSentByUser()), fn () => [
'sent_by_user' => new UserResource($this->getSentByUser()),
]),
];
diff --git a/backend/app/Resources/Order/OrderItemResource.php b/backend/app/Resources/Order/OrderItemResource.php
index 3199ca498b..71986b4459 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..163a847403 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;
@@ -12,6 +13,11 @@
*/
class OrderItemResourcePublic extends BaseResource
{
+ public function __construct($resource, private readonly bool $includeOnlineConnectionDetails = false)
+ {
+ parent::__construct($resource);
+ }
+
public function toArray(Request $request): array
{
return [
@@ -29,7 +35,15 @@ public function toArray(Request $request): array
'total_tax' => $this->getTotalTax(),
'total_gross' => $this->getTotalGross(),
'taxes_and_fees_rollup' => $this->getTaxesAndFeesRollup(),
- 'product' => $this->when((bool)$this->getProduct(), fn() => new ProductResourcePublic($this->getProduct())),
+ 'event_occurrence_id' => $this->getEventOccurrenceId(),
+ 'event_occurrence' => $this->when(
+ ! is_null($this->getEventOccurrence()),
+ fn () => new EventOccurrenceResourcePublic(
+ $this->getEventOccurrence(),
+ includeOnlineConnectionDetails: $this->includeOnlineConnectionDetails,
+ ),
+ ),
+ 'product' => $this->when((bool) $this->getProduct(), fn () => new ProductResourcePublic($this->getProduct())),
];
}
}
diff --git a/backend/app/Resources/Order/OrderResource.php b/backend/app/Resources/Order/OrderResource.php
index 32d9dadf85..a0af233ffb 100644
--- a/backend/app/Resources/Order/OrderResource.php
+++ b/backend/app/Resources/Order/OrderResource.php
@@ -44,20 +44,20 @@ public function toArray(Request $request): array
'promo_code' => $this->getPromoCode(),
'event_id' => $this->getEventId(),
'order_items' => $this->when(
- !is_null($this->getOrderItems()),
- fn() => OrderItemResource::collection($this->getOrderItems())
+ ! is_null($this->getOrderItems()),
+ fn () => OrderItemResource::collection($this->getOrderItems())
),
'attendees' => $this->when(
- !is_null($this->getAttendees()),
- fn() => AttendeeResource::collection($this->getAttendees())
+ ! is_null($this->getAttendees()),
+ fn () => AttendeeResource::collection($this->getAttendees())
),
'question_answers' => $this->when(
- !is_null($this->getQuestionAndAnswerViews()),
- fn() => QuestionAnswerViewResource::collection($this->getQuestionAndAnswerViews()),
+ ! is_null($this->getQuestionAndAnswerViews()),
+ fn () => QuestionAnswerViewResource::collection($this->getQuestionAndAnswerViews()),
),
'latest_invoice' => $this->when(
- !is_null($this->getLatestInvoice()),
- fn() => (new InvoiceResource($this->getLatestInvoice()))->toArray($request),
+ ! is_null($this->getLatestInvoice()),
+ fn () => (new InvoiceResource($this->getLatestInvoice()))->toArray($request),
),
];
}
diff --git a/backend/app/Resources/Order/OrderResourcePublic.php b/backend/app/Resources/Order/OrderResourcePublic.php
index 7880d22ed5..bb49863f6b 100644
--- a/backend/app/Resources/Order/OrderResourcePublic.php
+++ b/backend/app/Resources/Order/OrderResourcePublic.php
@@ -18,6 +18,8 @@ class OrderResourcePublic extends BaseResource
{
public function toArray(Request $request): array
{
+ $includePostCheckoutData = $this->getStatus() === OrderStatus::COMPLETED->name;
+
return [
'short_id' => $this->getShortId(),
'total_before_additions' => $this->getTotalBeforeAdditions(),
@@ -30,8 +32,8 @@ public function toArray(Request $request): array
'currency' => $this->getCurrency(),
'reserved_until' => $this->getReservedUntil(),
'is_expired' => $this->when(
- !is_null($this->getReservedUntil()),
- fn() => Carbon::createFromTimeString($this->getReservedUntil())->isPast(),
+ ! is_null($this->getReservedUntil()),
+ fn () => Carbon::createFromTimeString($this->getReservedUntil())->isPast(),
),
'first_name' => $this->getFirstName(),
'last_name' => $this->getLastName(),
@@ -41,29 +43,33 @@ public function toArray(Request $request): array
'promo_code' => $this->getPromoCode(),
'taxes_and_fees_rollup' => $this->getTaxesAndFeesRollup(),
'event' => $this->when(
- !is_null($this->getEvent()),
- fn() => new EventResourcePublic(
+ ! is_null($this->getEvent()),
+ fn () => new EventResourcePublic(
resource: $this->getEvent(),
- includePostCheckoutData: $this->getStatus() === OrderStatus::COMPLETED->name,
+ includePostCheckoutData: $includePostCheckoutData,
),
),
'latest_invoice' => $this->when(
- !is_null($this->getLatestInvoice()),
- fn() => (new InvoiceResourcePublic($this->getLatestInvoice()))->toArray($request),
+ ! is_null($this->getLatestInvoice()),
+ fn () => (new InvoiceResourcePublic($this->getLatestInvoice()))->toArray($request),
),
'address' => $this->when(
- !is_null($this->getAddress()),
- fn() => $this->getAddress()
+ ! is_null($this->getAddress()),
+ fn () => $this->getAddress()
),
'order_items' => $this->when(
- !is_null($this->getOrderItems()),
- fn() => OrderItemResourcePublic::collection($this->getOrderItems())
+ ! is_null($this->getOrderItems()),
+ fn () => $this->getOrderItems()->map(
+ fn ($orderItem) => new OrderItemResourcePublic($orderItem, $includePostCheckoutData),
+ )
),
'attendees' => $this->when(
- !is_null($this->getAttendees()),
- fn() => AttendeeResourcePublic::collection($this->getAttendees())
+ ! is_null($this->getAttendees()),
+ fn () => $this->getAttendees()->map(
+ fn ($attendee) => new AttendeeResourcePublic($attendee, $includePostCheckoutData),
+ )
),
- $this->mergeWhen($this->getSessionIdentifier() !== null, fn() => [
+ $this->mergeWhen($this->getSessionIdentifier() !== null, fn () => [
'session_identifier' => $this->getSessionIdentifier(),
]),
];
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())
+ (bool) $this->getImages(),
+ fn () => ImageResource::collection($this->getImages())
),
'settings' => $this->when(
- condition: !is_null($this->getOrganizerSettings()),
- value: fn() => new OrganizerSettingsResource($this->getOrganizerSettings())
+ 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/OrganizerSettingsPublicResource.php b/backend/app/Resources/Organizer/OrganizerSettingsPublicResource.php
index 80e0ab4c1e..a91172ea3b 100644
--- a/backend/app/Resources/Organizer/OrganizerSettingsPublicResource.php
+++ b/backend/app/Resources/Organizer/OrganizerSettingsPublicResource.php
@@ -19,10 +19,10 @@ public function toArray($request): array
$data['homepage_password'],
);
- if (config('app.saas_mode_enabled') && !empty($data['tracking_pixels'])) {
+ if (config('app.saas_mode_enabled') && ! empty($data['tracking_pixels'])) {
$data['tracking_pixels'] = array_values(array_filter(
$data['tracking_pixels'],
- fn($pixel) => ($pixel['provider'] ?? null) !== TrackingPixelProvider::GOOGLE_TAG_MANAGER->value,
+ fn ($pixel) => ($pixel['provider'] ?? null) !== TrackingPixelProvider::GOOGLE_TAG_MANAGER->value,
));
}
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/Product/ProductMinimalResourcePublic.php b/backend/app/Resources/Product/ProductMinimalResourcePublic.php
index daadaab8ba..f807080c06 100644
--- a/backend/app/Resources/Product/ProductMinimalResourcePublic.php
+++ b/backend/app/Resources/Product/ProductMinimalResourcePublic.php
@@ -19,8 +19,8 @@ public function toArray(Request $request): array
'type' => $this->getType(),
'event_id' => $this->getEventId(),
'prices' => $this->when(
- (bool)$this->getProductPrices(),
- fn() => ProductPriceResourcePublic::collection($this->getProductPrices()),
+ (bool) $this->getProductPrices(),
+ fn () => ProductPriceResourcePublic::collection($this->getProductPrices()),
),
'product_category_id' => $this->getProductCategoryId(),
];
diff --git a/backend/app/Resources/Product/ProductPriceResourcePublic.php b/backend/app/Resources/Product/ProductPriceResourcePublic.php
index da8d670fc3..45d8ac63df 100644
--- a/backend/app/Resources/Product/ProductPriceResourcePublic.php
+++ b/backend/app/Resources/Product/ProductPriceResourcePublic.php
@@ -23,14 +23,14 @@ public function toArray(Request $request): array
'sale_end_date' => $this->getSaleEndDate(),
'price_including_taxes_and_fees' => $this->getPriceIncludingTaxAndServiceFee(),
'price_before_discount' => $this->getPriceBeforeDiscount(),
- 'is_discounted' => (bool)$this->getPriceBeforeDiscount(),
+ 'is_discounted' => (bool) $this->getPriceBeforeDiscount(),
'tax_total' => $this->getTaxTotal(),
'fee_total' => $this->getFeeTotal(),
'is_before_sale_start_date' => $this->isBeforeSaleStartDate(),
'is_after_sale_end_date' => $this->isAfterSaleEndDate(),
'is_available' => $this->isAvailable(),
'is_sold_out' => $this->isSoldOut(),
- $this->mergeWhen($this->getAdditionalDataByKey(self::SHOW_QUANTITY_AVAILABLE), fn() => [
+ $this->mergeWhen($this->getAdditionalDataByKey(self::SHOW_QUANTITY_AVAILABLE), fn () => [
'quantity_remaining' => $this->getQuantityAvailable(),
]),
];
diff --git a/backend/app/Resources/Product/ProductResource.php b/backend/app/Resources/Product/ProductResource.php
index d172df6637..bc265f3ba6 100644
--- a/backend/app/Resources/Product/ProductResource.php
+++ b/backend/app/Resources/Product/ProductResource.php
@@ -28,7 +28,7 @@ public function toArray(Request $request): array
'description' => $this->getDescription(),
$this->mergeWhen(
condition: $this->getType() !== ProductPriceType::TIERED->name,
- value: fn() => [
+ value: fn () => [
'price' => $this->getPrice(),
]
),
@@ -49,16 +49,16 @@ public function toArray(Request $request): array
'is_before_sale_start_date' => $this->isBeforeSaleStartDate(),
'is_after_sale_end_date' => $this->isAfterSaleEndDate(),
'is_available' => $this->isAvailable(),
- $this->mergeWhen((bool)$this->getProductPrices(), fn() => [
+ $this->mergeWhen((bool) $this->getProductPrices(), fn () => [
'is_sold_out' => $this->isSoldOut(),
]),
'taxes_and_fees' => $this->when(
- (bool)$this->getTaxAndFees(),
- fn() => TaxAndFeeResource::collection($this->getTaxAndFees())
+ (bool) $this->getTaxAndFees(),
+ fn () => TaxAndFeeResource::collection($this->getTaxAndFees())
),
'prices' => $this->when(
- (bool)$this->getProductPrices(),
- fn() => ProductPriceResource::collection($this->getProductPrices())
+ (bool) $this->getProductPrices(),
+ fn () => ProductPriceResource::collection($this->getProductPrices())
),
'product_category_id' => $this->getProductCategoryId(),
'is_highlighted' => $this->getIsHighlighted(),
diff --git a/backend/app/Resources/Product/ProductResourcePublic.php b/backend/app/Resources/Product/ProductResourcePublic.php
index eec7789f1b..8ad91123a3 100644
--- a/backend/app/Resources/Product/ProductResourcePublic.php
+++ b/backend/app/Resources/Product/ProductResourcePublic.php
@@ -28,25 +28,25 @@ public function toArray(Request $request): array
'is_before_sale_start_date' => $this->isBeforeSaleStartDate(),
'is_after_sale_end_date' => $this->isAfterSaleEndDate(),
'start_collapsed' => $this->getStartCollapsed(),
- $this->mergeWhen($this->getShowQuantityRemaining(), fn() => [
+ $this->mergeWhen($this->getShowQuantityRemaining(), fn () => [
'quantity_available' => $this->getQuantityAvailable(),
]),
'price' => $this->when(
- $this->getProductPrices() && !$this->isTieredType(),
- fn() => $this->getPrice(),
+ $this->getProductPrices() && ! $this->isTieredType(),
+ fn () => $this->getPrice(),
),
'prices' => $this->when(
- (bool)$this->getProductPrices(),
- fn() => ProductPriceResourcePublic::collectionWithAdditionalData($this->getProductPrices(), [
+ (bool) $this->getProductPrices(),
+ fn () => ProductPriceResourcePublic::collectionWithAdditionalData($this->getProductPrices(), [
ProductPriceResourcePublic::SHOW_QUANTITY_AVAILABLE => $this->getShowQuantityRemaining(),
]),
),
// todo: this should be taxes_and_fees
'taxes' => $this->when(
- (bool)$this->getTaxAndFees(),
- fn() => TaxAndFeeResource::collection($this->getTaxAndFees())
+ (bool) $this->getTaxAndFees(),
+ fn () => TaxAndFeeResource::collection($this->getTaxAndFees())
),
- $this->mergeWhen((bool)$this->getProductPrices(), fn() => [
+ $this->mergeWhen((bool) $this->getProductPrices(), fn () => [
'is_available' => $this->isAvailable(),
'is_sold_out' => $this->isSoldOut(),
]),
diff --git a/backend/app/Resources/ProductCategory/ProductCategoryResource.php b/backend/app/Resources/ProductCategory/ProductCategoryResource.php
index 1d909545b2..3cae20ae6a 100644
--- a/backend/app/Resources/ProductCategory/ProductCategoryResource.php
+++ b/backend/app/Resources/ProductCategory/ProductCategoryResource.php
@@ -20,7 +20,7 @@ public function toArray($request): array
'is_hidden' => $this->getIsHidden(),
'order' => $this->getOrder(),
'no_products_message' => $this->getNoProductsMessage(),
- $this->mergeWhen((bool)$this->getProducts(), fn() => [
+ $this->mergeWhen((bool) $this->getProducts(), fn () => [
'products' => ProductResource::collection($this->getProducts()),
]),
];
diff --git a/backend/app/Resources/ProductCategory/ProductCategoryResourcePublic.php b/backend/app/Resources/ProductCategory/ProductCategoryResourcePublic.php
index bfa087a55c..a53f2c570e 100644
--- a/backend/app/Resources/ProductCategory/ProductCategoryResourcePublic.php
+++ b/backend/app/Resources/ProductCategory/ProductCategoryResourcePublic.php
@@ -20,7 +20,7 @@ public function toArray($request): array
'is_hidden' => $this->getIsHidden(),
'order' => $this->getOrder(),
'no_products_message' => $this->getNoProductsMessage(),
- $this->mergeWhen((bool)$this->getProducts(), fn() => [
+ $this->mergeWhen((bool) $this->getProducts(), fn () => [
'products' => ProductResourcePublic::collection($this->getProducts()),
]),
];
diff --git a/backend/app/Resources/PromoCode/PromoCodeResource.php b/backend/app/Resources/PromoCode/PromoCodeResource.php
index ec713886ad..b737d55aae 100644
--- a/backend/app/Resources/PromoCode/PromoCodeResource.php
+++ b/backend/app/Resources/PromoCode/PromoCodeResource.php
@@ -19,6 +19,7 @@ public function toArray(Request $request): array
'applicable_product_ids' => $this->getApplicableProductIds(),
'discount' => $this->getDiscount(),
'discount_type' => $this->getDiscountType(),
+ 'discount_applies_to' => $this->getDiscountAppliesTo(),
'created_at' => $this->getCreatedAt(),
'updated_at' => $this->getUpdatedAt(),
'expiry_date' => $this->getExpiryDate(),
diff --git a/backend/app/Resources/Question/QuestionAnswerViewResource.php b/backend/app/Resources/Question/QuestionAnswerViewResource.php
index d5b565c26a..9255e1bd0f 100644
--- a/backend/app/Resources/Question/QuestionAnswerViewResource.php
+++ b/backend/app/Resources/Question/QuestionAnswerViewResource.php
@@ -36,7 +36,7 @@ public function toArray(Request $request): array
$this->mergeWhen(
$this->getAttendeeId() !== null,
- fn() => [
+ fn () => [
'attendee_id' => $this->getAttendeeId(),
'first_name' => $this->getFirstName(),
'last_name' => $this->getLastName(),
diff --git a/backend/app/Resources/Question/QuestionResource.php b/backend/app/Resources/Question/QuestionResource.php
index 96bdf29651..1a9263aba3 100644
--- a/backend/app/Resources/Question/QuestionResource.php
+++ b/backend/app/Resources/Question/QuestionResource.php
@@ -24,8 +24,8 @@ public function toArray(Request $request): array
'belongs_to' => $this->getBelongsTo(),
'is_hidden' => $this->getIsHidden(),
'product_ids' => $this->when(
- !is_null($this->getProducts()),
- fn() => $this->getProducts()->map(fn($product) => $product->getId())
+ ! is_null($this->getProducts()),
+ fn () => $this->getProducts()->map(fn ($product) => $product->getId())
),
];
}
diff --git a/backend/app/Resources/Question/QuestionResourcePublic.php b/backend/app/Resources/Question/QuestionResourcePublic.php
index 296cae7322..4fc3b41419 100644
--- a/backend/app/Resources/Question/QuestionResourcePublic.php
+++ b/backend/app/Resources/Question/QuestionResourcePublic.php
@@ -24,8 +24,8 @@ public function toArray(Request $request): array
'belongs_to' => $this->getBelongsTo(),
'order' => $this->getOrder(),
'product_ids' => $this->when(
- !is_null($this->getProducts()),
- fn() => $this->getProducts()->map(fn($product) => $product->getId())
+ ! is_null($this->getProducts()),
+ fn () => $this->getProducts()->map(fn ($product) => $product->getId())
),
];
}
diff --git a/backend/app/Resources/User/AdminUserResource.php b/backend/app/Resources/User/AdminUserResource.php
index 432832487c..409107a9c4 100644
--- a/backend/app/Resources/User/AdminUserResource.php
+++ b/backend/app/Resources/User/AdminUserResource.php
@@ -24,7 +24,7 @@ public function toArray(Request $request): array
'id' => $this->resource->id,
'first_name' => $this->resource->first_name,
'last_name' => $this->resource->last_name,
- 'full_name' => $this->resource->first_name . ' ' . $this->resource->last_name,
+ 'full_name' => $this->resource->first_name.' '.$this->resource->last_name,
'email' => $this->resource->email,
'timezone' => $this->resource->timezone,
'locale' => $this->resource->locale,
diff --git a/backend/app/Resources/User/UserResource.php b/backend/app/Resources/User/UserResource.php
index 279bb15def..6dbbc8c64e 100644
--- a/backend/app/Resources/User/UserResource.php
+++ b/backend/app/Resources/User/UserResource.php
@@ -38,10 +38,10 @@ public function toArray(Request $request): array
'is_impersonating' => true,
'impersonator_id' => $impersonatorId,
]),
- $this->mergeWhen(config('app.enforce_email_confirmation_during_registration'), fn() => [
+ $this->mergeWhen(config('app.enforce_email_confirmation_during_registration'), fn () => [
'enforce_email_confirmation_during_registration' => true,
]),
- $this->mergeWhen($this->getCurrentAccountUser() !== null, fn() => [
+ $this->mergeWhen($this->getCurrentAccountUser() !== null, fn () => [
'role' => $this->getCurrentAccountUser()?->getRole(),
'is_account_owner' => $this->getCurrentAccountUser()?->getIsAccountOwner(),
'last_login_at' => $this->getCurrentAccountUser()?->getLastLoginAt(),
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/CreateAccountHandler.php b/backend/app/Services/Application/Handlers/Account/CreateAccountHandler.php
index bc5d89e350..86e39df589 100644
--- a/backend/app/Services/Application/Handlers/Account/CreateAccountHandler.php
+++ b/backend/app/Services/Application/Handlers/Account/CreateAccountHandler.php
@@ -30,20 +30,18 @@
class CreateAccountHandler
{
public function __construct(
- private readonly UserRepositoryInterface $userRepository,
- private readonly AccountRepositoryInterface $accountRepository,
- private readonly HashManager $hashManager,
- private readonly DatabaseManager $databaseManager,
- private readonly Repository $config,
- private readonly EmailConfirmationService $emailConfirmationService,
- private readonly AccountUserAssociationService $accountUserAssociationService,
- private readonly AccountUserRepositoryInterface $accountUserRepository,
+ private readonly UserRepositoryInterface $userRepository,
+ private readonly AccountRepositoryInterface $accountRepository,
+ private readonly HashManager $hashManager,
+ private readonly DatabaseManager $databaseManager,
+ private readonly Repository $config,
+ private readonly EmailConfirmationService $emailConfirmationService,
+ private readonly AccountUserAssociationService $accountUserAssociationService,
+ private readonly AccountUserRepositoryInterface $accountUserRepository,
private readonly AccountConfigurationRepositoryInterface $accountConfigurationRepository,
- private readonly AccountAttributionRepositoryInterface $accountAttributionRepository,
- private readonly LoggerInterface $logger,
- )
- {
- }
+ private readonly AccountAttributionRepositoryInterface $accountAttributionRepository,
+ private readonly LoggerInterface $logger,
+ ) {}
/**
* @throws Throwable
@@ -51,17 +49,17 @@ public function __construct(
public function handle(CreateAccountDTO $accountData): AccountDomainObject
{
if ($this->config->get('app.disable_registration')) {
- throw new AccountRegistrationDisabledException();
+ throw new AccountRegistrationDisabledException;
}
$isSaasMode = $this->config->get('app.saas_mode_enabled');
- $passwordHash = $this->hashManager->make($accountData->password);;
+ $passwordHash = $this->hashManager->make($accountData->password);
return $this->databaseManager->transaction(function () use ($isSaasMode, $passwordHash, $accountData) {
$account = $this->accountRepository->create([
'timezone' => $this->getTimezone($accountData),
'currency_code' => $this->getCurrencyCode($accountData),
- 'name' => $accountData->first_name . ($accountData->last_name ? ' ' . $accountData->last_name : ''),
+ 'name' => $accountData->first_name.($accountData->last_name ? ' '.$accountData->last_name : ''),
'email' => strtolower($accountData->email),
'short_id' => IdHelper::shortId(IdHelper::ACCOUNT_PREFIX),
'account_verified_at' => $isSaasMode ? null : now()->toDateTimeString(),
@@ -235,7 +233,7 @@ private function classifySourceType(CreateAccountDTO $data): string
return 'paid';
}
- if ($data->referrer_url !== null && !$this->isInternalReferrer($data->referrer_url)) {
+ if ($data->referrer_url !== null && ! $this->isInternalReferrer($data->referrer_url)) {
return 'referral';
}
diff --git a/backend/app/Services/Application/Handlers/Account/DTO/CreateAccountDTO.php b/backend/app/Services/Application/Handlers/Account/DTO/CreateAccountDTO.php
index e8363970e6..675ca73616 100644
--- a/backend/app/Services/Application/Handlers/Account/DTO/CreateAccountDTO.php
+++ b/backend/app/Services/Application/Handlers/Account/DTO/CreateAccountDTO.php
@@ -7,9 +7,9 @@
final class CreateAccountDTO extends BaseDTO
{
public function __construct(
- public readonly string $email,
- public readonly string $password,
- public readonly string $first_name,
+ public readonly string $email,
+ public readonly string $password,
+ public readonly string $first_name,
public readonly string $locale,
public readonly ?string $last_name = null,
public readonly ?string $timezone = null,
@@ -26,7 +26,5 @@ public function __construct(
public readonly ?string $gclid = null,
public readonly ?string $fbclid = null,
public readonly ?array $utm_raw = null,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Account/DTO/UpdateAccountDTO.php b/backend/app/Services/Application/Handlers/Account/DTO/UpdateAccountDTO.php
index d65cbe4752..ac0f8f0fe7 100644
--- a/backend/app/Services/Application/Handlers/Account/DTO/UpdateAccountDTO.php
+++ b/backend/app/Services/Application/Handlers/Account/DTO/UpdateAccountDTO.php
@@ -12,7 +12,5 @@ public function __construct(
public readonly string $name,
public readonly string $currency_code,
public readonly string $timezone,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Account/Exceptions/AccountConfigurationDoesNotExist.php b/backend/app/Services/Application/Handlers/Account/Exceptions/AccountConfigurationDoesNotExist.php
index 7c0504f9a1..9bfce7712a 100644
--- a/backend/app/Services/Application/Handlers/Account/Exceptions/AccountConfigurationDoesNotExist.php
+++ b/backend/app/Services/Application/Handlers/Account/Exceptions/AccountConfigurationDoesNotExist.php
@@ -4,7 +4,4 @@
use Exception;
-class AccountConfigurationDoesNotExist extends Exception
-{
-
-}
+class AccountConfigurationDoesNotExist extends Exception {}
diff --git a/backend/app/Services/Application/Handlers/Account/Exceptions/AccountRegistrationDisabledException.php b/backend/app/Services/Application/Handlers/Account/Exceptions/AccountRegistrationDisabledException.php
index be8318d4bd..4c3af1ec33 100644
--- a/backend/app/Services/Application/Handlers/Account/Exceptions/AccountRegistrationDisabledException.php
+++ b/backend/app/Services/Application/Handlers/Account/Exceptions/AccountRegistrationDisabledException.php
@@ -4,7 +4,4 @@
use Exception;
-class AccountRegistrationDisabledException extends Exception
-{
-
-}
+class AccountRegistrationDisabledException extends Exception {}
diff --git a/backend/app/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandler.php b/backend/app/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandler.php
deleted file mode 100644
index 4f1819e25c..0000000000
--- a/backend/app/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandler.php
+++ /dev/null
@@ -1,176 +0,0 @@
-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->createOrGetStripeConnectAccount($command));
- }
-
- /**
- * @throws CreateStripeConnectAccountFailedException|CreateStripeConnectAccountLinksFailedException
- * @throws StripeClientConfigurationException
- */
- private function createOrGetStripeConnectAccount(CreateStripeConnectAccountDTO $command): CreateStripeConnectAccountResponse
- {
- $account = $this->accountRepository
- ->loadRelation(AccountStripePlatformDomainObject::class)
- ->findById($command->accountId);
-
- // 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);
-
- // 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,
- stripeClient: $stripeClient,
- platform: $platformToUse,
- );
-
- $response = new CreateStripeConnectAccountResponse(
- stripeConnectAccountType: $stripeConnectAccount->type,
- stripeAccountId: $stripeConnectAccount->id,
- account: $account,
- 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);
- }
-
- return $response;
- }
-
- $connectUrl = $this->stripeAccountSyncService->createStripeAccountSetupUrl($stripeConnectAccount, $stripeClient);
- if ($connectUrl === null) {
- throw new CreateStripeConnectAccountLinksFailedException(
- message: __('There are issues with creating the Stripe Connect Account Link. Please try again.'),
- );
- }
-
- $response->connectUrl = $connectUrl;
-
- return $response;
- }
-
- /**
- * @throws CreateStripeConnectAccountFailedException
- */
- private function getOrCreateStripeConnectAccount(
- AccountDomainObject $account,
- ?AccountStripePlatformDomainObject $accountStripePlatform,
- StripeClient $stripeClient,
- ?StripePlatform $platform
- ): Account
- {
- try {
- if ($accountStripePlatform && $accountStripePlatform->getStripeAccountId() !== null) {
- return $stripeClient->accounts->retrieve($accountStripePlatform->getStripeAccountId());
- }
-
- $stripeAccount = $stripeClient->accounts->create([
- 'type' => $this->config->get('app.stripe_connect_account_type')
- ?? StripeConnectAccountType::EXPRESS->value,
- ]);
- } 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',
- 'platform' => $platform?->value ?? 'null',
- 'exception' => $e,
- ]);
-
- throw new CreateStripeConnectAccountFailedException(
- message: __('There are issues with creating or fetching the Stripe Connect Account. Please try again.'),
- previous: $e,
- );
- }
-
- // 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,
- ]);
- } else {
- $this->accountStripePlatformRepository->updateWhere(
- attributes: [
- AccountStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id,
- AccountStripePlatformDomainObjectAbstract::STRIPE_CONNECT_ACCOUNT_TYPE => $stripeAccount->type,
- ],
- where: [
- 'id' => $accountStripePlatform->getId(),
- ]
- );
- }
-
- return $stripeAccount;
- }
-
-
-}
diff --git a/backend/app/Services/Application/Handlers/Account/Payment/Stripe/DTO/CreateStripeConnectAccountDTO.php b/backend/app/Services/Application/Handlers/Account/Payment/Stripe/DTO/CreateStripeConnectAccountDTO.php
deleted file mode 100644
index c6693f60a5..0000000000
--- a/backend/app/Services/Application/Handlers/Account/Payment/Stripe/DTO/CreateStripeConnectAccountDTO.php
+++ /dev/null
@@ -1,16 +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/GetAccountVatSettingHandler.php b/backend/app/Services/Application/Handlers/Account/Vat/GetAccountVatSettingHandler.php
deleted file mode 100644
index 9e70b59285..0000000000
--- a/backend/app/Services/Application/Handlers/Account/Vat/GetAccountVatSettingHandler.php
+++ /dev/null
@@ -1,21 +0,0 @@
-vatSettingRepository->findByAccountId($accountId);
- }
-}
diff --git a/backend/app/Services/Application/Handlers/Admin/ApproveMessageHandler.php b/backend/app/Services/Application/Handlers/Admin/ApproveMessageHandler.php
index 0e46928dd6..f12ef32f03 100644
--- a/backend/app/Services/Application/Handlers/Admin/ApproveMessageHandler.php
+++ b/backend/app/Services/Application/Handlers/Admin/ApproveMessageHandler.php
@@ -17,11 +17,9 @@ class ApproveMessageHandler
{
public function __construct(
private readonly MessageRepositoryInterface $messageRepository,
- private readonly DatabaseManager $databaseManager,
- private readonly MessageDispatchService $messageDispatchService,
- )
- {
- }
+ private readonly DatabaseManager $databaseManager,
+ private readonly MessageDispatchService $messageDispatchService,
+ ) {}
public function handle(int $messageId): MessageDomainObject
{
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/DTO/AdminDashboardResponseDTO.php b/backend/app/Services/Application/Handlers/Admin/DTO/AdminDashboardResponseDTO.php
index 37407f886c..d54d004376 100644
--- a/backend/app/Services/Application/Handlers/Admin/DTO/AdminDashboardResponseDTO.php
+++ b/backend/app/Services/Application/Handlers/Admin/DTO/AdminDashboardResponseDTO.php
@@ -15,6 +15,5 @@ public function __construct(
public readonly int $recent_orders_count,
public readonly float $recent_orders_total,
public readonly int $recent_signups_count,
- ) {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Admin/DTO/GetAdminDashboardDataDTO.php b/backend/app/Services/Application/Handlers/Admin/DTO/GetAdminDashboardDataDTO.php
index b6428f3549..a6ec04aec3 100644
--- a/backend/app/Services/Application/Handlers/Admin/DTO/GetAdminDashboardDataDTO.php
+++ b/backend/app/Services/Application/Handlers/Admin/DTO/GetAdminDashboardDataDTO.php
@@ -9,6 +9,5 @@ class GetAdminDashboardDataDTO extends BaseDataObject
public function __construct(
public readonly int $days = 14,
public readonly int $limit = 10,
- ) {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Admin/DTO/GetAdminStatsDTO.php b/backend/app/Services/Application/Handlers/Admin/DTO/GetAdminStatsDTO.php
index 9b0ae04caa..2436290dae 100644
--- a/backend/app/Services/Application/Handlers/Admin/DTO/GetAdminStatsDTO.php
+++ b/backend/app/Services/Application/Handlers/Admin/DTO/GetAdminStatsDTO.php
@@ -11,7 +11,5 @@ public function __construct(
public readonly int $total_accounts,
public readonly int $total_live_events,
public readonly int $total_tickets_sold,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Admin/DTO/GetAllAccountsDTO.php b/backend/app/Services/Application/Handlers/Admin/DTO/GetAllAccountsDTO.php
index 22cf407182..98ae4abc3e 100644
--- a/backend/app/Services/Application/Handlers/Admin/DTO/GetAllAccountsDTO.php
+++ b/backend/app/Services/Application/Handlers/Admin/DTO/GetAllAccountsDTO.php
@@ -7,9 +7,7 @@
class GetAllAccountsDTO extends BaseDataObject
{
public function __construct(
- public readonly int $perPage = 20,
+ public readonly int $perPage = 20,
public readonly ?string $search = null,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Admin/DTO/GetAllEventsDTO.php b/backend/app/Services/Application/Handlers/Admin/DTO/GetAllEventsDTO.php
index bafc752152..253ea30189 100644
--- a/backend/app/Services/Application/Handlers/Admin/DTO/GetAllEventsDTO.php
+++ b/backend/app/Services/Application/Handlers/Admin/DTO/GetAllEventsDTO.php
@@ -3,16 +3,13 @@
namespace HiEvents\Services\Application\Handlers\Admin\DTO;
use HiEvents\DataTransferObjects\BaseDataObject;
-use HiEvents\DomainObjects\Generated\EventDomainObjectAbstract;
class GetAllEventsDTO extends BaseDataObject
{
public function __construct(
- public readonly int $perPage = 20,
+ public readonly int $perPage = 20,
public readonly ?string $search = null,
- public readonly ?string $sortBy = EventDomainObjectAbstract::START_DATE,
+ public readonly ?string $sortBy = 'start_date',
public readonly ?string $sortDirection = 'desc',
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Admin/DTO/GetAllFailedJobsDTO.php b/backend/app/Services/Application/Handlers/Admin/DTO/GetAllFailedJobsDTO.php
index dd2d31de3b..40343bd2e9 100644
--- a/backend/app/Services/Application/Handlers/Admin/DTO/GetAllFailedJobsDTO.php
+++ b/backend/app/Services/Application/Handlers/Admin/DTO/GetAllFailedJobsDTO.php
@@ -12,6 +12,5 @@ public function __construct(
public readonly ?string $queue = null,
public readonly ?string $sortBy = 'failed_at',
public readonly ?string $sortDirection = 'desc',
- ) {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Admin/DTO/GetAllMessagesForAdminDTO.php b/backend/app/Services/Application/Handlers/Admin/DTO/GetAllMessagesForAdminDTO.php
index 70583621e3..d967b21b6f 100644
--- a/backend/app/Services/Application/Handlers/Admin/DTO/GetAllMessagesForAdminDTO.php
+++ b/backend/app/Services/Application/Handlers/Admin/DTO/GetAllMessagesForAdminDTO.php
@@ -13,6 +13,5 @@ public function __construct(
public readonly ?string $type = null,
public readonly ?string $sortBy = 'created_at',
public readonly ?string $sortDirection = 'desc',
- ) {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Admin/DTO/GetAllOrdersDTO.php b/backend/app/Services/Application/Handlers/Admin/DTO/GetAllOrdersDTO.php
index dc3f6f9134..525f625bc2 100644
--- a/backend/app/Services/Application/Handlers/Admin/DTO/GetAllOrdersDTO.php
+++ b/backend/app/Services/Application/Handlers/Admin/DTO/GetAllOrdersDTO.php
@@ -8,11 +8,9 @@
class GetAllOrdersDTO extends BaseDataObject
{
public function __construct(
- public readonly int $perPage = 20,
+ public readonly int $perPage = 20,
public readonly ?string $search = null,
public readonly ?string $sortBy = OrderDomainObjectAbstract::CREATED_AT,
public readonly ?string $sortDirection = 'desc',
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Admin/DTO/GetAllUsersDTO.php b/backend/app/Services/Application/Handlers/Admin/DTO/GetAllUsersDTO.php
index 55a48a7285..e0217d9c14 100644
--- a/backend/app/Services/Application/Handlers/Admin/DTO/GetAllUsersDTO.php
+++ b/backend/app/Services/Application/Handlers/Admin/DTO/GetAllUsersDTO.php
@@ -7,9 +7,7 @@
class GetAllUsersDTO extends BaseDataObject
{
public function __construct(
- public readonly int $perPage = 20,
+ public readonly int $perPage = 20,
public readonly ?string $search = null,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Admin/DTO/GetUpcomingEventsDTO.php b/backend/app/Services/Application/Handlers/Admin/DTO/GetUpcomingEventsDTO.php
index cad0764d4c..0cd5965340 100644
--- a/backend/app/Services/Application/Handlers/Admin/DTO/GetUpcomingEventsDTO.php
+++ b/backend/app/Services/Application/Handlers/Admin/DTO/GetUpcomingEventsDTO.php
@@ -8,7 +8,5 @@ class GetUpcomingEventsDTO extends BaseDataObject
{
public function __construct(
public readonly int $perPage = 20,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Admin/DTO/GetUtmAttributionStatsDTO.php b/backend/app/Services/Application/Handlers/Admin/DTO/GetUtmAttributionStatsDTO.php
index d5634c70e6..b2978c3882 100644
--- a/backend/app/Services/Application/Handlers/Admin/DTO/GetUtmAttributionStatsDTO.php
+++ b/backend/app/Services/Application/Handlers/Admin/DTO/GetUtmAttributionStatsDTO.php
@@ -12,7 +12,5 @@ public function __construct(
public readonly ?string $date_to = null,
public readonly int $per_page = 20,
public readonly int $page = 1,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Admin/DTO/StartImpersonationDTO.php b/backend/app/Services/Application/Handlers/Admin/DTO/StartImpersonationDTO.php
index 0dc1f83cc0..95a0f77a43 100644
--- a/backend/app/Services/Application/Handlers/Admin/DTO/StartImpersonationDTO.php
+++ b/backend/app/Services/Application/Handlers/Admin/DTO/StartImpersonationDTO.php
@@ -10,7 +10,5 @@ public function __construct(
public readonly int $userId,
public readonly int $accountId,
public readonly int $impersonatorId,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Admin/DTO/StopImpersonationDTO.php b/backend/app/Services/Application/Handlers/Admin/DTO/StopImpersonationDTO.php
index 440f31b8f2..57eca87361 100644
--- a/backend/app/Services/Application/Handlers/Admin/DTO/StopImpersonationDTO.php
+++ b/backend/app/Services/Application/Handlers/Admin/DTO/StopImpersonationDTO.php
@@ -8,7 +8,5 @@ class StopImpersonationDTO extends BaseDataObject
{
public function __construct(
public readonly int $impersonatorId,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Admin/DeleteConfigurationHandler.php b/backend/app/Services/Application/Handlers/Admin/DeleteConfigurationHandler.php
index 77822d298f..c70f926e86 100644
--- a/backend/app/Services/Application/Handlers/Admin/DeleteConfigurationHandler.php
+++ b/backend/app/Services/Application/Handlers/Admin/DeleteConfigurationHandler.php
@@ -5,14 +5,15 @@
namespace HiEvents\Services\Application\Handlers\Admin;
use HiEvents\Exceptions\CannotDeleteEntityException;
-use HiEvents\Repository\Interfaces\AccountConfigurationRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrganizerConfigurationRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface;
class DeleteConfigurationHandler
{
public function __construct(
- private readonly AccountConfigurationRepositoryInterface $repository,
- ) {
- }
+ private readonly OrganizerConfigurationRepositoryInterface $repository,
+ private readonly OrganizerRepositoryInterface $organizerRepository,
+ ) {}
/**
* @throws CannotDeleteEntityException
@@ -27,6 +28,18 @@ public function handle(int $configurationId): void
);
}
+ $assignedOrganizerCount = $this->organizerRepository->countWhere([
+ 'organizer_configuration_id' => $configurationId,
+ ]);
+
+ if ($assignedOrganizerCount > 0) {
+ throw new CannotDeleteEntityException(
+ __('This plan is still assigned to :count organizer(s). Reassign them before deleting it.', [
+ 'count' => $assignedOrganizerCount,
+ ])
+ );
+ }
+
$this->repository->deleteById($configurationId);
}
}
diff --git a/backend/app/Services/Application/Handlers/Admin/GetAccountHandler.php b/backend/app/Services/Application/Handlers/Admin/GetAccountHandler.php
index 530099fb3b..06f42ddac0 100644
--- a/backend/app/Services/Application/Handlers/Admin/GetAccountHandler.php
+++ b/backend/app/Services/Application/Handlers/Admin/GetAccountHandler.php
@@ -8,9 +8,7 @@ class GetAccountHandler
{
public function __construct(
private readonly AccountRepositoryInterface $accountRepository,
- )
- {
- }
+ ) {}
public function handle(int $accountId)
{
diff --git a/backend/app/Services/Application/Handlers/Admin/GetAdminDashboardDataHandler.php b/backend/app/Services/Application/Handlers/Admin/GetAdminDashboardDataHandler.php
index 591838b14a..fb5a2cea03 100644
--- a/backend/app/Services/Application/Handlers/Admin/GetAdminDashboardDataHandler.php
+++ b/backend/app/Services/Application/Handlers/Admin/GetAdminDashboardDataHandler.php
@@ -31,7 +31,7 @@ public function handle(GetAdminDashboardDataDTO $dto): AdminDashboardResponseDTO
private function getPopularEvents(Carbon $since, int $limit): array
{
- $query = <<= :since
@@ -160,12 +168,12 @@ private function getRecentRevenue(Carbon $since): float
$result = DB::selectOne($query, ['since' => $since]);
- return (float)($result->total_revenue ?? 0);
+ return (float) ($result->total_revenue ?? 0);
}
private function getRecentOrdersCount(Carbon $since): int
{
- $query = <<= :since
@@ -180,12 +188,12 @@ private function getRecentOrdersCount(Carbon $since): int
'paymentStatusPaid' => OrderPaymentStatus::PAYMENT_RECEIVED->name,
]);
- return (int)($result->count ?? 0);
+ return (int) ($result->count ?? 0);
}
private function getRecentOrdersTotal(Carbon $since): float
{
- $query = <<= :since
@@ -200,12 +208,12 @@ private function getRecentOrdersTotal(Carbon $since): float
'paymentStatusPaid' => OrderPaymentStatus::PAYMENT_RECEIVED->name,
]);
- return (float)($result->total ?? 0);
+ return (float) ($result->total ?? 0);
}
private function getRecentSignupsCount(Carbon $since): int
{
- $query = <<= :since
@@ -214,6 +222,6 @@ private function getRecentSignupsCount(Carbon $since): int
$result = DB::selectOne($query, ['since' => $since]);
- return (int)($result->count ?? 0);
+ return (int) ($result->count ?? 0);
}
}
diff --git a/backend/app/Services/Application/Handlers/Admin/GetAdminStatsHandler.php b/backend/app/Services/Application/Handlers/Admin/GetAdminStatsHandler.php
index 84f94dfebf..bcb93d0717 100644
--- a/backend/app/Services/Application/Handlers/Admin/GetAdminStatsHandler.php
+++ b/backend/app/Services/Application/Handlers/Admin/GetAdminStatsHandler.php
@@ -13,13 +13,11 @@
class GetAdminStatsHandler
{
public function __construct(
- private readonly UserRepositoryInterface $userRepository,
- private readonly AccountRepositoryInterface $accountRepository,
- private readonly EventRepositoryInterface $eventRepository,
+ private readonly UserRepositoryInterface $userRepository,
+ private readonly AccountRepositoryInterface $accountRepository,
+ private readonly EventRepositoryInterface $eventRepository,
private readonly AttendeeRepositoryInterface $attendeeRepository,
- )
- {
- }
+ ) {}
public function handle(): GetAdminStatsDTO
{
diff --git a/backend/app/Services/Application/Handlers/Admin/GetAllAccountsHandler.php b/backend/app/Services/Application/Handlers/Admin/GetAllAccountsHandler.php
index 18b8688192..7f3a3cd36e 100644
--- a/backend/app/Services/Application/Handlers/Admin/GetAllAccountsHandler.php
+++ b/backend/app/Services/Application/Handlers/Admin/GetAllAccountsHandler.php
@@ -10,9 +10,7 @@ class GetAllAccountsHandler
{
public function __construct(
private readonly AccountRepositoryInterface $accountRepository,
- )
- {
- }
+ ) {}
public function handle(GetAllAccountsDTO $dto): LengthAwarePaginator
{
diff --git a/backend/app/Services/Application/Handlers/Admin/GetAllEventsHandler.php b/backend/app/Services/Application/Handlers/Admin/GetAllEventsHandler.php
index 436e1081c3..db21424906 100644
--- a/backend/app/Services/Application/Handlers/Admin/GetAllEventsHandler.php
+++ b/backend/app/Services/Application/Handlers/Admin/GetAllEventsHandler.php
@@ -10,9 +10,7 @@ class GetAllEventsHandler
{
public function __construct(
private readonly EventRepositoryInterface $eventRepository,
- )
- {
- }
+ ) {}
public function handle(GetAllEventsDTO $dto): LengthAwarePaginator
{
diff --git a/backend/app/Services/Application/Handlers/Admin/GetAllFailedJobsHandler.php b/backend/app/Services/Application/Handlers/Admin/GetAllFailedJobsHandler.php
index 84c94dd9d7..ad9c30a7d5 100644
--- a/backend/app/Services/Application/Handlers/Admin/GetAllFailedJobsHandler.php
+++ b/backend/app/Services/Application/Handlers/Admin/GetAllFailedJobsHandler.php
@@ -15,7 +15,7 @@ public function handle(GetAllFailedJobsDTO $dto): LengthAwarePaginator
$query = FailedJob::query();
if ($dto->search) {
- $searchTerm = '%' . $dto->search . '%';
+ $searchTerm = '%'.$dto->search.'%';
$query->where(function ($q) use ($searchTerm) {
$q->where('payload', 'ilike', $searchTerm)
->orWhere('exception', 'ilike', $searchTerm);
diff --git a/backend/app/Services/Application/Handlers/Admin/GetAllMessagesForAdminHandler.php b/backend/app/Services/Application/Handlers/Admin/GetAllMessagesForAdminHandler.php
index 6fea384b04..6f2158d0eb 100644
--- a/backend/app/Services/Application/Handlers/Admin/GetAllMessagesForAdminHandler.php
+++ b/backend/app/Services/Application/Handlers/Admin/GetAllMessagesForAdminHandler.php
@@ -27,7 +27,7 @@ public function handle(GetAllMessagesForAdminDTO $dto): LengthAwarePaginator
->whereNull('messages.deleted_at');
if ($dto->search) {
- $searchTerm = '%' . $dto->search . '%';
+ $searchTerm = '%'.$dto->search.'%';
$query->where(function ($q) use ($searchTerm) {
$q->where('messages.subject', 'ilike', $searchTerm)
->orWhere('events.title', 'ilike', $searchTerm)
@@ -46,7 +46,7 @@ public function handle(GetAllMessagesForAdminDTO $dto): LengthAwarePaginator
$sortColumn = in_array($dto->sortBy, self::ALLOWED_SORT_COLUMNS, true) ? $dto->sortBy : 'created_at';
$sortDirection = in_array(strtolower($dto->sortDirection ?? 'desc'), ['asc', 'desc']) ? $dto->sortDirection : 'desc';
- $query->orderBy('messages.' . $sortColumn, $sortDirection);
+ $query->orderBy('messages.'.$sortColumn, $sortDirection);
return $query->paginate($dto->perPage);
}
diff --git a/backend/app/Services/Application/Handlers/Admin/GetAllOrdersHandler.php b/backend/app/Services/Application/Handlers/Admin/GetAllOrdersHandler.php
index f2f2e961fb..8b9eddc766 100644
--- a/backend/app/Services/Application/Handlers/Admin/GetAllOrdersHandler.php
+++ b/backend/app/Services/Application/Handlers/Admin/GetAllOrdersHandler.php
@@ -10,9 +10,7 @@ class GetAllOrdersHandler
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
- )
- {
- }
+ ) {}
public function handle(GetAllOrdersDTO $dto): LengthAwarePaginator
{
diff --git a/backend/app/Services/Application/Handlers/Admin/GetAllUsersHandler.php b/backend/app/Services/Application/Handlers/Admin/GetAllUsersHandler.php
index 86332d726f..c145a39ba4 100644
--- a/backend/app/Services/Application/Handlers/Admin/GetAllUsersHandler.php
+++ b/backend/app/Services/Application/Handlers/Admin/GetAllUsersHandler.php
@@ -10,9 +10,7 @@ class GetAllUsersHandler
{
public function __construct(
private readonly UserRepositoryInterface $userRepository,
- )
- {
- }
+ ) {}
public function handle(GetAllUsersDTO $dto): LengthAwarePaginator
{
diff --git a/backend/app/Services/Application/Handlers/Admin/GetUpcomingEventsHandler.php b/backend/app/Services/Application/Handlers/Admin/GetUpcomingEventsHandler.php
index 5ce6d3ef45..f05f58b710 100644
--- a/backend/app/Services/Application/Handlers/Admin/GetUpcomingEventsHandler.php
+++ b/backend/app/Services/Application/Handlers/Admin/GetUpcomingEventsHandler.php
@@ -10,9 +10,7 @@ class GetUpcomingEventsHandler
{
public function __construct(
private readonly EventRepositoryInterface $eventRepository,
- )
- {
- }
+ ) {}
public function handle(GetUpcomingEventsDTO $dto): LengthAwarePaginator
{
diff --git a/backend/app/Services/Application/Handlers/Admin/GetUtmAttributionStatsHandler.php b/backend/app/Services/Application/Handlers/Admin/GetUtmAttributionStatsHandler.php
index bdd31036b8..d56195e927 100644
--- a/backend/app/Services/Application/Handlers/Admin/GetUtmAttributionStatsHandler.php
+++ b/backend/app/Services/Application/Handlers/Admin/GetUtmAttributionStatsHandler.php
@@ -9,9 +9,7 @@ class GetUtmAttributionStatsHandler
{
public function __construct(
private readonly AccountAttributionRepositoryInterface $attributionRepository,
- )
- {
- }
+ ) {}
public function handle(GetUtmAttributionStatsDTO $dto): array
{
diff --git a/backend/app/Services/Application/Handlers/Admin/Organizer/AssignOrganizerConfigurationHandler.php b/backend/app/Services/Application/Handlers/Admin/Organizer/AssignOrganizerConfigurationHandler.php
new file mode 100644
index 0000000000..3bb7d5d40b
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Admin/Organizer/AssignOrganizerConfigurationHandler.php
@@ -0,0 +1,30 @@
+configurationRepository->findById($configurationId);
+
+ $this->organizerRepository->updateFromArray(
+ id: $organizerId,
+ attributes: ['organizer_configuration_id' => $configurationId],
+ );
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/Admin/Organizer/UpdateAdminOrganizerVatSettingHandler.php b/backend/app/Services/Application/Handlers/Admin/Organizer/UpdateAdminOrganizerVatSettingHandler.php
new file mode 100644
index 0000000000..1332320883
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Admin/Organizer/UpdateAdminOrganizerVatSettingHandler.php
@@ -0,0 +1,44 @@
+vatSettingRepository->findByOrganizerId($dto->organizerId);
+
+ $vatValidated = $dto->vatValidated ?? false;
+
+ $data = [
+ 'organizer_id' => $dto->organizerId,
+ 'vat_registered' => $dto->vatRegistered,
+ 'vat_number' => $dto->vatNumber,
+ 'vat_validated' => $vatValidated,
+ 'vat_validation_status' => $vatValidated
+ ? VatValidationStatus::VALID->value
+ : VatValidationStatus::PENDING->value,
+ 'business_name' => $dto->businessName,
+ 'business_address' => $dto->businessAddress,
+ 'vat_country_code' => $dto->vatCountryCode,
+ ];
+
+ if ($existing) {
+ return $this->vatSettingRepository->updateFromArray(
+ id: $existing->getId(),
+ attributes: $data
+ );
+ }
+
+ return $this->vatSettingRepository->create($data);
+ }
+}
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..8830f480cc
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Admin/Organizer/UpdateOrganizerConfigurationHandler.php
@@ -0,0 +1,70 @@
+organizerRepository
+ ->loadRelation(new Relationship(
+ domainObject: OrganizerConfigurationDomainObject::class,
+ name: 'organizer_configuration',
+ ))
+ ->findById($dto->organizerId);
+
+ $currentConfiguration = $organizer->getOrganizerConfiguration();
+
+ 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;
+ }
+
+ $ownReference = $this->organizerRepository->countWhere([
+ 'organizer_configuration_id' => $configurationId,
+ 'id' => $organizerId,
+ ]);
+
+ return $ownReference === 1;
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/Admin/RetryFailedJobHandler.php b/backend/app/Services/Application/Handlers/Admin/RetryFailedJobHandler.php
index 42767b46c6..f54d0da5a6 100644
--- a/backend/app/Services/Application/Handlers/Admin/RetryFailedJobHandler.php
+++ b/backend/app/Services/Application/Handlers/Admin/RetryFailedJobHandler.php
@@ -11,7 +11,7 @@ public function handle(int $id): bool
{
$job = FailedJob::find($id);
- if (!$job) {
+ if (! $job) {
return false;
}
diff --git a/backend/app/Services/Application/Handlers/Admin/StartImpersonationHandler.php b/backend/app/Services/Application/Handlers/Admin/StartImpersonationHandler.php
index 2bf7f84ee7..2d8630043e 100644
--- a/backend/app/Services/Application/Handlers/Admin/StartImpersonationHandler.php
+++ b/backend/app/Services/Application/Handlers/Admin/StartImpersonationHandler.php
@@ -13,10 +13,8 @@ class StartImpersonationHandler
{
public function __construct(
private readonly AccountUserRepositoryInterface $accountUserRepository,
- private readonly AuthManager $authManager,
- )
- {
- }
+ private readonly AuthManager $authManager,
+ ) {}
/**
* @throws UnauthorizedException
@@ -28,14 +26,14 @@ public function handle(StartImpersonationDTO $dto): string
$accountUser = $this->accountUserRepository->findFirstWhere([
'user_id' => $targetUser->id,
- 'account_id' => $dto->accountId
+ 'account_id' => $dto->accountId,
]);
- if (!$accountUser) {
+ if (! $accountUser) {
throw new UnauthorizedException(__('User does not belong to this account'));
}
- if (!$this->authManager->user()?->canImpersonate() || $accountUser->getRole() === Role::SUPERADMIN->name) {
+ if (! $this->authManager->user()?->canImpersonate() || $accountUser->getRole() === Role::SUPERADMIN->name) {
throw new UnauthorizedException(__('Impersonation not allowed'));
}
diff --git a/backend/app/Services/Application/Handlers/Admin/StopImpersonationHandler.php b/backend/app/Services/Application/Handlers/Admin/StopImpersonationHandler.php
index 63762a8157..64c4ec7fda 100644
--- a/backend/app/Services/Application/Handlers/Admin/StopImpersonationHandler.php
+++ b/backend/app/Services/Application/Handlers/Admin/StopImpersonationHandler.php
@@ -10,9 +10,7 @@ class StopImpersonationHandler
{
public function __construct(
private readonly AuthManager $authManager,
- )
- {
- }
+ ) {}
public function handle(StopImpersonationDTO $dto): string
{
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/Admin/UpdateAccountMessagingTierHandler.php b/backend/app/Services/Application/Handlers/Admin/UpdateAccountMessagingTierHandler.php
index eb79878051..a878e04fea 100644
--- a/backend/app/Services/Application/Handlers/Admin/UpdateAccountMessagingTierHandler.php
+++ b/backend/app/Services/Application/Handlers/Admin/UpdateAccountMessagingTierHandler.php
@@ -11,8 +11,7 @@ class UpdateAccountMessagingTierHandler
{
public function __construct(
private readonly AccountRepositoryInterface $accountRepository,
- ) {
- }
+ ) {}
public function handle(int $accountId, int $tierId): AccountDomainObject
{
diff --git a/backend/app/Services/Application/Handlers/Admin/UpdateAdminAccountVatSettingHandler.php b/backend/app/Services/Application/Handlers/Admin/UpdateAdminAccountVatSettingHandler.php
deleted file mode 100644
index 4971856bf8..0000000000
--- a/backend/app/Services/Application/Handlers/Admin/UpdateAdminAccountVatSettingHandler.php
+++ /dev/null
@@ -1,40 +0,0 @@
-vatSettingRepository->findByAccountId($dto->accountId);
-
- $data = [
- 'account_id' => $dto->accountId,
- 'vat_registered' => $dto->vatRegistered,
- 'vat_number' => $dto->vatNumber,
- 'vat_validated' => $dto->vatValidated ?? false,
- 'business_name' => $dto->businessName,
- 'business_address' => $dto->businessAddress,
- 'vat_country_code' => $dto->vatCountryCode,
- ];
-
- if ($existing) {
- return $this->vatSettingRepository->updateFromArray(
- id: $existing->getId(),
- attributes: $data
- );
- }
-
- return $this->vatSettingRepository->create($data);
- }
-}
diff --git a/backend/app/Services/Application/Handlers/Affiliate/CreateAffiliateHandler.php b/backend/app/Services/Application/Handlers/Affiliate/CreateAffiliateHandler.php
index db10006798..d0c6412c33 100644
--- a/backend/app/Services/Application/Handlers/Affiliate/CreateAffiliateHandler.php
+++ b/backend/app/Services/Application/Handlers/Affiliate/CreateAffiliateHandler.php
@@ -13,9 +13,7 @@ class CreateAffiliateHandler
{
public function __construct(
private readonly AffiliateRepositoryInterface $affiliateRepository,
- )
- {
- }
+ ) {}
/**
* @throws ResourceConflictException
diff --git a/backend/app/Services/Application/Handlers/Affiliate/DTO/UpsertAffiliateDTO.php b/backend/app/Services/Application/Handlers/Affiliate/DTO/UpsertAffiliateDTO.php
index 0e6f71b9c5..5acac453dc 100644
--- a/backend/app/Services/Application/Handlers/Affiliate/DTO/UpsertAffiliateDTO.php
+++ b/backend/app/Services/Application/Handlers/Affiliate/DTO/UpsertAffiliateDTO.php
@@ -16,6 +16,5 @@ public function __construct(
public AffiliateStatus $status = AffiliateStatus::ACTIVE,
public ?int $event_id = null,
public ?int $account_id = null,
- ) {
- }
-}
\ No newline at end of file
+ ) {}
+}
diff --git a/backend/app/Services/Application/Handlers/Affiliate/DeleteAffiliateHandler.php b/backend/app/Services/Application/Handlers/Affiliate/DeleteAffiliateHandler.php
index 945dac32d4..8df2a3dd79 100644
--- a/backend/app/Services/Application/Handlers/Affiliate/DeleteAffiliateHandler.php
+++ b/backend/app/Services/Application/Handlers/Affiliate/DeleteAffiliateHandler.php
@@ -11,20 +11,19 @@ class DeleteAffiliateHandler
{
public function __construct(
private readonly AffiliateRepositoryInterface $affiliateRepository,
- ) {
- }
+ ) {}
public function handle(int $affiliateId, int $eventId): void
{
$affiliate = $this->affiliateRepository->findFirstWhere([
'id' => $affiliateId,
- 'event_id' => $eventId
+ 'event_id' => $eventId,
]);
- if (!$affiliate) {
+ if (! $affiliate) {
throw new NotFoundHttpException(__('Affiliate not found'));
}
$this->affiliateRepository->deleteById($affiliateId);
}
-}
\ No newline at end of file
+}
diff --git a/backend/app/Services/Application/Handlers/Affiliate/UpdateAffiliateHandler.php b/backend/app/Services/Application/Handlers/Affiliate/UpdateAffiliateHandler.php
index 15f9010173..3a2bd1252f 100644
--- a/backend/app/Services/Application/Handlers/Affiliate/UpdateAffiliateHandler.php
+++ b/backend/app/Services/Application/Handlers/Affiliate/UpdateAffiliateHandler.php
@@ -13,18 +13,16 @@ class UpdateAffiliateHandler
{
public function __construct(
private readonly AffiliateRepositoryInterface $affiliateRepository,
- )
- {
- }
+ ) {}
public function handle(int $affiliateId, int $eventId, UpsertAffiliateDTO $dto): AffiliateDomainObject
{
$existingAffiliate = $this->affiliateRepository->findFirstWhere([
'id' => $affiliateId,
- 'event_id' => $eventId
+ 'event_id' => $eventId,
]);
- if (!$existingAffiliate) {
+ if (! $existingAffiliate) {
throw new NotFoundHttpException(__('Affiliate not found'));
}
@@ -32,7 +30,7 @@ public function handle(int $affiliateId, int $eventId, UpsertAffiliateDTO $dto):
'name' => $dto->name,
'email' => $dto->email,
'status' => $dto->status->value,
- ], static fn($value) => $value !== null);
+ ], static fn ($value) => $value !== null);
return $this->affiliateRepository->updateFromArray($affiliateId, $updateData);
}
diff --git a/backend/app/Services/Application/Handlers/Attendee/CheckInAttendeeHandler.php b/backend/app/Services/Application/Handlers/Attendee/CheckInAttendeeHandler.php
index e11b6e172d..9a74b7bd13 100644
--- a/backend/app/Services/Application/Handlers/Attendee/CheckInAttendeeHandler.php
+++ b/backend/app/Services/Application/Handlers/Attendee/CheckInAttendeeHandler.php
@@ -18,11 +18,9 @@ class CheckInAttendeeHandler
{
public function __construct(
private readonly AttendeeRepositoryInterface $attendeeRepository,
- private readonly UserRepositoryInterface $userRepository,
- private readonly LoggerInterface $logger,
- )
- {
- }
+ private readonly UserRepositoryInterface $userRepository,
+ private readonly LoggerInterface $logger,
+ ) {}
/**
* @throws CannotCheckInException
@@ -49,8 +47,8 @@ private function fetchAttendee(CheckInAttendeeDTO $checkInAttendeeDTO): Attendee
$attendee = $this->attendeeRepository->findFirstWhere($criteria);
- if (!$attendee) {
- throw new ResourceNotFoundException();
+ if (! $attendee) {
+ throw new ResourceNotFoundException;
}
return $attendee;
@@ -90,7 +88,7 @@ private function validateAction(AttendeeDomainObject $attendee, CheckInAttendeeD
throw new CannotCheckInException(
__(
- "Cannot check :actionName attendee as they were already checked :actionName by :fullName :time.",
+ 'Cannot check :actionName attendee as they were already checked :actionName by :fullName :time.',
[
'actionName' => $actionName,
'fullName' => $user->getFullName(),
@@ -125,7 +123,7 @@ private function updateCheckInStatus(CheckInAttendeeDTO $checkInAttendeeDTO): vo
$this->attendeeRepository->updateWhere($updateData, $criteria);
$this->logger->info(
- 'Attendee checked ' . $checkInAttendeeDTO->action . ' by user ' . $checkInAttendeeDTO->checked_in_by_user_id,
+ 'Attendee checked '.$checkInAttendeeDTO->action.' by user '.$checkInAttendeeDTO->checked_in_by_user_id,
[
'attendee_public_id' => $checkInAttendeeDTO->attendee_public_id,
'event_id' => $checkInAttendeeDTO->event_id,
diff --git a/backend/app/Services/Application/Handlers/Attendee/CreateAttendeeHandler.php b/backend/app/Services/Application/Handlers/Attendee/CreateAttendeeHandler.php
index 4ca0d841a0..2245af1cfe 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,19 @@ public function __construct(
*/
public function handle(CreateAttendeeDTO $attendeeDTO): AttendeeDomainObject
{
+ $attendeeDTO = $this->resolveOccurrenceId($attendeeDTO);
+
+ $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 +93,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 +105,34 @@ 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);
+ $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 +167,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 +189,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 +197,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 +220,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)
);
}
@@ -214,17 +244,19 @@ private function createOrderItem(CreateAttendeeDTO $attendeeDTO, OrderDomainObje
OrderItemDomainObjectAbstract::ORDER_ID => $order->getId(),
OrderItemDomainObjectAbstract::ITEM_NAME => $product->getTitle(),
OrderItemDomainObjectAbstract::PRODUCT_PRICE_ID => $productPriceId,
+ OrderItemDomainObjectAbstract::PRODUCT_TYPE => $product->getProductType(),
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 +264,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 +288,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/CheckInAttendeeDTO.php b/backend/app/Services/Application/Handlers/Attendee/DTO/CheckInAttendeeDTO.php
index 60c75ae99e..d0869d6814 100644
--- a/backend/app/Services/Application/Handlers/Attendee/DTO/CheckInAttendeeDTO.php
+++ b/backend/app/Services/Application/Handlers/Attendee/DTO/CheckInAttendeeDTO.php
@@ -8,10 +8,8 @@ class CheckInAttendeeDTO extends BaseDTO
{
public function __construct(
public string $attendee_public_id,
- public int $event_id,
+ public int $event_id,
public string $action,
- public int $checked_in_by_user_id,
- )
- {
- }
+ public int $checked_in_by_user_id,
+ ) {}
}
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/DTO/CreateAttendeeTaxAndFeeDTO.php b/backend/app/Services/Application/Handlers/Attendee/DTO/CreateAttendeeTaxAndFeeDTO.php
index a0e6553cb0..297aab6fba 100644
--- a/backend/app/Services/Application/Handlers/Attendee/DTO/CreateAttendeeTaxAndFeeDTO.php
+++ b/backend/app/Services/Application/Handlers/Attendee/DTO/CreateAttendeeTaxAndFeeDTO.php
@@ -7,9 +7,7 @@
class CreateAttendeeTaxAndFeeDTO extends BaseDTO
{
public function __construct(
- public readonly int $tax_or_fee_id,
+ public readonly int $tax_or_fee_id,
public readonly float $amount,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Attendee/DTO/EditAttendeeDTO.php b/backend/app/Services/Application/Handlers/Attendee/DTO/EditAttendeeDTO.php
index 155c602bc3..94c4b18075 100644
--- a/backend/app/Services/Application/Handlers/Attendee/DTO/EditAttendeeDTO.php
+++ b/backend/app/Services/Application/Handlers/Attendee/DTO/EditAttendeeDTO.php
@@ -7,15 +7,13 @@
class EditAttendeeDTO extends BaseDTO
{
public function __construct(
- public string $first_name,
- public string $last_name,
- public string $email,
- public int $product_id,
- public int $product_price_id,
- public int $event_id,
- public int $attendee_id,
+ public string $first_name,
+ public string $last_name,
+ public string $email,
+ public int $product_id,
+ public int $product_price_id,
+ public int $event_id,
+ public int $attendee_id,
public ?string $notes = null,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Attendee/DTO/PartialEditAttendeeDTO.php b/backend/app/Services/Application/Handlers/Attendee/DTO/PartialEditAttendeeDTO.php
index ef91e99822..4f0189e718 100644
--- a/backend/app/Services/Application/Handlers/Attendee/DTO/PartialEditAttendeeDTO.php
+++ b/backend/app/Services/Application/Handlers/Attendee/DTO/PartialEditAttendeeDTO.php
@@ -14,7 +14,5 @@ public function __construct(
public ?string $last_name,
public ?string $email,
public ?string $status,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Attendee/DTO/ResendAttendeeTicketDTO.php b/backend/app/Services/Application/Handlers/Attendee/DTO/ResendAttendeeTicketDTO.php
index 282110648d..59bc078e94 100644
--- a/backend/app/Services/Application/Handlers/Attendee/DTO/ResendAttendeeTicketDTO.php
+++ b/backend/app/Services/Application/Handlers/Attendee/DTO/ResendAttendeeTicketDTO.php
@@ -9,7 +9,5 @@ class ResendAttendeeTicketDTO extends BaseDTO
public function __construct(
public int $attendeeId,
public int $eventId,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php b/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php
index f7dd85f02a..efada53416 100644
--- a/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php
+++ b/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php
@@ -25,14 +25,12 @@
class EditAttendeeHandler
{
public function __construct(
- private readonly AttendeeRepositoryInterface $attendeeRepository,
- private readonly ProductRepositoryInterface $productRepository,
+ private readonly AttendeeRepositoryInterface $attendeeRepository,
+ private readonly ProductRepositoryInterface $productRepository,
private readonly ProductQuantityUpdateService $productQuantityService,
- private readonly DatabaseManager $databaseManager,
+ private readonly DatabaseManager $databaseManager,
private readonly DomainEventDispatcherService $domainEventDispatcherService,
- )
- {
- }
+ ) {}
/**
* @throws ValidationException
@@ -63,14 +61,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(),
));
}
}
@@ -96,8 +95,7 @@ private function updateAttendee(EditAttendeeDTO $editAttendeeDTO): AttendeeDomai
private function validateProductId(
EditAttendeeDTO $editAttendeeDTO,
AttendeeDomainObject $attendee,
- ): void
- {
+ ): void {
/** @var ProductDomainObject $product */
$product = $this->productRepository
->loadRelation(ProductPriceDomainObject::class)
@@ -111,8 +109,8 @@ private function validateProductId(
]);
}
- $productPriceIds = $product->getProductPrices()->map(fn($productPrice) => $productPrice->getId())->toArray();
- if (!in_array($editAttendeeDTO->product_price_id, $productPriceIds, true)) {
+ $productPriceIds = $product->getProductPrices()->map(fn ($productPrice) => $productPrice->getId())->toArray();
+ if (! in_array($editAttendeeDTO->product_price_id, $productPriceIds, true)) {
throw ValidationException::withMessages([
'product_price_id' => __('Product price ID is not valid'),
]);
diff --git a/backend/app/Services/Application/Handlers/Attendee/GetAttendeesHandler.php b/backend/app/Services/Application/Handlers/Attendee/GetAttendeesHandler.php
index d8e5881ebf..a1ede76f1a 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;
@@ -13,9 +14,7 @@ class GetAttendeesHandler
{
public function __construct(
private readonly AttendeeRepositoryInterface $attendeeRepository,
- )
- {
- }
+ ) {}
public function handle(int $eventId, QueryParamsDTO $queryParams): LengthAwarePaginator
{
@@ -28,6 +27,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..9adb879ab9 100644
--- a/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php
+++ b/backend/app/Services/Application/Handlers/Attendee/PartialEditAttendeeHandler.php
@@ -23,17 +23,15 @@
class PartialEditAttendeeHandler
{
public function __construct(
- private readonly AttendeeRepositoryInterface $attendeeRepository,
- private readonly OrderRepositoryInterface $orderRepository,
- private readonly ProductQuantityUpdateService $productQuantityService,
- private readonly DatabaseManager $databaseManager,
- private readonly DomainEventDispatcherService $domainEventDispatcherService,
+ private readonly AttendeeRepositoryInterface $attendeeRepository,
+ private readonly OrderRepositoryInterface $orderRepository,
+ private readonly ProductQuantityUpdateService $productQuantityService,
+ private readonly DatabaseManager $databaseManager,
+ private readonly DomainEventDispatcherService $domainEventDispatcherService,
private readonly EventStatisticsCancellationService $eventStatisticsCancellationService,
private readonly EventStatisticsReactivationService $eventStatisticsReactivationService,
- private readonly LoggerInterface $logger,
- )
- {
- }
+ private readonly LoggerInterface $logger,
+ ) {}
/**
* @throws Throwable|ResourceNotFoundException
@@ -52,8 +50,8 @@ private function updateAttendee(PartialEditAttendeeDTO $data): AttendeeDomainObj
'event_id' => $data->event_id,
]);
- if (!$attendee) {
- throw new ResourceNotFoundException();
+ if (! $attendee) {
+ throw new ResourceNotFoundException;
}
$statusIsUpdated = $data->status && $data->status !== $attendee->getStatus();
@@ -93,22 +91,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(),
));
}
}
@@ -131,18 +131,21 @@ private function adjustEventStatistics(PartialEditAttendeeDTO $data, AttendeeDom
'order_id' => $attendee->getOrderId(),
'event_id' => $attendee->getEventId(),
]);
+
return;
}
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..b2110b40e3 100644
--- a/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php
+++ b/backend/app/Services/Application/Handlers/Attendee/ResendAttendeeTicketHandler.php
@@ -2,7 +2,10 @@
namespace HiEvents\Services\Application\Handlers\Attendee;
+use HiEvents\DomainObjects\EventLocationDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
+use HiEvents\DomainObjects\LocationDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
@@ -19,13 +22,11 @@
readonly class ResendAttendeeTicketHandler
{
public function __construct(
- private SendAttendeeTicketService $sendAttendeeProductService,
+ private SendAttendeeTicketService $sendAttendeeProductService,
private AttendeeRepositoryInterface $attendeeRepository,
- private EventRepositoryInterface $eventRepository,
- private LoggerInterface $logger,
- )
- {
- }
+ private EventRepositoryInterface $eventRepository,
+ private LoggerInterface $logger,
+ ) {}
/**
* @throws ResourceConflictException
@@ -36,13 +37,22 @@ public function handle(ResendAttendeeTicketDTO $resendAttendeeProductDTO): void
->loadRelation(new Relationship(OrderDomainObject::class, nested: [
new Relationship(OrderItemDomainObject::class),
], name: 'order'))
+ ->loadRelation(new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ], name: 'event_location'),
+ ],
+ name: 'event_occurrence',
+ ))
->findFirstWhere([
'id' => $resendAttendeeProductDTO->attendeeId,
'event_id' => $resendAttendeeProductDTO->eventId,
]);
- if (!$attendee) {
- throw new ResourceNotFoundException();
+ if (! $attendee) {
+ throw new ResourceNotFoundException;
}
if ($attendee->getStatus() !== AttendeeStatus::ACTIVE->name) {
@@ -52,6 +62,9 @@ public function handle(ResendAttendeeTicketDTO $resendAttendeeProductDTO): void
$event = $this->eventRepository
->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer'))
->loadRelation(EventSettingDomainObject::class)
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ], name: 'event_location'))
->findById($resendAttendeeProductDTO->eventId);
$this->sendAttendeeProductService->send(
@@ -64,7 +77,7 @@ public function handle(ResendAttendeeTicketDTO $resendAttendeeProductDTO): void
$this->logger->info('Attendee ticket resent', [
'attendeeId' => $resendAttendeeProductDTO->attendeeId,
- 'eventId' => $resendAttendeeProductDTO->eventId
+ 'eventId' => $resendAttendeeProductDTO->eventId,
]);
}
}
diff --git a/backend/app/Services/Application/Handlers/Auth/AcceptInvitationHandler.php b/backend/app/Services/Application/Handlers/Auth/AcceptInvitationHandler.php
index 9563f7035e..35f6231a6e 100644
--- a/backend/app/Services/Application/Handlers/Auth/AcceptInvitationHandler.php
+++ b/backend/app/Services/Application/Handlers/Auth/AcceptInvitationHandler.php
@@ -19,15 +19,13 @@
class AcceptInvitationHandler
{
public function __construct(
- private readonly EncryptedPayloadService $encryptedPayloadService,
- private readonly UserRepositoryInterface $userRepository,
+ private readonly EncryptedPayloadService $encryptedPayloadService,
+ private readonly UserRepositoryInterface $userRepository,
private readonly AccountUserRepositoryInterface $accountUserRepository,
- private readonly Hasher $hasher,
- private readonly DatabaseManager $databaseManager,
- private readonly LoggerInterface $logger
- )
- {
- }
+ private readonly Hasher $hasher,
+ private readonly DatabaseManager $databaseManager,
+ private readonly LoggerInterface $logger
+ ) {}
/**
* @throws DecryptionFailedException
diff --git a/backend/app/Services/Application/Handlers/Auth/DTO/AcceptInvitationDTO.php b/backend/app/Services/Application/Handlers/Auth/DTO/AcceptInvitationDTO.php
index 6f9ae0c791..f4112e62b5 100644
--- a/backend/app/Services/Application/Handlers/Auth/DTO/AcceptInvitationDTO.php
+++ b/backend/app/Services/Application/Handlers/Auth/DTO/AcceptInvitationDTO.php
@@ -7,13 +7,11 @@
class AcceptInvitationDTO extends BaseDTO
{
public function __construct(
- public readonly string $invitation_token,
- public readonly string $first_name,
+ public readonly string $invitation_token,
+ public readonly string $first_name,
public readonly ?string $last_name = null,
- public readonly string $password = '',
- public readonly string $timezone = '',
- public readonly bool $marketing_opt_in = false,
- )
- {
- }
+ public readonly string $password = '',
+ public readonly string $timezone = '',
+ public readonly bool $marketing_opt_in = false,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Auth/DTO/AuthenticatedResponseDTO.php b/backend/app/Services/Application/Handlers/Auth/DTO/AuthenticatedResponseDTO.php
index 242528d25a..444c527650 100644
--- a/backend/app/Services/Application/Handlers/Auth/DTO/AuthenticatedResponseDTO.php
+++ b/backend/app/Services/Application/Handlers/Auth/DTO/AuthenticatedResponseDTO.php
@@ -9,11 +9,9 @@
class AuthenticatedResponseDTO extends BaseDTO
{
public function __construct(
- public ?string $token,
- public int $expiresIn,
- public Collection $accounts,
+ public ?string $token,
+ public int $expiresIn,
+ public Collection $accounts,
public UserDomainObject $user,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Auth/DTO/LoginCredentialsDTO.php b/backend/app/Services/Application/Handlers/Auth/DTO/LoginCredentialsDTO.php
index 1aea0159c8..894c978711 100644
--- a/backend/app/Services/Application/Handlers/Auth/DTO/LoginCredentialsDTO.php
+++ b/backend/app/Services/Application/Handlers/Auth/DTO/LoginCredentialsDTO.php
@@ -10,7 +10,5 @@ public function __construct(
public readonly string $email,
public readonly string $password,
public readonly ?int $accountId = null,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Auth/DTO/ResetPasswordDTO.php b/backend/app/Services/Application/Handlers/Auth/DTO/ResetPasswordDTO.php
index d291a93723..3511c79e52 100644
--- a/backend/app/Services/Application/Handlers/Auth/DTO/ResetPasswordDTO.php
+++ b/backend/app/Services/Application/Handlers/Auth/DTO/ResetPasswordDTO.php
@@ -11,7 +11,5 @@ public function __construct(
public readonly string $password,
public readonly string $ipAddress,
public readonly string $userAgent,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Auth/ForgotPasswordHandler.php b/backend/app/Services/Application/Handlers/Auth/ForgotPasswordHandler.php
index 159b172a55..7633bd8f18 100644
--- a/backend/app/Services/Application/Handlers/Auth/ForgotPasswordHandler.php
+++ b/backend/app/Services/Application/Handlers/Auth/ForgotPasswordHandler.php
@@ -7,8 +7,8 @@
use HiEvents\Repository\Interfaces\PasswordResetTokenRepositoryInterface;
use HiEvents\Repository\Interfaces\UserRepositoryInterface;
use HiEvents\Services\Infrastructure\TokenGenerator\TokenGeneratorService;
-use Illuminate\Database\DatabaseManager;
use Illuminate\Contracts\Mail\Mailer;
+use Illuminate\Database\DatabaseManager;
use Psr\Log\LoggerInterface;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Throwable;
@@ -16,15 +16,13 @@
class ForgotPasswordHandler
{
public function __construct(
- private readonly UserRepositoryInterface $userRepository,
- private readonly Mailer $mailer,
- private readonly LoggerInterface $logger,
+ private readonly UserRepositoryInterface $userRepository,
+ private readonly Mailer $mailer,
+ private readonly LoggerInterface $logger,
private readonly PasswordResetTokenRepositoryInterface $passwordResetTokenRepository,
- private readonly TokenGeneratorService $tokenGeneratorService,
- private readonly DatabaseManager $databaseManager,
- )
- {
- }
+ private readonly TokenGeneratorService $tokenGeneratorService,
+ private readonly DatabaseManager $databaseManager,
+ ) {}
/**
* @throws Throwable
@@ -42,13 +40,13 @@ public function handle(string $email): void
private function findUserByEmail(string $email)
{
$user = $this->userRepository->findFirstWhere([
- 'email' => strtolower($email)
- ]
+ 'email' => strtolower($email),
+ ]
);
if ($user === null) {
$this->logUnrecognisedEmail($email);
- throw new ResourceNotFoundException();
+ throw new ResourceNotFoundException;
}
return $user;
diff --git a/backend/app/Services/Application/Handlers/Auth/LoginHandler.php b/backend/app/Services/Application/Handlers/Auth/LoginHandler.php
index 4e4d4ac323..4b16441c86 100644
--- a/backend/app/Services/Application/Handlers/Auth/LoginHandler.php
+++ b/backend/app/Services/Application/Handlers/Auth/LoginHandler.php
@@ -10,11 +10,9 @@
readonly class LoginHandler
{
public function __construct(
- private LoginService $loginService,
+ private LoginService $loginService,
private AccountUserRepositoryInterface $accountUserRepository,
- )
- {
- }
+ ) {}
public function handle(LoginCredentialsDTO $loginCredentials): LoginResponse
{
diff --git a/backend/app/Services/Application/Handlers/Auth/ResetPasswordHandler.php b/backend/app/Services/Application/Handlers/Auth/ResetPasswordHandler.php
index fd0c8f7829..ee9060c764 100644
--- a/backend/app/Services/Application/Handlers/Auth/ResetPasswordHandler.php
+++ b/backend/app/Services/Application/Handlers/Auth/ResetPasswordHandler.php
@@ -19,16 +19,14 @@
class ResetPasswordHandler
{
public function __construct(
- private readonly UserRepositoryInterface $userRepository,
+ private readonly UserRepositoryInterface $userRepository,
private readonly PasswordResetTokenRepositoryInterface $passwordResetTokenRepository,
- private readonly Mailer $mailer,
- private readonly HashManager $hashManager,
- private readonly DatabaseManager $databaseManager,
- private readonly LoggerInterface $logger,
- private readonly ResetPasswordTokenValidateService $passwordTokenValidateService,
- )
- {
- }
+ private readonly Mailer $mailer,
+ private readonly HashManager $hashManager,
+ private readonly DatabaseManager $databaseManager,
+ private readonly LoggerInterface $logger,
+ private readonly ResetPasswordTokenValidateService $passwordTokenValidateService,
+ ) {}
/**
* @throws Throwable
@@ -58,7 +56,7 @@ private function checkNewPasswordIsOldPassword(UserDomainObject $user, string $n
private function validateUser(string $email): UserDomainObject
{
$user = $this->userRepository->findFirstWhere(['email' => $email]);
- if (!$user) {
+ if (! $user) {
throw new ResourceNotFoundException(__('User not found'));
}
@@ -69,10 +67,10 @@ private function resetUserPassword(int $userId, string $newPassword): void
{
$this->userRepository->updateWhere(
attributes: [
- 'password' => $this->hashManager->make($newPassword)
+ 'password' => $this->hashManager->make($newPassword),
],
where: [
- 'id' => $userId
+ 'id' => $userId,
],
);
}
@@ -85,9 +83,9 @@ private function deleteResetToken(string $email): void
private function logResetPasswordSuccess(UserDomainObject $user): void
{
$this->logger->info('Password reset successfully', [
- 'user_id' => $user->getId(),
- 'email' => $user->getEmail()
- ]
+ 'user_id' => $user->getId(),
+ 'email' => $user->getEmail(),
+ ]
);
}
@@ -96,6 +94,6 @@ private function sendResetPasswordEmail(UserDomainObject $user): void
$this->mailer
->to($user->getEmail())
->locale($user->getLocale())
- ->send(new ResetPasswordSuccess());
+ ->send(new ResetPasswordSuccess);
}
}
diff --git a/backend/app/Services/Application/Handlers/CapacityAssignment/CreateCapacityAssignmentHandler.php b/backend/app/Services/Application/Handlers/CapacityAssignment/CreateCapacityAssignmentHandler.php
index 0f0b5bc1af..77c8b2dd04 100644
--- a/backend/app/Services/Application/Handlers/CapacityAssignment/CreateCapacityAssignmentHandler.php
+++ b/backend/app/Services/Application/Handlers/CapacityAssignment/CreateCapacityAssignmentHandler.php
@@ -12,9 +12,7 @@ class CreateCapacityAssignmentHandler
{
public function __construct(
private readonly CreateCapacityAssignmentService $createCapacityAssignmentService
- )
- {
- }
+ ) {}
/**
* @throws UnrecognizedProductIdException
diff --git a/backend/app/Services/Application/Handlers/CapacityAssignment/DTO/GetCapacityAssignmentsDTO.php b/backend/app/Services/Application/Handlers/CapacityAssignment/DTO/GetCapacityAssignmentsDTO.php
index a7d8752f80..09745871ee 100644
--- a/backend/app/Services/Application/Handlers/CapacityAssignment/DTO/GetCapacityAssignmentsDTO.php
+++ b/backend/app/Services/Application/Handlers/CapacityAssignment/DTO/GetCapacityAssignmentsDTO.php
@@ -8,9 +8,7 @@
class GetCapacityAssignmentsDTO extends BaseDTO
{
public function __construct(
- public int $eventId,
+ public int $eventId,
public QueryParamsDTO $queryParams,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/CapacityAssignment/DTO/UpsertCapacityAssignmentDTO.php b/backend/app/Services/Application/Handlers/CapacityAssignment/DTO/UpsertCapacityAssignmentDTO.php
index c335d6c1ec..51475d2785 100644
--- a/backend/app/Services/Application/Handlers/CapacityAssignment/DTO/UpsertCapacityAssignmentDTO.php
+++ b/backend/app/Services/Application/Handlers/CapacityAssignment/DTO/UpsertCapacityAssignmentDTO.php
@@ -8,14 +8,12 @@
class UpsertCapacityAssignmentDTO extends BaseDTO
{
public function __construct(
- public string $name,
- public int $event_id,
+ public string $name,
+ public int $event_id,
public CapacityAssignmentStatus $status,
- public ?int $capacity,
- public ?array $product_ids = null,
- public ?int $id = null,
- )
- {
- }
+ public ?int $capacity,
+ public ?array $product_ids = null,
+ public ?int $id = null,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/CapacityAssignment/DeleteCapacityAssignmentHandler.php b/backend/app/Services/Application/Handlers/CapacityAssignment/DeleteCapacityAssignmentHandler.php
index 71bc8f9966..391dc8bd71 100644
--- a/backend/app/Services/Application/Handlers/CapacityAssignment/DeleteCapacityAssignmentHandler.php
+++ b/backend/app/Services/Application/Handlers/CapacityAssignment/DeleteCapacityAssignmentHandler.php
@@ -13,11 +13,9 @@ class DeleteCapacityAssignmentHandler
{
public function __construct(
private readonly CapacityAssignmentRepositoryInterface $capacityAssignmentRepository,
- private readonly ProductRepositoryInterface $productRepository,
- private readonly DatabaseManager $databaseManager,
- )
- {
- }
+ private readonly ProductRepositoryInterface $productRepository,
+ private readonly DatabaseManager $databaseManager,
+ ) {}
public function handle(int $id, int $eventId): void
{
diff --git a/backend/app/Services/Application/Handlers/CapacityAssignment/GetCapacityAssignmentHandler.php b/backend/app/Services/Application/Handlers/CapacityAssignment/GetCapacityAssignmentHandler.php
index a8340b6ee5..2c37c49cbd 100644
--- a/backend/app/Services/Application/Handlers/CapacityAssignment/GetCapacityAssignmentHandler.php
+++ b/backend/app/Services/Application/Handlers/CapacityAssignment/GetCapacityAssignmentHandler.php
@@ -11,9 +11,7 @@ class GetCapacityAssignmentHandler
{
public function __construct(
private readonly CapacityAssignmentRepositoryInterface $capacityAssignmentRepository,
- )
- {
- }
+ ) {}
public function handle(int $capacityAssignmentId, int $eventId): CapacityAssignmentDomainObject
{
diff --git a/backend/app/Services/Application/Handlers/CapacityAssignment/GetCapacityAssignmentsHandler.php b/backend/app/Services/Application/Handlers/CapacityAssignment/GetCapacityAssignmentsHandler.php
index 215ffe567c..a0a7cd0315 100644
--- a/backend/app/Services/Application/Handlers/CapacityAssignment/GetCapacityAssignmentsHandler.php
+++ b/backend/app/Services/Application/Handlers/CapacityAssignment/GetCapacityAssignmentsHandler.php
@@ -11,9 +11,7 @@ class GetCapacityAssignmentsHandler
{
public function __construct(
private readonly CapacityAssignmentRepositoryInterface $capacityAssignmentRepository,
- )
- {
- }
+ ) {}
public function handle(GetCapacityAssignmentsDTO $dto): LengthAwarePaginator
{
diff --git a/backend/app/Services/Application/Handlers/CapacityAssignment/UpdateCapacityAssignmentHandler.php b/backend/app/Services/Application/Handlers/CapacityAssignment/UpdateCapacityAssignmentHandler.php
index b8b0c81aff..df14ecf738 100644
--- a/backend/app/Services/Application/Handlers/CapacityAssignment/UpdateCapacityAssignmentHandler.php
+++ b/backend/app/Services/Application/Handlers/CapacityAssignment/UpdateCapacityAssignmentHandler.php
@@ -14,11 +14,9 @@
class UpdateCapacityAssignmentHandler
{
public function __construct(
- private readonly UpdateCapacityAssignmentService $updateCapacityAssignmentService,
- private readonly CapacityAssignmentRepositoryInterface $capacityAssignmentRepository,
- )
- {
- }
+ private readonly UpdateCapacityAssignmentService $updateCapacityAssignmentService,
+ private readonly CapacityAssignmentRepositoryInterface $capacityAssignmentRepository,
+ ) {}
/**
* @throws UnrecognizedProductIdException
@@ -50,9 +48,8 @@ public function handle(UpsertCapacityAssignmentDTO $data): CapacityAssignmentDom
private function dispatchCapacityChangedEvents(
CapacityAssignmentDomainObject $existingAssignment,
- UpsertCapacityAssignmentDTO $data,
- ): void
- {
+ UpsertCapacityAssignmentDTO $data,
+ ): void {
if (empty($data->product_ids)) {
return;
}
@@ -62,11 +59,9 @@ private function dispatchCapacityChangedEvents(
$direction = match (true) {
($newCapacity === null && $oldCapacity !== null),
- ($newCapacity !== null && $oldCapacity !== null && $newCapacity > $oldCapacity)
- => CapacityChangeDirection::INCREASED,
+ ($newCapacity !== null && $oldCapacity !== null && $newCapacity > $oldCapacity) => CapacityChangeDirection::INCREASED,
($newCapacity !== null && $oldCapacity === null),
- ($newCapacity !== null && $oldCapacity !== null && $newCapacity < $oldCapacity)
- => CapacityChangeDirection::DECREASED,
+ ($newCapacity !== null && $oldCapacity !== null && $newCapacity < $oldCapacity) => CapacityChangeDirection::DECREASED,
default => null,
};
diff --git a/backend/app/Services/Application/Handlers/CheckInList/CreateCheckInListHandler.php b/backend/app/Services/Application/Handlers/CheckInList/CreateCheckInListHandler.php
index 6682b2a346..b8dcc460e9 100644
--- a/backend/app/Services/Application/Handlers/CheckInList/CreateCheckInListHandler.php
+++ b/backend/app/Services/Application/Handlers/CheckInList/CreateCheckInListHandler.php
@@ -11,21 +11,23 @@ class CreateCheckInListHandler
{
public function __construct(
private readonly CreateCheckInListService $createCheckInListService,
- )
- {
- }
+ ) {}
/**
* @throws UnrecognizedProductIdException
*/
public function handle(UpsertCheckInListDTO $listData): CheckInListDomainObject
{
- $checkInList = (new CheckInListDomainObject())
+ $checkInList = (new CheckInListDomainObject)
->setName($listData->name)
->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/GetCheckInListsDTO.php b/backend/app/Services/Application/Handlers/CheckInList/DTO/GetCheckInListsDTO.php
index 6815859758..c3cd9af4b1 100644
--- a/backend/app/Services/Application/Handlers/CheckInList/DTO/GetCheckInListsDTO.php
+++ b/backend/app/Services/Application/Handlers/CheckInList/DTO/GetCheckInListsDTO.php
@@ -8,9 +8,7 @@
class GetCheckInListsDTO extends BaseDTO
{
public function __construct(
- public int $eventId,
+ public int $eventId,
public QueryParamsDTO $queryParams,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/CheckInList/DTO/UpsertCheckInListDTO.php b/backend/app/Services/Application/Handlers/CheckInList/DTO/UpsertCheckInListDTO.php
index 229a93c42f..4976041b50 100644
--- a/backend/app/Services/Application/Handlers/CheckInList/DTO/UpsertCheckInListDTO.php
+++ b/backend/app/Services/Application/Handlers/CheckInList/DTO/UpsertCheckInListDTO.php
@@ -7,14 +7,16 @@
class UpsertCheckInListDTO extends BaseDTO
{
public function __construct(
- public string $name,
+ public string $name,
public ?string $description,
- public int $eventId,
- public array $productIds,
+ public int $eventId,
+ public array $productIds,
public ?string $expiresAt = null,
public ?string $activatesAt = null,
- public ?int $id = null,
- )
- {
- }
+ public ?int $id = null,
+ public ?int $eventOccurrenceId = null,
+ public bool $publicShowAttendeeNotes = false,
+ public bool $publicShowQuestionAnswers = false,
+ public bool $publicShowOrderDetails = false,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/CheckInList/DeleteCheckInListHandler.php b/backend/app/Services/Application/Handlers/CheckInList/DeleteCheckInListHandler.php
index 1ef9674a57..9f219e4d36 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;
@@ -9,9 +10,7 @@ class DeleteCheckInListHandler
{
public function __construct(
private readonly CheckInListRepositoryInterface $checkInListRepository,
- )
- {
- }
+ ) {}
public function handle(int $eventId, int $checkInListId): void
{
@@ -25,6 +24,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/GetCheckInListHandler.php b/backend/app/Services/Application/Handlers/CheckInList/GetCheckInListHandler.php
index b354ae853a..9f42a985e8 100644
--- a/backend/app/Services/Application/Handlers/CheckInList/GetCheckInListHandler.php
+++ b/backend/app/Services/Application/Handlers/CheckInList/GetCheckInListHandler.php
@@ -13,9 +13,7 @@ class GetCheckInListHandler
{
public function __construct(
private readonly CheckInListRepositoryInterface $checkInListRepository,
- )
- {
- }
+ ) {}
public function handle(int $checkInListId, int $eventId): CheckInListDomainObject
{
diff --git a/backend/app/Services/Application/Handlers/CheckInList/GetCheckInListsHandler.php b/backend/app/Services/Application/Handlers/CheckInList/GetCheckInListsHandler.php
index 3725d05ff7..0fe0cc1c8d 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;
@@ -14,15 +15,14 @@ class GetCheckInListsHandler
{
public function __construct(
private readonly CheckInListRepositoryInterface $checkInListRepository,
- )
- {
- }
+ ) {}
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,
@@ -33,7 +33,7 @@ public function handle(GetCheckInListsDTO $dto): LengthAwarePaginator
}
$attendeeCheckInCounts = $this->checkInListRepository->getCheckedInAttendeeCountByIds(
- $checkInLists->map(fn($checkInList) => $checkInList->getId())->toArray(),
+ $checkInLists->map(fn ($checkInList) => $checkInList->getId())->toArray(),
);
if ($attendeeCheckInCounts->isEmpty()) {
diff --git a/backend/app/Services/Application/Handlers/CheckInList/Public/CreateAttendeeCheckInPublicHandler.php b/backend/app/Services/Application/Handlers/CheckInList/Public/CreateAttendeeCheckInPublicHandler.php
index 74916036c2..46d3dc9d46 100644
--- a/backend/app/Services/Application/Handlers/CheckInList/Public/CreateAttendeeCheckInPublicHandler.php
+++ b/backend/app/Services/Application/Handlers/CheckInList/Public/CreateAttendeeCheckInPublicHandler.php
@@ -17,11 +17,9 @@ class CreateAttendeeCheckInPublicHandler
{
public function __construct(
private readonly CreateAttendeeCheckInService $createAttendeeCheckInService,
- private readonly LoggerInterface $logger,
+ private readonly LoggerInterface $logger,
private readonly DomainEventDispatcherService $domainEventDispatcherService,
- )
- {
- }
+ ) {}
/**
* @throws CannotCheckInException|Throwable
@@ -36,7 +34,7 @@ public function handle(CreateAttendeeCheckInPublicDTO $checkInData): CreateAtten
$this->logger->info('Attendee check-ins created', [
'attendee_ids' => $checkIns->attendeeCheckIns
- ->map(fn(AttendeeCheckInDomainObject $checkIn) => $checkIn->getAttendeeId())->toArray(),
+ ->map(fn (AttendeeCheckInDomainObject $checkIn) => $checkIn->getAttendeeId())->toArray(),
'check_in_list_uuid' => $checkInData->checkInListUuid,
'ip_address' => $checkInData->checkInUserIpAddress,
]);
diff --git a/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/AttendeeAndActionDTO.php b/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/AttendeeAndActionDTO.php
index 3f08384c72..d1b28f8090 100644
--- a/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/AttendeeAndActionDTO.php
+++ b/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/AttendeeAndActionDTO.php
@@ -8,9 +8,7 @@
class AttendeeAndActionDTO extends Data
{
public function __construct(
- public string $public_id,
+ public string $public_id,
public AttendeeCheckInActionType $action,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/CreateAttendeeCheckInPublicDTO.php b/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/CreateAttendeeCheckInPublicDTO.php
index a2ad087dc9..a86b1f3b9e 100644
--- a/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/CreateAttendeeCheckInPublicDTO.php
+++ b/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/CreateAttendeeCheckInPublicDTO.php
@@ -9,11 +9,9 @@
class CreateAttendeeCheckInPublicDTO extends Data
{
public function __construct(
- public string $checkInListUuid,
- public string $checkInUserIpAddress,
+ public string $checkInListUuid,
+ public string $checkInUserIpAddress,
#[DataCollectionOf(AttendeeAndActionDTO::class)]
public Collection $attendeesAndActions,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/DeleteAttendeeCheckInPublicDTO.php b/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/DeleteAttendeeCheckInPublicDTO.php
index 4df00fd489..4f79e0d041 100644
--- a/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/DeleteAttendeeCheckInPublicDTO.php
+++ b/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/DeleteAttendeeCheckInPublicDTO.php
@@ -10,7 +10,5 @@ public function __construct(
public string $checkInListShortId,
public string $checkInShortId,
public string $checkInUserIpAddress,
- )
- {
- }
+ ) {}
}
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..78e1afb1b7
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/CheckInList/Public/DTO/PublicAttendeeDetailDTO.php
@@ -0,0 +1,22 @@
+ $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/DeleteAttendeeCheckInPublicHandler.php b/backend/app/Services/Application/Handlers/CheckInList/Public/DeleteAttendeeCheckInPublicHandler.php
index 569b4c3428..a130ca1962 100644
--- a/backend/app/Services/Application/Handlers/CheckInList/Public/DeleteAttendeeCheckInPublicHandler.php
+++ b/backend/app/Services/Application/Handlers/CheckInList/Public/DeleteAttendeeCheckInPublicHandler.php
@@ -16,12 +16,10 @@ class DeleteAttendeeCheckInPublicHandler
{
public function __construct(
private readonly DeleteAttendeeCheckInService $deleteAttendeeCheckInService,
- private readonly LoggerInterface $logger,
+ private readonly LoggerInterface $logger,
private readonly DomainEventDispatcherService $domainEventDispatcherService,
- private readonly DatabaseManager $databaseManager
- )
- {
- }
+ private readonly DatabaseManager $databaseManager
+ ) {}
/**
* @throws CannotCheckInException
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..867be2a726
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeeDetailPublicHandler.php
@@ -0,0 +1,118 @@
+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->checkInListActivityValidator->assertActive($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;
+ }
+
+ 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..e487d8bb39 100644
--- a/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeePublicHandler.php
+++ b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeePublicHandler.php
@@ -8,20 +8,19 @@
use HiEvents\DomainObjects\Generated\CheckInListDomainObjectAbstract;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\Exceptions\CannotCheckInException;
-use HiEvents\Helper\DateHelper;
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface;
+use HiEvents\Services\Domain\CheckInList\CheckInListActivityValidator;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
class GetCheckInListAttendeePublicHandler
{
public function __construct(
- private readonly AttendeeRepositoryInterface $attendeeRepository,
+ private readonly AttendeeRepositoryInterface $attendeeRepository,
private readonly CheckInListRepositoryInterface $checkInListRepository,
- )
- {
- }
+ private readonly CheckInListActivityValidator $checkInListActivityValidator,
+ ) {}
/**
* @throws CannotCheckInException
@@ -35,30 +34,40 @@ public function handle(string $shortId, string $attendeePublicId): AttendeeDomai
CheckInListDomainObjectAbstract::SHORT_ID => $shortId,
]);
- if (!$checkInList) {
+ if (! $checkInList) {
throw new ResourceNotFoundException(__('Check-in list not found'));
}
- $this->validateCheckInListIsActive($checkInList);
+ $this->checkInListActivityValidator->assertActive($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;
}
- /**
- * @todo - Move this to its own service. It's used 3 times
- * @throws CannotCheckInException
- */
- private function validateCheckInListIsActive(CheckInListDomainObject $checkInList): void
- {
- if ($checkInList->getExpiresAt() && DateHelper::utcDateIsPast($checkInList->getExpiresAt())) {
- throw new CannotCheckInException(__('Check-in list has expired'));
+ 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->getActivatesAt() && DateHelper::utcDateIsFuture($checkInList->getActivatesAt())) {
- throw new CannotCheckInException(__('Check-in list is not active yet'));
+ 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..8e3c160146 100644
--- a/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandler.php
+++ b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandler.php
@@ -8,22 +8,22 @@
use HiEvents\DomainObjects\Generated\CheckInListDomainObjectAbstract;
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;
use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface;
+use HiEvents\Services\Domain\CheckInList\CheckInListActivityValidator;
use Illuminate\Contracts\Pagination\Paginator;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
class GetCheckInListAttendeesPublicHandler
{
public function __construct(
- private readonly AttendeeRepositoryInterface $attendeeRepository,
+ private readonly AttendeeRepositoryInterface $attendeeRepository,
private readonly CheckInListRepositoryInterface $checkInListRepository,
- )
- {
- }
+ private readonly CheckInListActivityValidator $checkInListActivityValidator,
+ ) {}
/**
* @throws CannotCheckInException
@@ -37,34 +37,53 @@ public function handle(string $shortId, QueryParamsDTO $queryParams): Paginator
CheckInListDomainObjectAbstract::SHORT_ID => $shortId,
]);
- if (!$checkInList) {
+ if (! $checkInList) {
throw new ResourceNotFoundException(__('Check-in list not found'));
}
- $this->validateCheckInListIsActive($checkInList);
+ $this->checkInListActivityValidator->assertActive($checkInList);
+
+ $queryParams = $this->applyCheckInListOccurrenceScope($checkInList, $queryParams);
$attendees = $this->attendeeRepository->getAttendeesByCheckInShortId($shortId, $queryParams);
// Set the check-in for each attendee
$attendees->getCollection()->transform(function (AttendeeDomainObject $attendee) use ($checkInList) {
$attendee->setCheckIn($attendee->getCheckIns()?->first(fn ($checkIn) => $checkIn->getCheckInListId() === $checkInList->getId()));
+
return $attendee;
});
return $attendees;
}
- /**
- * @throws CannotCheckInException
- */
- private function validateCheckInListIsActive(CheckInListDomainObject $checkInList): void
- {
- if ($checkInList->getExpiresAt() && DateHelper::utcDateIsPast($checkInList->getExpiresAt())) {
- throw new CannotCheckInException(__('Check-in list has expired'));
+ private function applyCheckInListOccurrenceScope(
+ CheckInListDomainObject $checkInList,
+ QueryParamsDTO $queryParams,
+ ): QueryParamsDTO {
+ $scopedOccurrenceId = $checkInList->getEventOccurrenceId();
+ if ($scopedOccurrenceId === null) {
+ return $queryParams;
}
- if ($checkInList->getActivatesAt() && DateHelper::utcDateIsFuture($checkInList->getActivatesAt())) {
- throw new CannotCheckInException(__('Check-in list is not active yet'));
- }
+ $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,
+ );
}
}
diff --git a/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListPublicHandler.php b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListPublicHandler.php
index ffee8de664..3c73a7e5ae 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;
@@ -14,17 +15,17 @@ class GetCheckInListPublicHandler
{
public function __construct(
private readonly CheckInListRepositoryInterface $checkInListRepository,
- )
- {
- }
+ ) {}
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)
+ ->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..f2ed785999
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/CheckInList/Public/GetCheckInListStatsPublicHandler.php
@@ -0,0 +1,48 @@
+checkInListRepository->findFirstWhere(['short_id' => $shortId]);
+
+ if (! $checkInList) {
+ throw new ResourceNotFoundException(__('Check-in list not found'));
+ }
+
+ $this->checkInListActivityValidator->assertActive($checkInList);
+
+ $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..2bae7c72af 100644
--- a/backend/app/Services/Application/Handlers/CheckInList/UpdateCheckInlistHandler.php
+++ b/backend/app/Services/Application/Handlers/CheckInList/UpdateCheckInlistHandler.php
@@ -10,23 +10,25 @@
class UpdateCheckInlistHandler
{
public function __construct(
- private readonly UpdateCheckInlistService $updateCheckInlistService,
- )
- {
- }
+ private readonly UpdateCheckInListService $updateCheckInlistService,
+ ) {}
/**
* @throws UnrecognizedProductIdException
*/
public function handle(UpsertCheckInListDTO $data): CheckInListDomainObject
{
- $checkInList = (new CheckInListDomainObject())
+ $checkInList = (new CheckInListDomainObject)
->setId($data->id)
->setName($data->name)
->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/EmailTemplate/CreateEmailTemplateHandler.php b/backend/app/Services/Application/Handlers/EmailTemplate/CreateEmailTemplateHandler.php
index 478df4e2cf..5eb1de1c99 100644
--- a/backend/app/Services/Application/Handlers/EmailTemplate/CreateEmailTemplateHandler.php
+++ b/backend/app/Services/Application/Handlers/EmailTemplate/CreateEmailTemplateHandler.php
@@ -14,11 +14,9 @@ class CreateEmailTemplateHandler
{
public function __construct(
private readonly EmailTemplateRepositoryInterface $emailTemplateRepository,
- private readonly EmailTemplateService $emailTemplateService,
- private readonly HtmlPurifierService $purifier,
- )
- {
- }
+ private readonly EmailTemplateService $emailTemplateService,
+ private readonly HtmlPurifierService $purifier,
+ ) {}
/**
* @throws EmailTemplateValidationException
@@ -27,7 +25,7 @@ public function __construct(
public function handle(UpsertEmailTemplateDTO $dto): EmailTemplateDomainObject
{
$validation = $this->emailTemplateService->validateTemplate($dto->subject, $dto->body);
- if (!$validation['valid']) {
+ if (! $validation['valid']) {
$exception = new EmailTemplateValidationException('Template validation failed');
$exception->validationErrors = $validation['errors'];
throw $exception;
diff --git a/backend/app/Services/Application/Handlers/EmailTemplate/DTO/DeleteEmailTemplateDTO.php b/backend/app/Services/Application/Handlers/EmailTemplate/DTO/DeleteEmailTemplateDTO.php
index cad0838d8b..240cd2ecce 100644
--- a/backend/app/Services/Application/Handlers/EmailTemplate/DTO/DeleteEmailTemplateDTO.php
+++ b/backend/app/Services/Application/Handlers/EmailTemplate/DTO/DeleteEmailTemplateDTO.php
@@ -9,7 +9,5 @@ class DeleteEmailTemplateDTO extends BaseDataObject
public function __construct(
public readonly int $id,
public readonly int $account_id,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/EmailTemplate/DTO/GetEmailTemplatesDTO.php b/backend/app/Services/Application/Handlers/EmailTemplate/DTO/GetEmailTemplatesDTO.php
index d8803df724..12873cb7d6 100644
--- a/backend/app/Services/Application/Handlers/EmailTemplate/DTO/GetEmailTemplatesDTO.php
+++ b/backend/app/Services/Application/Handlers/EmailTemplate/DTO/GetEmailTemplatesDTO.php
@@ -8,12 +8,10 @@
class GetEmailTemplatesDTO extends BaseDataObject
{
public function __construct(
- public readonly int $account_id,
- public readonly ?int $organizer_id = null,
- public readonly ?int $event_id = null,
+ public readonly int $account_id,
+ public readonly ?int $organizer_id = null,
+ public readonly ?int $event_id = null,
public readonly ?EmailTemplateType $template_type = null,
- public readonly bool $include_inactive = false,
- )
- {
- }
+ public readonly bool $include_inactive = false,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/EmailTemplate/DTO/PreviewEmailTemplateDTO.php b/backend/app/Services/Application/Handlers/EmailTemplate/DTO/PreviewEmailTemplateDTO.php
index b69d34c6e1..5f527f1bff 100644
--- a/backend/app/Services/Application/Handlers/EmailTemplate/DTO/PreviewEmailTemplateDTO.php
+++ b/backend/app/Services/Application/Handlers/EmailTemplate/DTO/PreviewEmailTemplateDTO.php
@@ -8,11 +8,9 @@
class PreviewEmailTemplateDTO extends BaseDataObject
{
public function __construct(
- public readonly string $subject,
- public readonly string $body,
+ public readonly string $subject,
+ public readonly string $body,
public readonly EmailTemplateType $template_type,
- public readonly ?array $cta = null,
- )
- {
- }
+ public readonly ?array $cta = null,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/EmailTemplate/DTO/UpsertEmailTemplateDTO.php b/backend/app/Services/Application/Handlers/EmailTemplate/DTO/UpsertEmailTemplateDTO.php
index 43afc7379f..4866f52326 100644
--- a/backend/app/Services/Application/Handlers/EmailTemplate/DTO/UpsertEmailTemplateDTO.php
+++ b/backend/app/Services/Application/Handlers/EmailTemplate/DTO/UpsertEmailTemplateDTO.php
@@ -9,17 +9,15 @@
class UpsertEmailTemplateDTO extends BaseDataObject
{
public function __construct(
- public readonly int $account_id,
- public readonly EmailTemplateType $template_type,
- public readonly string $subject,
- public readonly string $body,
- public readonly ?int $organizer_id = null,
- public readonly ?int $event_id = null,
- public readonly ?int $id = null,
- public readonly ?array $cta = null,
+ public readonly int $account_id,
+ public readonly EmailTemplateType $template_type,
+ public readonly string $subject,
+ public readonly string $body,
+ public readonly ?int $organizer_id = null,
+ public readonly ?int $event_id = null,
+ public readonly ?int $id = null,
+ public readonly ?array $cta = null,
public readonly EmailTemplateEngine $engine = EmailTemplateEngine::LIQUID,
- public readonly bool $is_active = true,
- )
- {
- }
+ public readonly bool $is_active = true,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/EmailTemplate/DeleteEmailTemplateHandler.php b/backend/app/Services/Application/Handlers/EmailTemplate/DeleteEmailTemplateHandler.php
index 66abdda71b..d31c40dbf7 100644
--- a/backend/app/Services/Application/Handlers/EmailTemplate/DeleteEmailTemplateHandler.php
+++ b/backend/app/Services/Application/Handlers/EmailTemplate/DeleteEmailTemplateHandler.php
@@ -10,9 +10,7 @@ class DeleteEmailTemplateHandler
{
public function __construct(
private readonly EmailTemplateRepositoryInterface $emailTemplateRepository
- )
- {
- }
+ ) {}
/**
* @throws EmailTemplateNotFoundException
@@ -24,7 +22,7 @@ public function handle(DeleteEmailTemplateDTO $dto): bool
'account_id' => $dto->account_id,
]);
- if (!$template) {
+ if (! $template) {
throw new EmailTemplateNotFoundException(__('Email template not found'));
}
diff --git a/backend/app/Services/Application/Handlers/EmailTemplate/GetAvailableTokensHandler.php b/backend/app/Services/Application/Handlers/EmailTemplate/GetAvailableTokensHandler.php
index 72d16388b6..3cf7d063dc 100644
--- a/backend/app/Services/Application/Handlers/EmailTemplate/GetAvailableTokensHandler.php
+++ b/backend/app/Services/Application/Handlers/EmailTemplate/GetAvailableTokensHandler.php
@@ -9,11 +9,10 @@ class GetAvailableTokensHandler
{
public function __construct(
private readonly LiquidTemplateRenderer $liquidRenderer
- ) {
- }
+ ) {}
public function handle(EmailTemplateType $templateType): array
{
return $this->liquidRenderer->getAvailableTokens($templateType);
}
-}
\ No newline at end of file
+}
diff --git a/backend/app/Services/Application/Handlers/EmailTemplate/GetEmailTemplatesHandler.php b/backend/app/Services/Application/Handlers/EmailTemplate/GetEmailTemplatesHandler.php
index 585ba6a2c7..098dac7dc4 100644
--- a/backend/app/Services/Application/Handlers/EmailTemplate/GetEmailTemplatesHandler.php
+++ b/backend/app/Services/Application/Handlers/EmailTemplate/GetEmailTemplatesHandler.php
@@ -10,8 +10,7 @@ class GetEmailTemplatesHandler
{
public function __construct(
private readonly EmailTemplateRepositoryInterface $emailTemplateRepository
- ) {
- }
+ ) {}
public function handle(GetEmailTemplatesDTO $dto): Collection
{
@@ -31,10 +30,10 @@ public function handle(GetEmailTemplatesDTO $dto): Collection
$conditions['template_type'] = $dto->template_type->value;
}
- if (!$dto->include_inactive) {
+ if (! $dto->include_inactive) {
$conditions['is_active'] = true;
}
return $this->emailTemplateRepository->findWhere($conditions);
}
-}
\ No newline at end of file
+}
diff --git a/backend/app/Services/Application/Handlers/EmailTemplate/PreviewEmailTemplateHandler.php b/backend/app/Services/Application/Handlers/EmailTemplate/PreviewEmailTemplateHandler.php
index e55357ee05..bd2a4eaaa0 100644
--- a/backend/app/Services/Application/Handlers/EmailTemplate/PreviewEmailTemplateHandler.php
+++ b/backend/app/Services/Application/Handlers/EmailTemplate/PreviewEmailTemplateHandler.php
@@ -9,8 +9,7 @@ class PreviewEmailTemplateHandler
{
public function __construct(
private readonly EmailTemplateService $emailTemplateService
- ) {
- }
+ ) {}
public function handle(PreviewEmailTemplateDTO $dto): array
{
@@ -21,4 +20,4 @@ public function handle(PreviewEmailTemplateDTO $dto): array
$dto->cta
);
}
-}
\ No newline at end of file
+}
diff --git a/backend/app/Services/Application/Handlers/EmailTemplate/UpdateEmailTemplateHandler.php b/backend/app/Services/Application/Handlers/EmailTemplate/UpdateEmailTemplateHandler.php
index ceb110a7e9..e8a14bdf44 100644
--- a/backend/app/Services/Application/Handlers/EmailTemplate/UpdateEmailTemplateHandler.php
+++ b/backend/app/Services/Application/Handlers/EmailTemplate/UpdateEmailTemplateHandler.php
@@ -17,8 +17,7 @@ public function __construct(
private readonly EmailTemplateRepositoryInterface $emailTemplateRepository,
private readonly EmailTemplateService $emailTemplateService,
private readonly HtmlPurifierService $purifier,
- ) {
- }
+ ) {}
/**
* @throws EmailTemplateValidationException
@@ -27,12 +26,12 @@ public function __construct(
*/
public function handle(UpsertEmailTemplateDTO $dto): EmailTemplateDomainObject
{
- if (!$dto->id) {
+ if (! $dto->id) {
throw new InvalidEmailTemplateException('Template ID is required for update');
}
$validation = $this->emailTemplateService->validateTemplate($dto->subject, $dto->body);
- if (!$validation['valid']) {
+ if (! $validation['valid']) {
$exception = new EmailTemplateValidationException('Template validation failed');
$exception->validationErrors = $validation['errors'];
throw $exception;
@@ -43,7 +42,7 @@ public function handle(UpsertEmailTemplateDTO $dto): EmailTemplateDomainObject
'account_id' => $dto->account_id,
]);
- if (!$template) {
+ if (! $template) {
throw new EmailTemplateNotFoundException('Email template not found');
}
diff --git a/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php b/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php
index 7b86a00011..9511566da6 100644
--- a/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php
+++ b/backend/app/Services/Application/Handlers/Event/CreateEventHandler.php
@@ -6,12 +6,16 @@
use HiEvents\DomainObjects\Enums\EventCategory;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
+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 +23,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 +37,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 +51,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($event);
+ $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->setEventLocationId($eventLocation->getId());
+ $newEvent->setEventLocation($eventLocation);
+ }
$this->createProductCategoryService->createDefaultProductCategory($newEvent);
@@ -72,6 +98,13 @@ private function createEvent(CreateEventDTO $eventData): EventDomainObject
DomainEventType::EVENT_CREATED,
);
+ $newEvent->setEventOccurrences(
+ $this->eventRepository
+ ->loadRelation(EventOccurrenceDomainObject::class)
+ ->findById($newEvent->getId())
+ ->getEventOccurrences()
+ );
+
return $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/PublicOccurrenceFetchResultDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/PublicOccurrenceFetchResultDTO.php
new file mode 100644
index 0000000000..a68eb3e2e2
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Event/DTO/PublicOccurrenceFetchResultDTO.php
@@ -0,0 +1,14 @@
+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..ed121a4af0 100644
--- a/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php
+++ b/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php
@@ -2,34 +2,46 @@
namespace HiEvents\Services\Application\Handlers\Event;
+use Carbon\Carbon;
+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;
+use HiEvents\Services\Application\Handlers\Event\DTO\PublicOccurrenceFetchResultDTO;
use HiEvents\Services\Domain\Event\EventPageViewIncrementService;
+use HiEvents\Services\Domain\EventOccurrence\PublicOccurrenceVisibilityService;
use HiEvents\Services\Domain\Product\ProductFilterService;
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,
- )
- {
- }
+ private readonly PublicOccurrenceVisibilityService $occurrenceVisibilityService,
+ ) {}
public function handle(GetPublicEventDTO $data): EventDomainObject
{
@@ -48,29 +60,175 @@ 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);
+ $isRecurring = $event->getType() === EventType::RECURRING->name;
+ $hideSoldOutOccurrences = $this->occurrenceVisibilityService->shouldHideSoldOutOccurrences($event);
+ $occurrenceWhere = $this->occurrenceVisibilityService->buildWhereConditions(
+ eventId: $data->eventId,
+ isRecurring: $isRecurring,
+ hideSoldOutOccurrences: $hideSoldOutOccurrences,
+ );
+
+ $verifiedOccurrence = $this->resolveVerifiedOccurrence($data, $hideSoldOutOccurrences);
+
+ if ($isRecurring) {
+ $this->setRecurringEventOccurrences($event, $data->eventId, $occurrenceWhere, $hideSoldOutOccurrences, $verifiedOccurrence);
+ } else {
+ $event->setEventOccurrences(
+ $this->fetchOccurrences($occurrenceWhere, $verifiedOccurrence)->occurrences
+ );
+ }
+
$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: $verifiedOccurrence?->getId(),
));
}
+
+ private function setRecurringEventOccurrences(
+ EventDomainObject $event,
+ int $eventId,
+ array $occurrenceWhere,
+ bool $hideSoldOutOccurrences,
+ ?EventOccurrenceDomainObject $verifiedOccurrence,
+ ): void {
+ $nextBookableWhere = $hideSoldOutOccurrences
+ ? $occurrenceWhere
+ : [...$occurrenceWhere, PublicOccurrenceVisibilityService::hasRemainingCapacity()];
+
+ $nextBookable = $this->findEdgeOccurrence($nextBookableWhere, 'asc');
+ $event->setNextOccurrenceStartDate($nextBookable?->getStartDate());
+ $event->setLastOccurrenceStartDate($this->findEdgeOccurrence($occurrenceWhere, 'desc')?->getStartDate());
+
+ $anchorOccurrence = $verifiedOccurrence ?? $nextBookable;
+ if ($anchorOccurrence === null && ! $hideSoldOutOccurrences) {
+ $anchorOccurrence = $this->findEdgeOccurrence($occurrenceWhere, 'asc');
+ }
+
+ $timezone = $event->getTimezone() ?: 'UTC';
+ $anchorMonthStart = Carbon::parse($anchorOccurrence?->getStartDate() ?? now(), 'UTC')
+ ->setTimezone($timezone)
+ ->startOfMonth();
+
+ $monthWhere = [
+ ...$occurrenceWhere,
+ [EventOccurrenceDomainObjectAbstract::START_DATE, '>=', $anchorMonthStart->copy()->utc()->toDateTimeString()],
+ [EventOccurrenceDomainObjectAbstract::START_DATE, '<=', $anchorMonthStart->copy()->endOfMonth()->utc()->toDateTimeString()],
+ ];
+
+ $result = $this->fetchOccurrences($monthWhere, $verifiedOccurrence);
+
+ $event->setEventOccurrences($result->occurrences);
+ $event->setOccurrencesMonth($result->truncated ? null : $anchorMonthStart->format('Y-m'));
+
+ if ($hideSoldOutOccurrences && $nextBookable === null) {
+ $event->setUpcomingOccurrencesSoldOut(
+ $this->occurrenceRepository->findFirstWhere([
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
+ [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name],
+ PublicOccurrenceVisibilityService::isNotEnded(),
+ static function ($query): void {
+ $query->whereColumn(
+ EventOccurrenceDomainObjectAbstract::USED_CAPACITY,
+ '>=',
+ EventOccurrenceDomainObjectAbstract::CAPACITY,
+ );
+ },
+ ]) !== null
+ );
+ }
+ }
+
+ private function fetchOccurrences(array $where, ?EventOccurrenceDomainObject $verifiedOccurrence): PublicOccurrenceFetchResultDTO
+ {
+ $occurrences = $this->occurrenceRepository
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]))
+ ->findWhere(
+ where: $where,
+ orderAndDirections: [
+ new OrderAndDirection(EventOccurrenceDomainObjectAbstract::START_DATE, 'asc'),
+ ],
+ limit: self::MAX_PUBLIC_OCCURRENCES + 1,
+ );
+
+ $truncated = $occurrences->count() > self::MAX_PUBLIC_OCCURRENCES;
+ if ($truncated) {
+ $occurrences = $occurrences->take(self::MAX_PUBLIC_OCCURRENCES)->values();
+ }
+
+ if ($verifiedOccurrence !== null
+ && ! $occurrences->contains(fn (EventOccurrenceDomainObject $o) => $o->getId() === $verifiedOccurrence->getId())) {
+ $occurrences->push($verifiedOccurrence);
+ }
+
+ return new PublicOccurrenceFetchResultDTO($occurrences, $truncated);
+ }
+
+ private function findEdgeOccurrence(array $where, string $direction): ?EventOccurrenceDomainObject
+ {
+ return $this->occurrenceRepository
+ ->findWhere(
+ where: $where,
+ orderAndDirections: [
+ new OrderAndDirection(EventOccurrenceDomainObjectAbstract::START_DATE, $direction),
+ ],
+ limit: 1,
+ )
+ ->first();
+ }
+
+ private function resolveVerifiedOccurrence(GetPublicEventDTO $data, bool $hideSoldOutOccurrences): ?EventOccurrenceDomainObject
+ {
+ if ($data->eventOccurrenceId === null) {
+ return null;
+ }
+
+ $where = [
+ EventOccurrenceDomainObjectAbstract::ID => $data->eventOccurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $data->eventId,
+ [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name],
+ ];
+
+ if ($hideSoldOutOccurrences) {
+ $where[] = PublicOccurrenceVisibilityService::hasRemainingCapacity();
+ }
+
+ $verifiedOccurrence = $this->occurrenceRepository
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]))
+ ->findFirstWhere($where);
+
+ if ($verifiedOccurrence !== null && $verifiedOccurrence->isPast()) {
+ return null;
+ }
+
+ return $verifiedOccurrence;
+ }
}
diff --git a/backend/app/Services/Application/Handlers/Event/GetPublicEventsHandler.php b/backend/app/Services/Application/Handlers/Event/GetPublicEventsHandler.php
index 933f1fc1c0..d2679c1cf7 100644
--- a/backend/app/Services/Application/Handlers/Event/GetPublicEventsHandler.php
+++ b/backend/app/Services/Application/Handlers/Event/GetPublicEventsHandler.php
@@ -2,8 +2,11 @@
namespace HiEvents\Services\Application\Handlers\Event;
+use HiEvents\DomainObjects\EventLocationDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\ImageDomainObject;
+use HiEvents\DomainObjects\LocationDomainObject;
use HiEvents\DomainObjects\ProductCategoryDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
@@ -19,17 +22,23 @@
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(domainObject: EventLocationDomainObject::class, nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ], name: 'event_location'))
+ ->loadRelation(new Relationship(domainObject: EventOccurrenceDomainObject::class, nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ], name: 'event_location'),
+ ]))
->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 b7b5a32137..dc590468d2 100644
--- a/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php
+++ b/backend/app/Services/Application/Handlers/Event/UpdateEventHandler.php
@@ -1,20 +1,28 @@
eventRepository->findFirstWhere([
'id' => $eventData->id,
@@ -52,51 +62,81 @@ 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' => StringHelper::stripControlCharacters($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' => StringHelper::stripControlCharacters($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(),
],
);
}
private function getUpdateEvent(UpdateEventDTO $eventData): EventDomainObject
{
- $event = $this->eventRepository->findFirstWhere([
- 'id' => $eventData->id,
- 'account_id' => $eventData->account_id,
- ]);
+ $event = $this->eventRepository
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ], name: 'event_location'))
+ ->loadRelation(new Relationship(domainObject: EventOccurrenceDomainObject::class, nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ], name: 'event_location'),
+ ]))
+ ->findFirstWhere([
+ 'id' => $eventData->id,
+ 'account_id' => $eventData->account_id,
+ ]);
$this->dispatcher->dispatchEvent(new EventUpdateEvent($event));
@@ -118,11 +158,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..359184491d
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Event/UpdateEventLocationHandler.php
@@ -0,0 +1,89 @@
+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
+ ->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'),
+ ]),
+ ]))
+ ->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..73501b409d 100644
--- a/backend/app/Services/Application/Handlers/Event/UpdateEventStatusHandler.php
+++ b/backend/app/Services/Application/Handlers/Event/UpdateEventStatusHandler.php
@@ -3,12 +3,16 @@
namespace HiEvents\Services\Application\Handlers\Event;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventLocationDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
+use HiEvents\DomainObjects\LocationDomainObject;
+use HiEvents\DomainObjects\Status\EventStatus;
use HiEvents\Exceptions\AccountNotVerifiedException;
+use HiEvents\Jobs\Event\Webhook\DispatchEventWebhookJob;
+use HiEvents\Repository\Eloquent\Value\Relationship;
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 +21,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,13 +62,22 @@ private function updateEventStatus(UpdateEventStatusDTO $updateEventStatusDTO):
$this->logger->info('Event status updated', [
'eventId' => $updateEventStatusDTO->eventId,
- 'status' => $updateEventStatusDTO->status
+ 'status' => $updateEventStatusDTO->status,
]);
- $event = $this->eventRepository->findFirstWhere([
- 'id' => $updateEventStatusDTO->eventId,
- 'account_id' => $updateEventStatusDTO->accountId,
- ]);
+ $event = $this->eventRepository
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ], name: 'event_location'))
+ ->loadRelation(new Relationship(domainObject: EventOccurrenceDomainObject::class, nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ], name: 'event_location'),
+ ]))
+ ->findFirstWhere([
+ 'id' => $updateEventStatusDTO->eventId,
+ 'account_id' => $updateEventStatusDTO->accountId,
+ ]);
$eventType = $updateEventStatusDTO->status === EventStatus::ARCHIVED->name
? DomainEventType::EVENT_ARCHIVED
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..b76cfb8ecf
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandler.php
@@ -0,0 +1,373 @@
+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: []);
+ }
+
+ $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)) {
+ $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],
+ ],
+ );
+
+ return new BulkUpdateOccurrencesResultDTO(
+ updated_count: count($ids),
+ updated_ids: $ids,
+ );
+ }
+
+ private function applyPerRowUpdate(BulkUpdateOccurrencesDTO $dto, Collection $eligible, int $accountId): BulkUpdateOccurrencesResultDTO
+ {
+ $updatedIds = [];
+ $orphanCandidateIds = [];
+ $vacatedStartDates = [];
+
+ foreach ($eligible as $occurrence) {
+ $attributes = $this->buildPerRowAttributes($dto, $occurrence);
+
+ if (array_key_exists(EventOccurrenceDomainObjectAbstract::START_DATE, $attributes)) {
+ $vacatedStartDates[] = $occurrence->getStartDate();
+ }
+
+ $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)) {
+ $this->occurrenceRepository->updateWhere(
+ attributes: $attributes,
+ where: [EventOccurrenceDomainObjectAbstract::ID => $occurrence->getId()],
+ );
+ $updatedIds[] = $occurrence->getId();
+ }
+ }
+
+ foreach (array_unique($orphanCandidateIds) as $eventLocationId) {
+ $this->eventLocationCleaner->deleteIfOrphaned($eventLocationId);
+ }
+
+ if (! empty($vacatedStartDates)) {
+ $this->exclusionService->addExclusions($dto->event_id, $vacatedStartDates);
+ }
+
+ return new BulkUpdateOccurrencesResultDTO(
+ updated_count: count($updatedIds),
+ updated_ids: $updatedIds,
+ );
+ }
+
+ 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) {
+ $this->guardResultingDates($attributes, $occurrence, $dto->timezone);
+ }
+
+ if ($startEndChanged
+ || array_key_exists(EventOccurrenceDomainObjectAbstract::CAPACITY, $attributes)
+ ) {
+ $attributes[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] = true;
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * @throws InvalidOccurrenceDatesException
+ */
+ private function guardResultingDates(array $attributes, EventOccurrenceDomainObject $occurrence, string $timezone): void
+ {
+ $start = $attributes[EventOccurrenceDomainObjectAbstract::START_DATE] ?? $occurrence->getStartDate();
+ $end = $attributes[EventOccurrenceDomainObjectAbstract::END_DATE] ?? $occurrence->getEndDate();
+
+ if ($end !== null && Carbon::parse($end, 'UTC')->lessThanOrEqualTo(Carbon::parse($start, 'UTC'))) {
+ throw new InvalidOccurrenceDatesException(
+ __('This update would make the occurrence starting :start end before it starts. Adjust the time shift or duration.', [
+ 'start' => Carbon::parse($occurrence->getStartDate(), 'UTC')->setTimezone($timezone)->format('M j, Y g:i A'),
+ ]),
+ );
+ }
+ }
+
+ 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..88004d13a6
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/CancelOccurrenceHandler.php
@@ -0,0 +1,89 @@
+databaseManager->transaction(function () use ($eventId, $occurrenceId, &$wasCancelled, &$cancelledAttendeeIds) {
+ $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;
+ }
+
+ $cancelResult = $this->cancelAttendeesService->cancelForOccurrence($eventId, $occurrenceId);
+ $cancelledAttendeeIds = $cancelResult['attendee_ids'];
+
+ $updated = $this->occurrenceRepository->updateFromArray(
+ id: $occurrenceId,
+ attributes: [
+ EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::CANCELLED->name,
+ EventOccurrenceDomainObjectAbstract::CANCELLED_ATTENDEES_COUNT => $cancelResult['sales_backed_count'],
+ ],
+ );
+
+ $this->exclusionService->addExclusions($eventId, [$occurrence->getStartDate()]);
+
+ $wasCancelled = true;
+
+ return $updated;
+ });
+
+ if ($wasCancelled) {
+ SendOccurrenceCancellationEmailJob::dispatchChunked($eventId, $occurrenceId, $cancelledAttendeeIds, $refundOrders);
+
+ 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..52c108987e
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandler.php
@@ -0,0 +1,81 @@
+databaseManager->transaction(function () use ($dto) {
+ $existingOccurrenceCount = $this->occurrenceRepository->countWhere([
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $dto->event_id,
+ ]);
+
+ if ($existingOccurrenceCount >= RecurrenceRuleParserService::MAX_OCCURRENCES) {
+ throw ValidationException::withMessages([
+ 'occurrence' => [
+ __('This event has reached the maximum of :max occurrences.', [
+ 'max' => RecurrenceRuleParserService::MAX_OCCURRENCES,
+ ]),
+ ],
+ ]);
+ }
+
+ $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::SHOW_AVAILABLE_CAPACITY => $dto->show_available_capacity,
+ 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();
+
+ $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,
+ ]);
+
+ $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/GetPublicEventOccurrencesHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/GetPublicEventOccurrencesHandler.php
new file mode 100644
index 0000000000..af8e18728c
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/GetPublicEventOccurrencesHandler.php
@@ -0,0 +1,108 @@
+validateRange($dto);
+
+ $event = $this->eventRepository
+ ->loadRelation(new Relationship(ProductCategoryDomainObject::class, [
+ new Relationship(ProductDomainObject::class),
+ ]))
+ ->loadRelation(new Relationship(EventSettingDomainObject::class))
+ ->findById($dto->eventId);
+
+ $where = $this->occurrenceVisibilityService->buildWhereConditions(
+ eventId: $dto->eventId,
+ isRecurring: $event->getType() === EventType::RECURRING->name,
+ hideSoldOutOccurrences: $this->occurrenceVisibilityService->shouldHideSoldOutOccurrences($event),
+ );
+
+ $where[] = [EventOccurrenceDomainObjectAbstract::START_DATE, '>=', $startDateFrom->toDateTimeString()];
+ $where[] = [EventOccurrenceDomainObjectAbstract::START_DATE, '<=', $startDateTo->toDateTimeString()];
+
+ $occurrences = $this->occurrenceRepository
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]))
+ ->findWhere(
+ where: $where,
+ orderAndDirections: [
+ new OrderAndDirection(EventOccurrenceDomainObjectAbstract::START_DATE, 'asc'),
+ ],
+ limit: GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES,
+ );
+
+ return new GetPublicEventOccurrencesResultDTO(
+ event: $event,
+ occurrences: $occurrences,
+ );
+ }
+
+ /**
+ * @return array{0: Carbon, 1: Carbon}
+ *
+ * @throws InvalidOccurrenceDatesException
+ */
+ private function validateRange(GetPublicEventOccurrencesDTO $dto): array
+ {
+ if ($dto->startDateFrom === null || $dto->startDateTo === null) {
+ throw new InvalidOccurrenceDatesException(
+ __('Both start_date_from and start_date_to are required.')
+ );
+ }
+
+ try {
+ $startDateFrom = Carbon::parse($dto->startDateFrom, 'UTC');
+ $startDateTo = Carbon::parse($dto->startDateTo, 'UTC');
+ } catch (InvalidFormatException) {
+ throw new InvalidOccurrenceDatesException(
+ __('The date range is invalid.')
+ );
+ }
+
+ if ($startDateFrom->greaterThan($startDateTo)
+ || $startDateFrom->diffInDays($startDateTo) > self::MAX_RANGE_DAYS) {
+ throw new InvalidOccurrenceDatesException(
+ __('The date range must be valid and span at most :days days.', ['days' => self::MAX_RANGE_DAYS])
+ );
+ }
+
+ return [$startDateFrom, $startDateTo];
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/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..503fc78c40
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/ReactivateOccurrenceHandler.php
@@ -0,0 +1,67 @@
+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.'),
+ ]);
+ }
+
+ if ((int) $occurrence->getCancelledAttendeesCount() > 0) {
+ throw ValidationException::withMessages([
+ 'status' => __('This date had ticket sales that were cancelled with it, so it can\'t be reactivated automatically. Please create a new date instead.'),
+ ]);
+ }
+
+ $updated = $this->occurrenceRepository->updateFromArray(
+ id: $occurrenceId,
+ attributes: [
+ EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::ACTIVE->name,
+ EventOccurrenceDomainObjectAbstract::CANCELLED_ATTENDEES_COUNT => null,
+ ],
+ );
+
+ $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..40a4677c1c
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandler.php
@@ -0,0 +1,129 @@
+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;
+ }
+
+ $originalStartDate = $occurrence->getStartDate();
+ $startDateChanged = $this->datesDiffer($dto->start_date, $originalStartDate);
+
+ $isOverride = $occurrence->getIsOverridden()
+ || $startDateChanged
+ || $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::SHOW_AVAILABLE_CAPACITY => $dto->show_available_capacity,
+ EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => $isOverride,
+ EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID => $newEventLocationId,
+ ];
+
+ $updated = $this->occurrenceRepository->updateFromArray(
+ id: $occurrence->getId(),
+ attributes: $attributes,
+ );
+
+ if ($startDateChanged) {
+ $this->exclusionService->addExclusions($dto->event_id, [$originalStartDate]);
+ }
+
+ if ($dto->clear_event_location && $previousEventLocationId !== null) {
+ $this->eventLocationCleaner->deleteIfOrphaned($previousEventLocationId);
+ }
+
+ return $updated;
+ });
+ }
+
+ 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..0cb693ebe6
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandler.php
@@ -0,0 +1,81 @@
+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])
+ );
+ }
+
+ $selectedProductIds = array_map('intval', $dto->product_ids);
+
+ return $this->databaseManager->transaction(function () use ($dto, $selectedProductIds) {
+ $this->visibilityRepository->deleteWhere([
+ ProductOccurrenceVisibilityDomainObjectAbstract::EVENT_OCCURRENCE_ID => $dto->event_occurrence_id,
+ ]);
+
+ $allProducts = $this->productRepository->findWhere([
+ ProductDomainObjectAbstract::EVENT_ID => $dto->event_id,
+ ]);
+
+ $allProductIds = $allProducts->map(fn ($product) => $product->getId())->sort()->values()->toArray();
+ $sortedSelectedProductIds = collect($selectedProductIds)->sort()->values()->toArray();
+
+ $invalidIds = array_diff($sortedSelectedProductIds, $allProductIds);
+ if (! empty($invalidIds)) {
+ throw new ResourceNotFoundException(
+ __('One or more product IDs do not belong to this event')
+ );
+ }
+
+ if ($allProductIds === $sortedSelectedProductIds) {
+ return collect();
+ }
+
+ foreach (array_unique($selectedProductIds) 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 86ce00afdc..a058feb7f9 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;
@@ -14,93 +13,88 @@
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,
// Attendee detail copy control
- public readonly bool $allow_copy_details_to_all_attendees = true,
+ public readonly bool $allow_copy_details_to_all_attendees = 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,
+
+ // Occurrence display
+ public readonly bool $show_available_occurrence_capacity = false,
+ public readonly bool $hide_sold_out_occurrences = 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,
@@ -124,13 +118,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],
@@ -180,4 +170,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 f873196507..debdd52d07 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(),
@@ -141,6 +125,10 @@ public function handle(PartialUpdateEventSettingsDTO $eventSettingsDTO): EventSe
// Self-service settings
'allow_attendee_self_edit' => $eventSettingsDTO->settings['allow_attendee_self_edit'] ?? $existingSettings->getAllowAttendeeSelfEdit(),
+ // Occurrence display
+ 'show_available_occurrence_capacity' => $eventSettingsDTO->settings['show_available_occurrence_capacity'] ?? $existingSettings->getShowAvailableOccurrenceCapacity(),
+ 'hide_sold_out_occurrences' => $eventSettingsDTO->settings['hide_sold_out_occurrences'] ?? $existingSettings->getHideSoldOutOccurrences(),
+
// Waitlist settings
'waitlist_auto_process' => $eventSettingsDTO->settings['waitlist_auto_process'] ?? $existingSettings->getWaitlistAutoProcess(),
'waitlist_offer_timeout_minutes' => $eventSettingsDTO->settings['waitlist_offer_timeout_minutes'] ?? $existingSettings->getWaitlistOfferTimeoutMinutes(),
diff --git a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php
index 953d819401..b95aa11943 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,
@@ -101,6 +95,10 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje
// Self-service settings
'allow_attendee_self_edit' => $settings->allow_attendee_self_edit,
+ // Occurrence display
+ 'show_available_occurrence_capacity' => $settings->show_available_occurrence_capacity,
+ 'hide_sold_out_occurrences' => $settings->hide_sold_out_occurrences,
+
// Waitlist settings
'waitlist_auto_process' => $settings->waitlist_auto_process,
'waitlist_offer_timeout_minutes' => $settings->waitlist_offer_timeout_minutes,
@@ -116,7 +114,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/Images/CreateImageHandler.php b/backend/app/Services/Application/Handlers/Images/CreateImageHandler.php
index ff74876a6a..a48b8551c9 100644
--- a/backend/app/Services/Application/Handlers/Images/CreateImageHandler.php
+++ b/backend/app/Services/Application/Handlers/Images/CreateImageHandler.php
@@ -24,13 +24,11 @@ class CreateImageHandler
];
public function __construct(
- private readonly ImageUploadService $imageUploadService,
+ private readonly ImageUploadService $imageUploadService,
private readonly OrganizerRepositoryInterface $organizerRepository,
- private readonly EventRepositoryInterface $eventRepository,
- private readonly ImageRepositoryInterface $imageRepository,
- )
- {
- }
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly ImageRepositoryInterface $imageRepository,
+ ) {}
/**
* @throws CouldNotUploadImageException
diff --git a/backend/app/Services/Application/Handlers/Images/DTO/CreateImageDTO.php b/backend/app/Services/Application/Handlers/Images/DTO/CreateImageDTO.php
index 97795a3c7d..933ef3faf0 100644
--- a/backend/app/Services/Application/Handlers/Images/DTO/CreateImageDTO.php
+++ b/backend/app/Services/Application/Handlers/Images/DTO/CreateImageDTO.php
@@ -8,14 +8,12 @@
class CreateImageDTO
{
public function __construct(
- public readonly int $userId,
- public readonly int $accountId,
+ public readonly int $userId,
+ public readonly int $accountId,
public readonly UploadedFile $image,
- public readonly ?ImageType $imageType = null,
- public readonly ?int $entityId = null,
- )
- {
- }
+ public readonly ?ImageType $imageType = null,
+ public readonly ?int $entityId = null,
+ ) {}
public function isGeneric(): bool
{
diff --git a/backend/app/Services/Application/Handlers/Images/DTO/DeleteImageDTO.php b/backend/app/Services/Application/Handlers/Images/DTO/DeleteImageDTO.php
index 0e4731f5f0..5f21c169fa 100644
--- a/backend/app/Services/Application/Handlers/Images/DTO/DeleteImageDTO.php
+++ b/backend/app/Services/Application/Handlers/Images/DTO/DeleteImageDTO.php
@@ -8,7 +8,5 @@ public function __construct(
public readonly int $imageId,
public readonly int $userId,
public readonly int $accountId,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Images/DeleteImageHandler.php b/backend/app/Services/Application/Handlers/Images/DeleteImageHandler.php
index 490b390b58..c34db43944 100644
--- a/backend/app/Services/Application/Handlers/Images/DeleteImageHandler.php
+++ b/backend/app/Services/Application/Handlers/Images/DeleteImageHandler.php
@@ -11,9 +11,7 @@ class DeleteImageHandler
{
public function __construct(
private readonly ImageRepositoryInterface $imageRepository,
- )
- {
- }
+ ) {}
/**
* @throws CannotDeleteEntityException
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..c7681f63e4
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Location/CreateLocationHandler.php
@@ -0,0 +1,72 @@
+databaseManager->transaction(fn () => $this->createOrReuse($dto));
+ } catch (UniqueConstraintViolationException $exception) {
+ return $this->findExistingProviderPlace($dto) ?? throw $exception;
+ }
+ }
+
+ private function createOrReuse(UpsertLocationDTO $dto): LocationDomainObject
+ {
+ $existing = $this->findExistingProviderPlace($dto);
+
+ 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->cachedRawProviderResponse($dto->provider, $dto->provider_place_id),
+ ]);
+ }
+
+ private function findExistingProviderPlace(UpsertLocationDTO $dto): ?LocationDomainObject
+ {
+ if ($dto->provider === null || $dto->provider_place_id === null) {
+ return null;
+ }
+
+ return $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,
+ ]);
+ }
+}
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) {
+ $this->locationLockService->acquireExclusiveTransactionLock($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..4b5dbb381c
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Location/UpdateLocationHandler.php
@@ -0,0 +1,95 @@
+databaseManager->transaction(fn () => $this->update($locationId, $dto));
+ } catch (UniqueConstraintViolationException $exception) {
+ throw new ResourceConflictException(__('Another saved location already uses this place.'), previous: $exception);
+ }
+ }
+
+ private function update(int $locationId, UpsertLocationDTO $dto): LocationDomainObject
+ {
+ $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'));
+ }
+
+ $this->guardAgainstProviderPlaceConflict($location, $dto);
+
+ 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->resolveRawProviderResponse($location, $dto),
+ ]);
+ }
+
+ private function guardAgainstProviderPlaceConflict(LocationDomainObject $location, UpsertLocationDTO $dto): void
+ {
+ if ($dto->provider === null || $dto->provider_place_id === null) {
+ return;
+ }
+
+ $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,
+ ]);
+
+ if ($existing !== null && $existing->getId() !== $location->getId()) {
+ throw new ResourceConflictException(__('Another saved location already uses this place.'));
+ }
+ }
+
+ private function resolveRawProviderResponse(LocationDomainObject $location, UpsertLocationDTO $dto): array|string|null
+ {
+ $cached = $this->sanitizer->cachedRawProviderResponse($dto->provider, $dto->provider_place_id);
+
+ if ($cached !== null) {
+ return $cached;
+ }
+
+ $placeUnchanged = $dto->provider === $location->getProvider()
+ && $dto->provider_place_id === $location->getProviderPlaceId();
+
+ return $placeUnchanged ? $location->getRawProviderResponse() : null;
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/Message/CancelMessageHandler.php b/backend/app/Services/Application/Handlers/Message/CancelMessageHandler.php
index 29206a250b..9a42b638b8 100644
--- a/backend/app/Services/Application/Handlers/Message/CancelMessageHandler.php
+++ b/backend/app/Services/Application/Handlers/Message/CancelMessageHandler.php
@@ -14,9 +14,7 @@ class CancelMessageHandler
{
public function __construct(
private readonly MessageRepositoryInterface $messageRepository,
- )
- {
- }
+ ) {}
public function handle(int $messageId, int $eventId): MessageDomainObject
{
diff --git a/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php b/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php
index ef6eb83ce4..46bdc52b4c 100644
--- a/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php
+++ b/backend/app/Services/Application/Handlers/Message/DTO/SendMessageDTO.php
@@ -8,21 +8,21 @@
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,
+ public readonly ?array $event_occurrence_ids = null,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Message/GetMessageRecipientsHandler.php b/backend/app/Services/Application/Handlers/Message/GetMessageRecipientsHandler.php
index 1dd600d5aa..b791a9c242 100644
--- a/backend/app/Services/Application/Handlers/Message/GetMessageRecipientsHandler.php
+++ b/backend/app/Services/Application/Handlers/Message/GetMessageRecipientsHandler.php
@@ -12,10 +12,8 @@ class GetMessageRecipientsHandler
{
public function __construct(
private readonly OutgoingMessageRepositoryInterface $outgoingMessageRepository,
- private readonly MessageRepositoryInterface $messageRepository,
- )
- {
- }
+ private readonly MessageRepositoryInterface $messageRepository,
+ ) {}
public function handle(int $eventId, int $messageId, QueryParamsDTO $params): LengthAwarePaginator
{
diff --git a/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php b/backend/app/Services/Application/Handlers/Message/SendMessageHandler.php
index 9f1b7b9872..a92807248c 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,7 @@ public function handle(SendMessageDTO $messageData): MessageDomainObject
'account_id' => $messageData->account_id,
'attendee_ids' => $messageData->attendee_ids,
'product_ids' => $messageData->product_ids,
+ 'event_occurrence_ids' => $messageData->event_occurrence_ids,
],
]);
@@ -139,6 +139,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 +151,40 @@ 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,
),
};
}
+ 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 +196,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 +210,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/CancelOrderHandler.php b/backend/app/Services/Application/Handlers/Order/CancelOrderHandler.php
index e1e4ed099d..c6bd6fe1af 100644
--- a/backend/app/Services/Application/Handlers/Order/CancelOrderHandler.php
+++ b/backend/app/Services/Application/Handlers/Order/CancelOrderHandler.php
@@ -17,13 +17,11 @@
class CancelOrderHandler
{
public function __construct(
- private readonly OrderCancelService $orderCancelService,
+ private readonly OrderCancelService $orderCancelService,
private readonly OrderRepositoryInterface $orderRepository,
- private readonly DatabaseManager $databaseManager,
- private readonly RefundOrderHandler $refundOrderHandler,
- )
- {
- }
+ private readonly DatabaseManager $databaseManager,
+ private readonly RefundOrderHandler $refundOrderHandler,
+ ) {}
/**
* @throws Throwable
@@ -38,7 +36,7 @@ public function handle(CancelOrderDTO $cancelOrderDTO): OrderDomainObject
OrderDomainObjectAbstract::ID => $cancelOrderDTO->orderId,
]);
- if (!$order) {
+ if (! $order) {
throw new ResourceNotFoundException(__('Order not found'));
}
diff --git a/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php b/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php
index 49abae7e49..4647cac685 100644
--- a/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php
+++ b/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php
@@ -36,12 +36,13 @@
use HiEvents\Services\Application\Handlers\Order\DTO\CompleteOrderProductDataDTO;
use HiEvents\Services\Application\Handlers\Order\DTO\CreatedProductDataDTO;
use HiEvents\Services\Application\Handlers\Order\DTO\OrderQuestionsDTO;
+use HiEvents\Services\Domain\Order\OccurrenceStatusValidator;
use HiEvents\Services\Domain\Payment\Stripe\EventHandlers\PaymentIntentSucceededHandler;
use HiEvents\Services\Domain\Product\ProductQuantityUpdateService;
use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService;
-use HiEvents\Services\Infrastructure\Session\CheckoutSessionManagementService;
use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType;
use HiEvents\Services\Infrastructure\DomainEvents\Events\OrderEvent;
+use HiEvents\Services\Infrastructure\Session\CheckoutSessionManagementService;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use RuntimeException;
@@ -53,18 +54,17 @@
class CompleteOrderHandler
{
public function __construct(
- private readonly OrderRepositoryInterface $orderRepository,
- private readonly AffiliateRepositoryInterface $affiliateRepository,
- private readonly AttendeeRepositoryInterface $attendeeRepository,
+ private readonly OrderRepositoryInterface $orderRepository,
+ private readonly AffiliateRepositoryInterface $affiliateRepository,
+ private readonly AttendeeRepositoryInterface $attendeeRepository,
private readonly QuestionAnswerRepositoryInterface $questionAnswersRepository,
- private readonly ProductQuantityUpdateService $productQuantityUpdateService,
- private readonly ProductPriceRepositoryInterface $productPriceRepository,
- private readonly DomainEventDispatcherService $domainEventDispatcherService,
- private readonly EventSettingsRepositoryInterface $eventSettingsRepository,
- private readonly CheckoutSessionManagementService $sessionManagementService,
- )
- {
- }
+ private readonly ProductQuantityUpdateService $productQuantityUpdateService,
+ private readonly ProductPriceRepositoryInterface $productPriceRepository,
+ private readonly DomainEventDispatcherService $domainEventDispatcherService,
+ private readonly EventSettingsRepositoryInterface $eventSettingsRepository,
+ private readonly CheckoutSessionManagementService $sessionManagementService,
+ private readonly OccurrenceStatusValidator $occurrenceStatusValidator,
+ ) {}
/**
* @throws ResourceNotFoundException|ResourceConflictException|RuntimeException
@@ -81,6 +81,8 @@ public function handle(string $orderShortId, CompleteOrderDTO $orderData): Order
$order = $this->getOrder($orderShortId);
+ $this->occurrenceStatusValidator->assertOrderOccurrencesArePurchasable($order);
+
$updatedOrder = $this->updateOrder($order, $orderDTO);
$this->createAttendees($orderData->products, $order, $orderDTO, $eventSettings);
@@ -95,7 +97,7 @@ public function handle(string $orderShortId, CompleteOrderDTO $orderData): Order
*
* @see PaymentIntentSucceededHandler
*/
- if (!$order->isPaymentRequired()) {
+ if (! $order->isPaymentRequired()) {
$this->productQuantityUpdateService->updateQuantitiesFromOrder($updatedOrder);
}
@@ -121,16 +123,16 @@ public function handle(string $orderShortId, CompleteOrderDTO $orderData): Order
}
/**
- * @param Collection $orderProducts
+ * @param Collection $orderProducts
+ *
* @throws Exception
*/
private function createAttendees(
- Collection $orderProducts,
- OrderDomainObject $order,
- CompleteOrderOrderDTO $orderDTO,
- EventSettingDomainObject $eventSettings,
- ): void
- {
+ Collection $orderProducts,
+ OrderDomainObject $order,
+ CompleteOrderOrderDTO $orderDTO,
+ EventSettingDomainObject $eventSettings,
+ ): void {
$inserts = [];
$createdProductData = collect();
@@ -144,13 +146,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)
+ 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 +164,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,
@@ -184,7 +198,7 @@ private function createAttendees(
));
}
- if (!$this->attendeeRepository->insert($inserts)) {
+ if (! $this->attendeeRepository->insert($inserts)) {
throw new RuntimeException(__('Failed to create attendee'));
}
@@ -210,16 +224,16 @@ private function createOrderQuestions(Collection $questions, OrderDomainObject $
}
/**
- * @param Collection $createdAttendees
- * @param Collection $productPrices
+ * @param Collection $createdAttendees
+ * @param Collection $productPrices
+ *
* @throws ResourceConflictException|Exception
*/
private function createProductQuestions(
- Collection $createdAttendees,
+ Collection $createdAttendees,
OrderDomainObject $order,
- Collection $productPrices
- ): void
- {
+ Collection $productPrices
+ ): void {
$newAttendees = $this->attendeeRepository->findWhereIn(
field: AttendeeDomainObjectAbstract::SHORT_ID,
values: $createdAttendees->pluck('shortId')->toArray(),
@@ -233,12 +247,12 @@ private function createProductQuestions(
}
$productId = $productPrices->first(
- fn(ProductPriceDomainObject $productPrice) => $productPrice->getId() === $productRequestData->product_price_id
+ fn (ProductPriceDomainObject $productPrice) => $productPrice->getId() === $productRequestData->product_price_id
)->getProductId();
// This will be null for non-ticket products
$insertedAttendee = $newAttendees->first(
- fn(AttendeeDomainObject $attendee) => $attendee->getShortId() === $createdAttendee->shortId,
+ fn (AttendeeDomainObject $attendee) => $attendee->getShortId() === $createdAttendee->shortId,
);
foreach ($productRequestData->questions as $question) {
@@ -293,7 +307,7 @@ private function getOrder(string $orderShortId): OrderDomainObject
}
if ($order->getSessionId() === null
- || !$this->sessionManagementService->verifySession($order->getSessionId())) {
+ || ! $this->sessionManagementService->verifySession($order->getSessionId())) {
throw new UnauthorizedException(
__('Sorry, we could not verify your session. Please restart your order.')
);
@@ -328,7 +342,7 @@ private function updateOrder(OrderDomainObject $order, CompleteOrderOrderDTO $or
);
// Update affiliate sales if this is a free order (no payment required) and has an affiliate
- if (!$order->isPaymentRequired() && $updatedOrder->getAffiliateId()) {
+ if (! $order->isPaymentRequired() && $updatedOrder->getAffiliateId()) {
$this->affiliateRepository->incrementSales(
$updatedOrder->getAffiliateId(),
$updatedOrder->getTotalGross()
@@ -346,9 +360,9 @@ private function updateOrder(OrderDomainObject $order, CompleteOrderOrderDTO $or
private function validateProductPriceIdsMatchOrder(OrderDomainObject $order, Collection $productsPrices): void
{
$orderProductPriceIds = $order->getOrderItems()
- ?->map(fn(OrderItemDomainObject $orderItem) => $orderItem->getProductPriceId())->toArray();
+ ?->map(fn (OrderItemDomainObject $orderItem) => $orderItem->getProductPriceId())->toArray();
- $productsPricesIds = $productsPrices->map(fn(ProductPriceDomainObject $productPrice) => $productPrice->getId());
+ $productsPricesIds = $productsPrices->map(fn (ProductPriceDomainObject $productPrice) => $productPrice->getId());
if ($productsPricesIds->diff($orderProductPriceIds)->isNotEmpty()) {
throw new ResourceConflictException(__('There is an unexpected product price ID in the order'));
@@ -361,15 +375,15 @@ private function validateProductPriceIdsMatchOrder(OrderDomainObject $order, Col
private function validateTicketProductsCount(OrderDomainObject $order, Collection $attendees): void
{
$orderAttendeeCount = $order->getOrderItems()
- ?->filter(fn(OrderItemDomainObject $orderItem) => $orderItem->getProductType() === ProductType::TICKET->name)
- ?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity());
+ ?->filter(fn (OrderItemDomainObject $orderItem) => $orderItem->getProductType() === ProductType::TICKET->name)
+ ?->sum(fn (OrderItemDomainObject $orderItem) => $orderItem->getQuantity());
$ticketAttendeeCount = $attendees
->filter(
- fn(CompleteOrderProductDataDTO $attendee) => $this->getProductTypeFromPriceId(
- $attendee->product_price_id,
- $order->getOrderItems()
- ) === ProductType::TICKET->name)
+ fn (CompleteOrderProductDataDTO $attendee) => $this->getProductTypeFromPriceId(
+ $attendee->product_price_id,
+ $order->getOrderItems()
+ ) === ProductType::TICKET->name)
->count();
if ($orderAttendeeCount !== $ticketAttendeeCount) {
@@ -381,7 +395,7 @@ private function validateTicketProductsCount(OrderDomainObject $order, Collectio
private function getProductTypeFromPriceId(int $priceId, Collection $orderItems): string
{
- return $orderItems->first(fn(OrderItemDomainObject $orderItem) => $orderItem->getProductPriceId() === $priceId)
+ return $orderItems->first(fn (OrderItemDomainObject $orderItem) => $orderItem->getProductPriceId() === $priceId)
->getProductType();
}
}
diff --git a/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php b/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php
index 9ae7d2fce0..e80a819045 100644
--- a/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php
+++ b/backend/app/Services/Application/Handlers/Order/CreateOrderHandler.php
@@ -5,6 +5,7 @@
namespace HiEvents\Services\Application\Handlers\Order;
use HiEvents\DomainObjects\AffiliateDomainObject;
+use HiEvents\DomainObjects\Enums\ProductType;
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\Generated\AffiliateDomainObjectAbstract;
@@ -17,11 +18,15 @@
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface;
use HiEvents\Services\Application\Handlers\Order\DTO\CreateOrderPublicDTO;
+use HiEvents\Services\Domain\EventOccurrence\OccurrencePurchaseEligibilityService;
use HiEvents\Services\Domain\Order\OrderItemProcessingService;
use HiEvents\Services\Domain\Order\OrderManagementService;
-use HiEvents\Services\Domain\PromoCode\PromoCodeUsageValidationService;
use HiEvents\Services\Domain\Product\AvailableProductQuantitiesFetchService;
+use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesDTO;
+use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesResponseDTO;
+use HiEvents\Services\Domain\PromoCode\PromoCodeUsageValidationService;
use Illuminate\Database\DatabaseManager;
+use Illuminate\Support\Collection;
use Illuminate\Validation\UnauthorizedException;
use Illuminate\Validation\ValidationException;
use Throwable;
@@ -29,27 +34,25 @@
class CreateOrderHandler
{
public function __construct(
- private readonly EventRepositoryInterface $eventRepository,
- private readonly PromoCodeRepositoryInterface $promoCodeRepository,
- private readonly PromoCodeUsageValidationService $promoCodeUsageValidationService,
- private readonly AffiliateRepositoryInterface $affiliateRepository,
- private readonly OrderManagementService $orderManagementService,
- private readonly OrderItemProcessingService $orderItemProcessingService,
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly PromoCodeRepositoryInterface $promoCodeRepository,
+ private readonly PromoCodeUsageValidationService $promoCodeUsageValidationService,
+ private readonly AffiliateRepositoryInterface $affiliateRepository,
+ private readonly OrderManagementService $orderManagementService,
+ private readonly OrderItemProcessingService $orderItemProcessingService,
private readonly AvailableProductQuantitiesFetchService $availableProductQuantitiesFetchService,
- private readonly DatabaseManager $databaseManager,
- )
- {
- }
+ private readonly OccurrencePurchaseEligibilityService $occurrencePurchaseEligibilityService,
+ private readonly DatabaseManager $databaseManager,
+ ) {}
/**
* @throws Throwable
*/
public function handle(
- int $eventId,
+ int $eventId,
CreateOrderPublicDTO $createOrderPublicDTO,
- bool $deleteExistingOrdersForSession = true
- ): OrderDomainObject
- {
+ bool $deleteExistingOrdersForSession = true
+ ): OrderDomainObject {
return $this->databaseManager->transaction(function () use ($eventId, $createOrderPublicDTO, $deleteExistingOrdersForSession) {
$this->databaseManager->statement('SELECT pg_advisory_xact_lock(?)', [$eventId]);
@@ -59,7 +62,6 @@ public function handle(
$this->validateEventStatus($event, $createOrderPublicDTO);
- // Remove the session's stale reservations before promo usage is counted in getPromoCode()
if ($deleteExistingOrdersForSession) {
$this->orderManagementService->deleteExistingOrders($eventId, $createOrderPublicDTO->session_identifier);
}
@@ -101,7 +103,7 @@ private function getPromoCode(CreateOrderPublicDTO $createOrderPublicDTO, int $e
PromoCodeDomainObjectAbstract::EVENT_ID => $eventId,
]);
- if (!$this->promoCodeUsageValidationService->isPromoCodeUsable($promoCode)) {
+ if (! $this->promoCodeUsageValidationService->isPromoCodeUsable($promoCode)) {
return null;
}
@@ -123,7 +125,7 @@ private function getAffiliate(CreateOrderPublicDTO $createOrderPublicDTO, int $e
public function validateEventStatus(EventDomainObject $event, CreateOrderPublicDTO $createOrderPublicDTO): void
{
- if (!$createOrderPublicDTO->is_user_authenticated && $event->getStatus() !== EventStatus::LIVE->name) {
+ if (! $createOrderPublicDTO->is_user_authenticated && $event->getStatus() !== EventStatus::LIVE->name) {
throw new UnauthorizedException(
__('This event is not live.')
);
@@ -135,21 +137,64 @@ public function validateEventStatus(EventDomainObject $event, CreateOrderPublicD
*/
private function validateProductAvailability(int $eventId, CreateOrderPublicDTO $createOrderPublicDTO): void
{
- $availability = $this->availableProductQuantitiesFetchService
- ->getAvailableProductQuantities($eventId, ignoreCache: true);
+ $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,
+ );
+
+ if ($occurrenceId) {
+ $this->occurrencePurchaseEligibilityService->assertOccurrencePurchasable(
+ eventId: $eventId,
+ occurrenceId: (int) $occurrenceId,
+ additionalQuantity: $this->sumTicketQuantities($products, $availability),
+ occurrence: $availability->occurrence,
+ reservedQuantity: $availability->occurrenceReservedQuantity,
+ );
+ }
+
+ $this->assertQuantitiesAvailable($products, $availability);
+ }
+
+ if ($productsByOccurrence->count() > 1) {
+ $this->assertQuantitiesAvailable(
+ $createOrderPublicDTO->products,
+ $this->availableProductQuantitiesFetchService->getAvailableProductQuantities($eventId, ignoreCache: true),
+ );
+ }
+ }
- foreach ($createOrderPublicDTO->products as $product) {
+ /**
+ * @throws ValidationException
+ */
+ private function assertQuantitiesAvailable(Collection $products, AvailableProductQuantitiesResponseDTO $availability): void
+ {
+ $requestedQuantities = [];
+ foreach ($products as $product) {
foreach ($product->quantities as $priceQuantity) {
if ($priceQuantity->quantity <= 0) {
continue;
}
+ $requestedQuantities[$product->product_id][$priceQuantity->price_id] =
+ ($requestedQuantities[$product->product_id][$priceQuantity->price_id] ?? 0) + $priceQuantity->quantity;
+ }
+ }
+
+ foreach ($requestedQuantities as $productId => $priceQuantities) {
+ foreach ($priceQuantities as $priceId => $requestedQuantity) {
$available = $availability->productQuantities
- ->where('product_id', $product->product_id)
- ->where('price_id', $priceQuantity->price_id)
+ ->where('product_id', $productId)
+ ->where('price_id', $priceId)
->first()?->quantity_available ?? 0;
- if ($priceQuantity->quantity > $available) {
+ if ($requestedQuantity > $available) {
throw ValidationException::withMessages([
'products' => __('Not enough products available. Please try again.'),
]);
@@ -157,4 +202,17 @@ private function validateProductAvailability(int $eventId, CreateOrderPublicDTO
}
}
}
+
+ private function sumTicketQuantities(Collection $products, AvailableProductQuantitiesResponseDTO $availability): int
+ {
+ $ticketProductIds = $availability->productQuantities
+ ->filter(fn (AvailableProductQuantitiesDTO $dto) => $dto->product_type === ProductType::TICKET->name)
+ ->pluck('product_id')
+ ->unique()
+ ->all();
+
+ return (int) $products
+ ->filter(fn (DTO\ProductOrderDetailsDTO $product) => in_array($product->product_id, $ticketProductIds, true))
+ ->sum(fn (DTO\ProductOrderDetailsDTO $product) => $product->quantities->sum('quantity'));
+ }
}
diff --git a/backend/app/Services/Application/Handlers/Order/DTO/CancelOrderDTO.php b/backend/app/Services/Application/Handlers/Order/DTO/CancelOrderDTO.php
index e8941d2516..09534aa81f 100644
--- a/backend/app/Services/Application/Handlers/Order/DTO/CancelOrderDTO.php
+++ b/backend/app/Services/Application/Handlers/Order/DTO/CancelOrderDTO.php
@@ -10,7 +10,5 @@ public function __construct(
public int $eventId,
public int $orderId,
public bool $refund = false
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Order/DTO/CompleteOrderDTO.php b/backend/app/Services/Application/Handlers/Order/DTO/CompleteOrderDTO.php
index 0532294c2a..ff8911bcf3 100644
--- a/backend/app/Services/Application/Handlers/Order/DTO/CompleteOrderDTO.php
+++ b/backend/app/Services/Application/Handlers/Order/DTO/CompleteOrderDTO.php
@@ -9,16 +9,12 @@
class CompleteOrderDTO extends BaseDTO
{
/**
- * @param CompleteOrderOrderDTO $order
- * @param Collection $products
- * @param int $event_id
+ * @param Collection $products
*/
public function __construct(
public CompleteOrderOrderDTO $order,
#[CollectionOf(CompleteOrderProductDataDTO::class)]
- public Collection $products,
- public int $event_id,
- )
- {
- }
+ public Collection $products,
+ public int $event_id,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Order/DTO/CompleteOrderOrderDTO.php b/backend/app/Services/Application/Handlers/Order/DTO/CompleteOrderOrderDTO.php
index d44442c2a1..3575af9e1e 100644
--- a/backend/app/Services/Application/Handlers/Order/DTO/CompleteOrderOrderDTO.php
+++ b/backend/app/Services/Application/Handlers/Order/DTO/CompleteOrderOrderDTO.php
@@ -9,22 +9,15 @@
class CompleteOrderOrderDTO extends BaseDTO
{
/**
- * @param string $first_name
- * @param string $last_name
- * @param string $email
- * @param Collection|null $questions
- * @param array|null $address
- * @param bool $opted_into_marketing
+ * @param Collection|null $questions
*/
public function __construct(
- public readonly string $first_name,
- public readonly string $last_name,
- public readonly string $email,
+ public readonly string $first_name,
+ public readonly string $last_name,
+ public readonly string $email,
#[CollectionOf(OrderQuestionsDTO::class)]
public readonly ?Collection $questions,
- public readonly ?array $address = [],
- public readonly bool $opted_into_marketing = false,
- )
- {
- }
+ public readonly ?array $address = [],
+ public readonly bool $opted_into_marketing = false,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Order/DTO/CompleteOrderProductDataDTO.php b/backend/app/Services/Application/Handlers/Order/DTO/CompleteOrderProductDataDTO.php
index 7119065e8f..746cbb794e 100644
--- a/backend/app/Services/Application/Handlers/Order/DTO/CompleteOrderProductDataDTO.php
+++ b/backend/app/Services/Application/Handlers/Order/DTO/CompleteOrderProductDataDTO.php
@@ -9,17 +9,15 @@
class CompleteOrderProductDataDTO extends BaseDTO
{
public function __construct(
- public readonly int $product_price_id,
+ public readonly int $product_price_id,
#[CollectionOf(OrderQuestionsDTO::class)]
public readonly ?Collection $questions = null,
// Only relevant for products with product type 'TICKET'
- public readonly ?string $first_name = null,
- public readonly ?string $last_name = null,
- public readonly ?string $email = null,
- )
- {
- }
+ public readonly ?string $first_name = null,
+ public readonly ?string $last_name = null,
+ public readonly ?string $email = null,
+ ) {}
public function isTicketProduct(): bool
{
diff --git a/backend/app/Services/Application/Handlers/Order/DTO/CreateOrderPublicDTO.php b/backend/app/Services/Application/Handlers/Order/DTO/CreateOrderPublicDTO.php
index 33d690f4b9..94275ae178 100644
--- a/backend/app/Services/Application/Handlers/Order/DTO/CreateOrderPublicDTO.php
+++ b/backend/app/Services/Application/Handlers/Order/DTO/CreateOrderPublicDTO.php
@@ -12,12 +12,10 @@ public function __construct(
* @var Collection
*/
public readonly Collection $products,
- public readonly bool $is_user_authenticated,
- public readonly string $session_identifier,
- public readonly ?string $order_locale = null,
- public readonly ?string $promo_code = null,
- public readonly ?string $affiliate_code = null,
- )
- {
- }
+ public readonly bool $is_user_authenticated,
+ public readonly string $session_identifier,
+ public readonly ?string $order_locale = null,
+ public readonly ?string $promo_code = null,
+ public readonly ?string $affiliate_code = null,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Order/DTO/CreatedProductDataDTO.php b/backend/app/Services/Application/Handlers/Order/DTO/CreatedProductDataDTO.php
index fc9573e119..3d895c8e58 100644
--- a/backend/app/Services/Application/Handlers/Order/DTO/CreatedProductDataDTO.php
+++ b/backend/app/Services/Application/Handlers/Order/DTO/CreatedProductDataDTO.php
@@ -8,8 +8,6 @@ class CreatedProductDataDTO extends BaseDTO
{
public function __construct(
public readonly CompleteOrderProductDataDTO $productRequestData,
- public readonly ?string $shortId,
- )
- {
- }
+ public readonly ?string $shortId,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Order/DTO/EditOrderDTO.php b/backend/app/Services/Application/Handlers/Order/DTO/EditOrderDTO.php
index e53b980aa9..4af5be7ae9 100644
--- a/backend/app/Services/Application/Handlers/Order/DTO/EditOrderDTO.php
+++ b/backend/app/Services/Application/Handlers/Order/DTO/EditOrderDTO.php
@@ -7,13 +7,11 @@
class EditOrderDTO extends BaseDTO
{
public function __construct(
- public int $id,
- public int $eventId,
- public string $firstName,
- public string $lastName,
- public string $email,
+ public int $id,
+ public int $eventId,
+ public string $firstName,
+ public string $lastName,
+ public string $email,
public ?string $notes,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Order/DTO/GetOrderInvoiceDTO.php b/backend/app/Services/Application/Handlers/Order/DTO/GetOrderInvoiceDTO.php
index 91bfed017c..af395b3022 100644
--- a/backend/app/Services/Application/Handlers/Order/DTO/GetOrderInvoiceDTO.php
+++ b/backend/app/Services/Application/Handlers/Order/DTO/GetOrderInvoiceDTO.php
@@ -9,7 +9,5 @@ class GetOrderInvoiceDTO extends BaseDTO
public function __construct(
public readonly int $orderId,
public readonly int $eventId,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Order/DTO/GetOrderPublicDTO.php b/backend/app/Services/Application/Handlers/Order/DTO/GetOrderPublicDTO.php
index 386fee201f..2c0d097871 100644
--- a/backend/app/Services/Application/Handlers/Order/DTO/GetOrderPublicDTO.php
+++ b/backend/app/Services/Application/Handlers/Order/DTO/GetOrderPublicDTO.php
@@ -7,10 +7,8 @@
class GetOrderPublicDTO extends BaseDTO
{
public function __construct(
- public int $eventId,
+ public int $eventId,
public string $orderShortId,
- public bool $includeEventInResponse = false,
- )
- {
- }
+ public bool $includeEventInResponse = false,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Order/DTO/MarkOrderAsPaidDTO.php b/backend/app/Services/Application/Handlers/Order/DTO/MarkOrderAsPaidDTO.php
index cb028ffb52..56a1d69099 100644
--- a/backend/app/Services/Application/Handlers/Order/DTO/MarkOrderAsPaidDTO.php
+++ b/backend/app/Services/Application/Handlers/Order/DTO/MarkOrderAsPaidDTO.php
@@ -9,7 +9,5 @@ class MarkOrderAsPaidDTO extends BaseDTO
public function __construct(
public readonly int $eventId,
public readonly int $orderId,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Order/DTO/OrderQuestionsDTO.php b/backend/app/Services/Application/Handlers/Order/DTO/OrderQuestionsDTO.php
index e7fbc590c1..4d602553f0 100644
--- a/backend/app/Services/Application/Handlers/Order/DTO/OrderQuestionsDTO.php
+++ b/backend/app/Services/Application/Handlers/Order/DTO/OrderQuestionsDTO.php
@@ -8,8 +8,6 @@ class OrderQuestionsDTO extends BaseDTO
{
public function __construct(
public readonly string|int $question_id,
- public readonly array $response,
- )
- {
- }
+ public readonly array $response,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Order/DTO/ProductOrderDetailsDTO.php b/backend/app/Services/Application/Handlers/Order/DTO/ProductOrderDetailsDTO.php
index 5fecfac13f..86646adad7 100644
--- a/backend/app/Services/Application/Handlers/Order/DTO/ProductOrderDetailsDTO.php
+++ b/backend/app/Services/Application/Handlers/Order/DTO/ProductOrderDetailsDTO.php
@@ -12,8 +12,7 @@ class ProductOrderDetailsDTO extends BaseDTO
public function __construct(
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/DTO/RefundOrderDTO.php b/backend/app/Services/Application/Handlers/Order/DTO/RefundOrderDTO.php
index b4c8d58697..3f67634a57 100644
--- a/backend/app/Services/Application/Handlers/Order/DTO/RefundOrderDTO.php
+++ b/backend/app/Services/Application/Handlers/Order/DTO/RefundOrderDTO.php
@@ -7,12 +7,10 @@
class RefundOrderDTO extends BaseDTO
{
public function __construct(
- public readonly int $event_id,
- public readonly int $order_id,
+ public readonly int $event_id,
+ public readonly int $order_id,
public readonly float $amount,
- public readonly bool $notify_buyer,
- public readonly bool $cancel_order
- )
- {
- }
+ public readonly bool $notify_buyer,
+ public readonly bool $cancel_order
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Order/DTO/TransitionOrderToOfflinePaymentPublicDTO.php b/backend/app/Services/Application/Handlers/Order/DTO/TransitionOrderToOfflinePaymentPublicDTO.php
index 3b9da9d414..3e10d1d1e1 100644
--- a/backend/app/Services/Application/Handlers/Order/DTO/TransitionOrderToOfflinePaymentPublicDTO.php
+++ b/backend/app/Services/Application/Handlers/Order/DTO/TransitionOrderToOfflinePaymentPublicDTO.php
@@ -8,7 +8,5 @@ class TransitionOrderToOfflinePaymentPublicDTO extends BaseDTO
{
public function __construct(
public readonly string $orderShortId,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Order/EditOrderHandler.php b/backend/app/Services/Application/Handlers/Order/EditOrderHandler.php
index 9279ba0139..c80fe8ebe5 100644
--- a/backend/app/Services/Application/Handlers/Order/EditOrderHandler.php
+++ b/backend/app/Services/Application/Handlers/Order/EditOrderHandler.php
@@ -12,10 +12,8 @@ class EditOrderHandler
{
public function __construct(
private readonly EditOrderService $editOrderService,
- private readonly LoggerInterface $logger,
- )
- {
- }
+ private readonly LoggerInterface $logger,
+ ) {}
/**
* @throws Throwable
diff --git a/backend/app/Services/Application/Handlers/Order/GetOrderInvoiceHandler.php b/backend/app/Services/Application/Handlers/Order/GetOrderInvoiceHandler.php
index bf4c8bfe97..df53e86af2 100644
--- a/backend/app/Services/Application/Handlers/Order/GetOrderInvoiceHandler.php
+++ b/backend/app/Services/Application/Handlers/Order/GetOrderInvoiceHandler.php
@@ -10,9 +10,7 @@ class GetOrderInvoiceHandler
{
public function __construct(
private readonly GenerateOrderInvoicePDFService $generateOrderInvoicePDFService,
- )
- {
- }
+ ) {}
public function handle(GetOrderInvoiceDTO $command): InvoicePdfResponseDTO
{
diff --git a/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php b/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php
index d6eecedef3..4abbd2d564 100644
--- a/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php
+++ b/backend/app/Services/Application/Handlers/Order/GetOrderPublicHandler.php
@@ -4,12 +4,15 @@
use HiEvents\DomainObjects\AttendeeDomainObject;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventLocationDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\Generated\EventDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\OrganizerDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract;
use HiEvents\DomainObjects\ImageDomainObject;
use HiEvents\DomainObjects\InvoiceDomainObject;
+use HiEvents\DomainObjects\LocationDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
@@ -27,18 +30,16 @@
class GetOrderPublicHandler
{
public function __construct(
- private readonly OrderRepositoryInterface $orderRepository,
- private readonly CheckoutSessionManagementService $sessionIdentifierService,
+ private readonly OrderRepositoryInterface $orderRepository,
+ private readonly CheckoutSessionManagementService $sessionIdentifierService,
private readonly OfflinePaymentInstructionsRenderService $offlinePaymentInstructionsRenderService,
- )
- {
- }
+ ) {}
public function handle(GetOrderPublicDTO $getOrderData): OrderDomainObject
{
$order = $this->getOrderDomainObject($getOrderData);
- if (!$order) {
+ if (! $order) {
throw new ResourceNotFoundException(__('Order not found'));
}
@@ -58,7 +59,7 @@ public function handle(GetOrderPublicDTO $getOrderData): OrderDomainObject
private function verifySessionId(string $orderSessionId): void
{
- if (!$this->sessionIdentifierService->verifySession($orderSessionId)) {
+ if (! $this->sessionIdentifierService->verifySession($orderSessionId)) {
throw new UnauthorizedException(
__('Sorry, we could not verify your session. Please restart your order.')
);
@@ -76,15 +77,43 @@ private function getOrderDomainObject(GetOrderPublicDTO $getOrderData): ?OrderDo
nested: [
new Relationship(
domainObject: ProductPriceDomainObject::class,
- )
+ ),
],
name: ProductDomainObjectAbstract::SINGULAR_NAME,
- )
+ ),
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: EventLocationDomainObject::class,
+ nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ],
+ name: 'event_location',
+ ),
+ ],
+ name: 'event_occurrence',
+ ),
],
))
->loadRelation(new Relationship(domainObject: InvoiceDomainObject::class))
->loadRelation(new Relationship(
domainObject: OrderItemDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: EventLocationDomainObject::class,
+ nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ],
+ name: 'event_location',
+ ),
+ ],
+ name: 'event_occurrence',
+ ),
+ ],
));
if ($getOrderData->includeEventInResponse) {
@@ -100,7 +129,26 @@ private function getOrderDomainObject(GetOrderPublicDTO $getOrderData): ?OrderDo
),
new Relationship(
domainObject: ImageDomainObject::class,
- )
+ ),
+ new Relationship(
+ domainObject: EventLocationDomainObject::class,
+ nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ],
+ name: 'event_location',
+ ),
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: EventLocationDomainObject::class,
+ nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ],
+ name: 'event_location',
+ ),
+ ],
+ ),
],
name: EventDomainObjectAbstract::SINGULAR_NAME
));
diff --git a/backend/app/Services/Application/Handlers/Order/MarkOrderAsPaidHandler.php b/backend/app/Services/Application/Handlers/Order/MarkOrderAsPaidHandler.php
index 98aa18d7d5..063f08cdcc 100644
--- a/backend/app/Services/Application/Handlers/Order/MarkOrderAsPaidHandler.php
+++ b/backend/app/Services/Application/Handlers/Order/MarkOrderAsPaidHandler.php
@@ -13,10 +13,8 @@ class MarkOrderAsPaidHandler
{
public function __construct(
private readonly MarkOrderAsPaidService $markOrderAsPaidService,
- private readonly LoggerInterface $logger,
- )
- {
- }
+ private readonly LoggerInterface $logger,
+ ) {}
/**
* @throws ResourceConflictException|Throwable
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..8136486895 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;
@@ -35,20 +36,17 @@
readonly class CreatePaymentIntentHandler
{
public function __construct(
- private OrderRepositoryInterface $orderRepository,
+ private OrderRepositoryInterface $orderRepository,
private StripePaymentIntentCreationService $stripePaymentService,
- private CheckoutSessionManagementService $sessionIdentifierService,
- private StripePaymentsRepositoryInterface $stripePaymentsRepository,
- private AccountRepositoryInterface $accountRepository,
- private StripeClientFactory $stripeClientFactory,
- private StripeConfigurationService $stripeConfigurationService,
- )
- {
- }
+ private CheckoutSessionManagementService $sessionIdentifierService,
+ private StripePaymentsRepositoryInterface $stripePaymentsRepository,
+ private AccountRepositoryInterface $accountRepository,
+ private OrganizerRepositoryInterface $organizerRepository,
+ private StripeClientFactory $stripeClientFactory,
+ private StripeConfigurationService $stripeConfigurationService,
+ ) {}
/**
- * @param string $orderShortId
- * @return CreatePaymentIntentResponseDTO
* @throws CreatePaymentIntentFailedException
* @throws MathException
* @throws NumberFormatException
@@ -65,7 +63,7 @@ public function handle(string $orderShortId): CreatePaymentIntentResponseDTO
->loadRelation(new Relationship(EventDomainObject::class, name: 'event'))
->findByShortId($orderShortId);
- if (!$order || !$this->sessionIdentifierService->verifySession($order->getSessionId())) {
+ if (! $order || ! $this->sessionIdentifierService->verifySession($order->getSessionId())) {
throw new UnauthorizedException(__('Sorry, we could not verify your session. Please create a new order.'));
}
@@ -73,32 +71,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(),
@@ -116,7 +112,7 @@ public function handle(string $orderShortId): CreatePaymentIntentResponseDTO
$description = __(':item_count item(s) for event: :event_name (Order :order_short_id)', [
'event_name' => Str::limit($order->getEvent()?->getTitle() ?? __('Event'), 75),
'order_short_id' => $orderShortId,
- 'item_count' => $order->getOrderItems()->sum(fn(OrderItemDomainObject $item) => $item->getQuantity()),
+ 'item_count' => $order->getOrderItems()->sum(fn (OrderItemDomainObject $item) => $item->getQuantity()),
]);
$paymentIntent = $this->stripePaymentService->createPaymentIntentWithClient(
@@ -126,8 +122,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/Order/Payment/Stripe/DTO/StripePaymentIntentPublicDTO.php b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/DTO/StripePaymentIntentPublicDTO.php
index ae295d5199..66b0fd6125 100644
--- a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/DTO/StripePaymentIntentPublicDTO.php
+++ b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/DTO/StripePaymentIntentPublicDTO.php
@@ -10,7 +10,5 @@ public function __construct(
public string $status,
public string $paymentIntentId,
public string $amount,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/DTO/StripeWebhookDTO.php b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/DTO/StripeWebhookDTO.php
index 459c3a6ea5..0bcd295a0b 100644
--- a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/DTO/StripeWebhookDTO.php
+++ b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/DTO/StripeWebhookDTO.php
@@ -9,7 +9,5 @@ class StripeWebhookDTO extends BaseDTO
public function __construct(
public readonly string $headerSignature,
public readonly string $payload,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/GetPaymentIntentHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/GetPaymentIntentHandler.php
index b129c1425b..efd94ef6f7 100644
--- a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/GetPaymentIntentHandler.php
+++ b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/GetPaymentIntentHandler.php
@@ -17,13 +17,11 @@
class GetPaymentIntentHandler
{
public function __construct(
- private readonly StripeClientFactory $stripeClientFactory,
- private readonly OrderRepositoryInterface $orderRepository,
- private readonly LoggerInterface $logger,
+ private readonly StripeClientFactory $stripeClientFactory,
+ private readonly OrderRepositoryInterface $orderRepository,
+ private readonly LoggerInterface $logger,
private readonly PaymentIntentSucceededHandler $paymentIntentSucceededHandler,
- )
- {
- }
+ ) {}
public function handle(int $eventId, string $orderShortId): StripePaymentIntentPublicDTO
{
@@ -35,7 +33,7 @@ public function handle(int $eventId, string $orderShortId): StripePaymentIntentP
))
->findFirstWhere([
'event_id' => $eventId,
- 'short_id' => $orderShortId
+ 'short_id' => $orderShortId,
]);
$accountId = $order->getStripePayment()->getConnectedAccountId();
@@ -55,7 +53,7 @@ public function handle(int $eventId, string $orderShortId): StripePaymentIntentP
'payment_intent_id' => $order->getStripePayment()->getPaymentIntentId(),
]);
- throw new ResourceNotFoundException('Payment intent not found: ' . $e->getMessage());
+ throw new ResourceNotFoundException('Payment intent not found: '.$e->getMessage());
}
// If the payment intent is a success and the order's payment status is not received, we manually handle the event here.
diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/IncomingWebhookHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/IncomingWebhookHandler.php
index 13e04f003b..9870b6d1c7 100644
--- a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/IncomingWebhookHandler.php
+++ b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/IncomingWebhookHandler.php
@@ -10,6 +10,7 @@
use HiEvents\Services\Domain\Payment\Stripe\EventHandlers\PaymentIntentFailedHandler;
use HiEvents\Services\Domain\Payment\Stripe\EventHandlers\PaymentIntentSucceededHandler;
use HiEvents\Services\Domain\Payment\Stripe\EventHandlers\PayoutPaidHandler;
+use HiEvents\Services\Infrastructure\Stripe\StripeConfigurationService;
use Illuminate\Cache\Repository;
use Illuminate\Log\Logger;
use JsonException;
@@ -19,7 +20,6 @@
use Stripe\Webhook;
use Throwable;
use UnexpectedValueException;
-use HiEvents\Services\Infrastructure\Stripe\StripeConfigurationService;
class IncomingWebhookHandler
{
@@ -37,18 +37,16 @@ class IncomingWebhookHandler
];
public function __construct(
- private readonly ChargeRefundUpdatedHandler $refundEventHandlerService,
- private readonly ChargeSucceededHandler $chargeSucceededHandler,
+ private readonly ChargeRefundUpdatedHandler $refundEventHandlerService,
+ private readonly ChargeSucceededHandler $chargeSucceededHandler,
private readonly PaymentIntentSucceededHandler $paymentIntentSucceededHandler,
- private readonly PaymentIntentFailedHandler $paymentIntentFailedHandler,
- private readonly AccountUpdateHandler $accountUpdateHandler,
- private readonly PayoutPaidHandler $payoutPaidHandler,
- private readonly Logger $logger,
- private readonly Repository $cache,
- private readonly StripeConfigurationService $stripeConfigurationService,
- )
- {
- }
+ private readonly PaymentIntentFailedHandler $paymentIntentFailedHandler,
+ private readonly AccountUpdateHandler $accountUpdateHandler,
+ private readonly PayoutPaidHandler $payoutPaidHandler,
+ private readonly Logger $logger,
+ private readonly Repository $cache,
+ private readonly StripeConfigurationService $stripeConfigurationService,
+ ) {}
/**
* @throws SignatureVerificationException
@@ -60,7 +58,7 @@ public function handle(StripeWebhookDTO $webhookDTO): void
try {
$event = $this->constructEventWithValidPlatform($webhookDTO);
- if (!in_array($event->type, self::$validEvents, true)) {
+ if (! in_array($event->type, self::$validEvents, true)) {
$this->logger->debug(__('Received a :event Stripe event, which has no handler', [
'event' => $event->type,
]), [
@@ -81,7 +79,7 @@ public function handle(StripeWebhookDTO $webhookDTO): void
return;
}
- $this->logger->debug('Stripe event received: ' . $event->type, $event->data->object->toArray());
+ $this->logger->debug('Stripe event received: '.$event->type, $event->data->object->toArray());
switch ($event->type) {
case Event::PAYMENT_INTENT_SUCCEEDED:
@@ -113,27 +111,27 @@ public function handle(StripeWebhookDTO $webhookDTO): void
$this->markEventAsHandled($event);
} catch (CannotAcceptPaymentException $exception) {
$this->logger->error(
- 'Cannot accept payment: ' . $exception->getMessage(), [
+ 'Cannot accept payment: '.$exception->getMessage(), [
'payload' => $webhookDTO->payload,
]
);
throw $exception;
} catch (SignatureVerificationException $exception) {
$this->logger->error(
- 'Unable to verify Stripe signature: ' . $exception->getMessage(), [
+ 'Unable to verify Stripe signature: '.$exception->getMessage(), [
'payload' => $webhookDTO->payload,
]
);
throw $exception;
} catch (UnexpectedValueException $exception) {
$this->logger->error(
- 'Unexpected value in Stripe payload: ' . $exception->getMessage(), [
+ 'Unexpected value in Stripe payload: '.$exception->getMessage(), [
'payload' => $webhookDTO->payload,
]
);
throw $exception;
} catch (Throwable $exception) {
- $this->logger->error('Unhandled Stripe error: ' . $exception->getMessage(), [
+ $this->logger->error('Unhandled Stripe error: '.$exception->getMessage(), [
'payload' => $webhookDTO->payload,
]);
throw $exception;
@@ -147,7 +145,7 @@ private function constructEventWithValidPlatform(StripeWebhookDTO $webhookDTO):
foreach ($webhookSecrets as $platform => $webhookSecret) {
try {
- if (!$webhookSecret) {
+ if (! $webhookSecret) {
continue;
}
@@ -157,7 +155,7 @@ private function constructEventWithValidPlatform(StripeWebhookDTO $webhookDTO):
$webhookSecret
);
- $this->logger->debug('Webhook validated with platform: ' . $platform, [
+ $this->logger->debug('Webhook validated with platform: '.$platform, [
'event_id' => $event->id,
'platform' => $platform,
]);
@@ -165,6 +163,7 @@ private function constructEventWithValidPlatform(StripeWebhookDTO $webhookDTO):
return $event;
} catch (SignatureVerificationException $exception) {
$lastException = $exception;
+
continue;
}
}
@@ -174,7 +173,7 @@ private function constructEventWithValidPlatform(StripeWebhookDTO $webhookDTO):
private function hasEventBeenHandled(Event $event): bool
{
- return $this->cache->has('stripe_event_' . $event->id);
+ return $this->cache->has('stripe_event_'.$event->id);
}
private function handleChargeRefunded(Charge $charge): void
@@ -192,6 +191,6 @@ private function markEventAsHandled(Event $event): void
'event_id' => $event->id,
'type' => $event->type,
]);
- $this->cache->put('stripe_event_' . $event->id, true, now()->addMinutes(60));
+ $this->cache->put('stripe_event_'.$event->id, true, now()->addMinutes(60));
}
}
diff --git a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandler.php b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandler.php
index 2882194d4d..abf2981dce 100644
--- a/backend/app/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandler.php
+++ b/backend/app/Services/Application/Handlers/Order/Payment/Stripe/RefundOrderHandler.php
@@ -33,15 +33,13 @@ class RefundOrderHandler
{
public function __construct(
private readonly StripePaymentIntentRefundService $refundService,
- private readonly OrderRepositoryInterface $orderRepository,
- private readonly EventRepositoryInterface $eventRepository,
- private readonly Mailer $mailer,
- private readonly OrderCancelService $orderCancelService,
- private readonly DatabaseManager $databaseManager,
- private readonly StripeClientFactory $stripeClientFactory,
- )
- {
- }
+ private readonly OrderRepositoryInterface $orderRepository,
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly Mailer $mailer,
+ private readonly OrderCancelService $orderCancelService,
+ private readonly DatabaseManager $databaseManager,
+ private readonly StripeClientFactory $stripeClientFactory,
+ ) {}
/**
* @throws RefundNotPossibleException
@@ -50,7 +48,7 @@ public function __construct(
*/
public function handle(RefundOrderDTO $refundOrderDTO): OrderDomainObject
{
- return $this->databaseManager->transaction(fn() => $this->refundOrder($refundOrderDTO));
+ return $this->databaseManager->transaction(fn () => $this->refundOrder($refundOrderDTO));
}
private function fetchOrder(int $eventId, int $orderId): OrderDomainObject
@@ -59,7 +57,7 @@ private function fetchOrder(int $eventId, int $orderId): OrderDomainObject
->loadRelation(new Relationship(StripePaymentDomainObject::class, name: 'stripe_payment'))
->findFirstWhere(['event_id' => $eventId, 'id' => $orderId]);
- if (!$order) {
+ if (! $order) {
throw new ResourceNotFoundException(__('Order :id not found for event :eventId', [
'id' => $orderId,
'eventId' => $eventId,
@@ -74,7 +72,7 @@ private function fetchOrder(int $eventId, int $orderId): OrderDomainObject
*/
private function validateRefundability(OrderDomainObject $order): void
{
- if (!$order->getStripePayment()) {
+ if (! $order->getStripePayment()) {
throw new RefundNotPossibleException(__('There is no Stripe data associated with this order.'));
}
diff --git a/backend/app/Services/Application/Handlers/Order/Public/AbandonOrderPublicHandler.php b/backend/app/Services/Application/Handlers/Order/Public/AbandonOrderPublicHandler.php
index 469ee59efe..3a2d2a9f19 100644
--- a/backend/app/Services/Application/Handlers/Order/Public/AbandonOrderPublicHandler.php
+++ b/backend/app/Services/Application/Handlers/Order/Public/AbandonOrderPublicHandler.php
@@ -18,9 +18,7 @@ public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly CheckoutSessionManagementService $sessionService,
private readonly Logger $logger,
- )
- {
- }
+ ) {}
/**
* @throws ResourceConflictException
@@ -29,7 +27,7 @@ public function handle(string $orderShortId): OrderDomainObject
{
$order = $this->orderRepository->findByShortId($orderShortId);
- if (!$order) {
+ if (! $order) {
throw new ResourceNotFoundException(__('Order not found'));
}
@@ -58,7 +56,7 @@ public function handle(string $orderShortId): OrderDomainObject
private function verifySessionId(string $orderSessionId): void
{
- if (!$this->sessionService->verifySession($orderSessionId)) {
+ if (! $this->sessionService->verifySession($orderSessionId)) {
throw new UnauthorizedException(
__('Sorry, we could not verify your session. Please restart your order.')
);
diff --git a/backend/app/Services/Application/Handlers/Order/Public/DownloadOrderInvoicePublicHandler.php b/backend/app/Services/Application/Handlers/Order/Public/DownloadOrderInvoicePublicHandler.php
index 36c2696714..f19a461d55 100644
--- a/backend/app/Services/Application/Handlers/Order/Public/DownloadOrderInvoicePublicHandler.php
+++ b/backend/app/Services/Application/Handlers/Order/Public/DownloadOrderInvoicePublicHandler.php
@@ -9,9 +9,7 @@ class DownloadOrderInvoicePublicHandler
{
public function __construct(
private readonly GenerateOrderInvoicePDFService $generateOrderInvoicePDFService,
- )
- {
- }
+ ) {}
public function handle(int $eventId, string $orderShortId): InvoicePdfResponseDTO
{
diff --git a/backend/app/Services/Application/Handlers/Order/TransitionOrderToOfflinePaymentHandler.php b/backend/app/Services/Application/Handlers/Order/TransitionOrderToOfflinePaymentHandler.php
index 9a03323d3b..4ebf6f4b65 100644
--- a/backend/app/Services/Application/Handlers/Order/TransitionOrderToOfflinePaymentHandler.php
+++ b/backend/app/Services/Application/Handlers/Order/TransitionOrderToOfflinePaymentHandler.php
@@ -15,6 +15,7 @@
use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Services\Application\Handlers\Order\DTO\TransitionOrderToOfflinePaymentPublicDTO;
+use HiEvents\Services\Domain\Order\OccurrenceStatusValidator;
use HiEvents\Services\Domain\Product\ProductQuantityUpdateService;
use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService;
use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType;
@@ -25,15 +26,14 @@
class TransitionOrderToOfflinePaymentHandler
{
public function __construct(
- private readonly ProductQuantityUpdateService $productQuantityUpdateService,
- private readonly OrderRepositoryInterface $orderRepository,
- private readonly DatabaseManager $databaseManager,
+ private readonly ProductQuantityUpdateService $productQuantityUpdateService,
+ private readonly OrderRepositoryInterface $orderRepository,
+ private readonly DatabaseManager $databaseManager,
private readonly EventSettingsRepositoryInterface $eventSettingsRepository,
- private readonly DomainEventDispatcherService $domainEventDispatcherService,
+ private readonly OccurrenceStatusValidator $occurrenceStatusValidator,
+ private readonly DomainEventDispatcherService $domainEventDispatcherService,
private readonly CheckoutSessionManagementService $sessionManagementService,
- )
- {
- }
+ ) {}
public function handle(TransitionOrderToOfflinePaymentPublicDTO $dto): OrderDomainObject
{
@@ -48,7 +48,7 @@ public function handle(TransitionOrderToOfflinePaymentPublicDTO $dto): OrderDoma
}
if ($order->getSessionId() === null
- || !$this->sessionManagementService->verifySession($order->getSessionId())) {
+ || ! $this->sessionManagementService->verifySession($order->getSessionId())) {
throw new UnauthorizedException(
__('Sorry, we could not verify your session. Please restart your order.')
);
@@ -61,6 +61,8 @@ public function handle(TransitionOrderToOfflinePaymentPublicDTO $dto): OrderDoma
$this->validateOfflinePayment($order, $eventSettings);
+ $this->occurrenceStatusValidator->assertOrderOccurrencesArePurchasable($order);
+
$this->updateOrderStatuses($order->getId());
$this->productQuantityUpdateService->updateQuantitiesFromOrder($order);
@@ -100,11 +102,10 @@ private function updateOrderStatuses(int $orderId): void
* @throws ResourceConflictException
*/
public function validateOfflinePayment(
- OrderDomainObject $order,
+ OrderDomainObject $order,
EventSettingDomainObject $settings,
- ): void
- {
- if (!$order->isOrderReserved()) {
+ ): void {
+ if (! $order->isOrderReserved()) {
throw new ResourceConflictException(__('Order is not in the correct status to transition to offline payment'));
}
diff --git a/backend/app/Services/Application/Handlers/Organizer/CreateOrganizerHandler.php b/backend/app/Services/Application/Handlers/Organizer/CreateOrganizerHandler.php
index 4277d4523b..340f3809cf 100644
--- a/backend/app/Services/Application/Handlers/Organizer/CreateOrganizerHandler.php
+++ b/backend/app/Services/Application/Handlers/Organizer/CreateOrganizerHandler.php
@@ -4,23 +4,27 @@
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 HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService;
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 OrganizerRepositoryInterface $organizerRepository,
+ private readonly OrganizerConfigurationRepositoryInterface $organizerConfigurationRepository,
+ private readonly AccountRepositoryInterface $accountRepository,
+ private readonly DatabaseManager $databaseManager,
private readonly CreateDefaultOrganizerSettingsService $createDefaultOrganizerSettingsService,
- private readonly HtmlPurifierService $purifier,
- )
- {
- }
+ private readonly HtmlPurifierService $purifier,
+ private readonly LoggerInterface $logger,
+ ) {}
/**
* @throws Throwable
@@ -28,7 +32,7 @@ public function __construct(
public function handle(CreateOrganizerDTO $organizerData): OrganizerDomainObject
{
return $this->databaseManager->transaction(
- fn() => $this->createOrganizer($organizerData)
+ fn () => $this->createOrganizer($organizerData)
);
}
@@ -43,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);
@@ -51,4 +56,34 @@ private function createOrganizer(CreateOrganizerDTO $organizerData): OrganizerDo
->loadRelation(ImageDomainObject::class)
->findById($organizer->getId());
}
+
+ 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/CreateOrganizerDTO.php b/backend/app/Services/Application/Handlers/Organizer/DTO/CreateOrganizerDTO.php
index 53e9947280..6ffd69feae 100644
--- a/backend/app/Services/Application/Handlers/Organizer/DTO/CreateOrganizerDTO.php
+++ b/backend/app/Services/Application/Handlers/Organizer/DTO/CreateOrganizerDTO.php
@@ -8,16 +8,14 @@
class CreateOrganizerDTO extends BaseDTO
{
public function __construct(
- public string $name,
- public string $email,
- public int $account_id,
- public string $timezone,
- public string $currency,
- public ?string $phone = null,
- public ?string $website = null,
- public ?string $description = null,
+ public string $name,
+ public string $email,
+ public int $account_id,
+ public string $timezone,
+ public string $currency,
+ public ?string $phone = null,
+ public ?string $website = null,
+ public ?string $description = null,
public ?UploadedFile $logo = null,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Organizer/DTO/DeleteOrganizerDTO.php b/backend/app/Services/Application/Handlers/Organizer/DTO/DeleteOrganizerDTO.php
index 760a3c9036..4b8918e574 100644
--- a/backend/app/Services/Application/Handlers/Organizer/DTO/DeleteOrganizerDTO.php
+++ b/backend/app/Services/Application/Handlers/Organizer/DTO/DeleteOrganizerDTO.php
@@ -9,7 +9,5 @@ class DeleteOrganizerDTO extends BaseDTO
public function __construct(
public int $organizerId,
public int $accountId,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Organizer/DTO/EditOrganizerDTO.php b/backend/app/Services/Application/Handlers/Organizer/DTO/EditOrganizerDTO.php
index 0f307abdc3..cd47fbc667 100644
--- a/backend/app/Services/Application/Handlers/Organizer/DTO/EditOrganizerDTO.php
+++ b/backend/app/Services/Application/Handlers/Organizer/DTO/EditOrganizerDTO.php
@@ -8,17 +8,15 @@
class EditOrganizerDTO extends BaseDataObject
{
public function __construct(
- public int $id,
- public string $name,
- public string $email,
- public int $account_id,
- public string $timezone,
- public string $currency,
- public ?string $phone = null,
- public ?string $website = null,
- public ?string $description = null,
- public ?UploadedFile $logo = null,
- )
- {
- }
+ public int $id,
+ public string $name,
+ public string $email,
+ public int $account_id,
+ public string $timezone,
+ public string $currency,
+ public ?string $phone = null,
+ public ?string $website = null,
+ public ?string $description = null,
+ public ?UploadedFile $logo = null,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Organizer/DTO/GetOrganizerEventsDTO.php b/backend/app/Services/Application/Handlers/Organizer/DTO/GetOrganizerEventsDTO.php
index 294a287f5c..bc319e51e8 100644
--- a/backend/app/Services/Application/Handlers/Organizer/DTO/GetOrganizerEventsDTO.php
+++ b/backend/app/Services/Application/Handlers/Organizer/DTO/GetOrganizerEventsDTO.php
@@ -8,10 +8,8 @@
class GetOrganizerEventsDTO extends BaseDTO
{
public function __construct(
- public int $organizerId,
- public int $accountId,
+ public int $organizerId,
+ public int $accountId,
public QueryParamsDTO $queryParams
- )
- {
- }
+ ) {}
}
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..e7ef546f76 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;
@@ -16,64 +15,59 @@
class PartialUpdateOrganizerSettingsDTO extends BaseDataObject
{
public function __construct(
- public readonly int $organizerId,
- public readonly string $accountId,
+ public readonly int $organizerId,
+ public readonly string $accountId,
// Event defaults
#[WithCast(EnumCast::class, AttendeeDetailsCollectionMethod::class)]
public readonly AttendeeDetailsCollectionMethod|Optional|null $defaultAttendeeDetailsCollectionMethod,
- public readonly bool|Optional|null $defaultShowMarketingOptIn,
- public readonly bool|Optional|null $defaultPassPlatformFeeToBuyer,
- public readonly bool|Optional|null $defaultAllowAttendeeSelfEdit,
+ public readonly bool|Optional|null $defaultShowMarketingOptIn,
+ public readonly bool|Optional|null $defaultPassPlatformFeeToBuyer,
+ public readonly bool|Optional|null $defaultAllowAttendeeSelfEdit,
// Social
- public readonly string|Optional|null $facebookHandle,
- public readonly string|Optional|null $instagramHandle,
- public readonly string|Optional|null $twitterHandle,
- public readonly string|Optional|null $linkedinHandle,
- public readonly string|Optional|null $discordHandle,
- public readonly string|Optional|null $tiktokHandle,
- public readonly string|Optional|null $youtubeHandle,
- public readonly string|Optional|null $snapchatHandle,
- public readonly string|Optional|null $twitchHandle,
- public readonly string|Optional|null $redditHandle,
- public readonly string|Optional|null $pinterestHandle,
- public readonly string|Optional|null $whatsappHandle,
- public readonly string|Optional|null $telegramHandle,
- public readonly string|Optional|null $vkHandle,
- public readonly string|Optional|null $weiboHandle,
- public readonly string|Optional|null $wechatHandle,
- public readonly string|Optional|null $flickrHandle,
- public readonly string|Optional|null $tumblrHandle,
- public readonly string|Optional|null $quoraHandle,
- public readonly string|Optional|null $vimeoHandle,
- public readonly string|Optional|null $githubHandle,
+ public readonly string|Optional|null $facebookHandle,
+ public readonly string|Optional|null $instagramHandle,
+ public readonly string|Optional|null $twitterHandle,
+ public readonly string|Optional|null $linkedinHandle,
+ public readonly string|Optional|null $discordHandle,
+ public readonly string|Optional|null $tiktokHandle,
+ public readonly string|Optional|null $youtubeHandle,
+ public readonly string|Optional|null $snapchatHandle,
+ public readonly string|Optional|null $twitchHandle,
+ public readonly string|Optional|null $redditHandle,
+ public readonly string|Optional|null $pinterestHandle,
+ public readonly string|Optional|null $whatsappHandle,
+ public readonly string|Optional|null $telegramHandle,
+ public readonly string|Optional|null $vkHandle,
+ public readonly string|Optional|null $weiboHandle,
+ public readonly string|Optional|null $wechatHandle,
+ public readonly string|Optional|null $flickrHandle,
+ public readonly string|Optional|null $tumblrHandle,
+ public readonly string|Optional|null $quoraHandle,
+ public readonly string|Optional|null $vimeoHandle,
+ public readonly string|Optional|null $githubHandle,
// Website
- public readonly string|Optional|null $websiteUrl,
-
- // Location details
- public readonly AddressDTO|Optional|null $locationDetails,
+ public readonly string|Optional|null $websiteUrl,
// Homepage settings
- public readonly OrganizerHomepageVisibility|Optional|null $homepageVisibility,
+ public readonly OrganizerHomepageVisibility|Optional|null $homepageVisibility,
// Simplified homepage theme settings
- public readonly array|Optional|null $homepageThemeSettings,
+ public readonly array|Optional|null $homepageThemeSettings,
// SEO
- public readonly string|Optional|null $seoKeywords,
- public readonly string|Optional|null $seoTitle,
- public readonly string|Optional|null $seoDescription,
- public readonly bool|Optional|null $allowSearchEngineIndexing,
+ public readonly string|Optional|null $seoKeywords,
+ public readonly string|Optional|null $seoTitle,
+ public readonly string|Optional|null $seoDescription,
+ public readonly bool|Optional|null $allowSearchEngineIndexing,
// Password
- public readonly string|Optional|null $homepagePassword,
+ public readonly string|Optional|null $homepagePassword,
// Tracking pixels
- public readonly array|Optional|null $trackingPixels,
- public readonly bool|Optional|null $trackingConsentAcknowledged,
- )
- {
- }
+ public readonly array|Optional|null $trackingPixels,
+ public readonly bool|Optional|null $trackingConsentAcknowledged,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Organizer/DTO/SendOrganizerContactMessageDTO.php b/backend/app/Services/Application/Handlers/Organizer/DTO/SendOrganizerContactMessageDTO.php
index 74789cbabe..386e89e703 100644
--- a/backend/app/Services/Application/Handlers/Organizer/DTO/SendOrganizerContactMessageDTO.php
+++ b/backend/app/Services/Application/Handlers/Organizer/DTO/SendOrganizerContactMessageDTO.php
@@ -7,12 +7,10 @@
class SendOrganizerContactMessageDTO extends BaseDataObject
{
public function __construct(
- public int $organizer_id,
- public ?int $account_id,
+ public int $organizer_id,
+ public ?int $account_id,
public string $name,
public string $email,
public string $message,
- )
- {
- }
+ ) {}
}
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 @@
+databaseManager->transaction(
- fn() => $this->editOrganizer($organizerData)
+ fn () => $this->editOrganizer($organizerData)
);
}
diff --git a/backend/app/Services/Application/Handlers/Organizer/GetOrganizerEventsHandler.php b/backend/app/Services/Application/Handlers/Organizer/GetOrganizerEventsHandler.php
index 06a24e2a27..13383dbb4b 100644
--- a/backend/app/Services/Application/Handlers/Organizer/GetOrganizerEventsHandler.php
+++ b/backend/app/Services/Application/Handlers/Organizer/GetOrganizerEventsHandler.php
@@ -2,8 +2,11 @@
namespace HiEvents\Services\Application\Handlers\Organizer;
+use HiEvents\DomainObjects\EventLocationDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\ImageDomainObject;
+use HiEvents\DomainObjects\LocationDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
@@ -14,13 +17,19 @@ class GetOrganizerEventsHandler
{
public function __construct(
private readonly EventRepositoryInterface $eventRepository
- )
- {
- }
+ ) {}
public function handle(GetOrganizerEventsDTO $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(
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..27edcdc8c8 100644
--- a/backend/app/Services/Application/Handlers/Organizer/GetPublicOrganizerHandler.php
+++ b/backend/app/Services/Application/Handlers/Organizer/GetPublicOrganizerHandler.php
@@ -3,22 +3,23 @@
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
{
public function __construct(
private readonly OrganizerRepositoryInterface $organizerRepository
- )
- {
- }
+ ) {}
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/Order/GetOrganizerOrdersHandler.php b/backend/app/Services/Application/Handlers/Organizer/Order/GetOrganizerOrdersHandler.php
index 613a0efcd3..df0a6b469f 100644
--- a/backend/app/Services/Application/Handlers/Organizer/Order/GetOrganizerOrdersHandler.php
+++ b/backend/app/Services/Application/Handlers/Organizer/Order/GetOrganizerOrdersHandler.php
@@ -13,9 +13,7 @@ class GetOrganizerOrdersHandler
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
- )
- {
- }
+ ) {}
public function handle(int $organizer, int $accountId, QueryParamsDTO $queryParams): LengthAwarePaginator
{
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..3cb962e0bb
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandler.php
@@ -0,0 +1,124 @@
+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(),
+ ]);
+ }
+
+ $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/Organizer/Payment/Stripe/CreateStripeConnectAccountHandler.php b/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/CreateStripeConnectAccountHandler.php
new file mode 100644
index 0000000000..3a3f7ae527
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/CreateStripeConnectAccountHandler.php
@@ -0,0 +1,172 @@
+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->createOrGetStripeConnectAccount($command));
+ }
+
+ /**
+ * @throws CreateStripeConnectAccountFailedException
+ * @throws CreateStripeConnectAccountLinksFailedException
+ * @throws ResourceNotFoundException
+ * @throws StripeClientConfigurationException
+ */
+ private function createOrGetStripeConnectAccount(CreateStripeConnectAccountDTO $command): CreateStripeConnectAccountResponse
+ {
+ $organizer = $this->organizerRepository
+ ->loadRelation(OrganizerStripePlatformDomainObject::class)
+ ->findFirstWhere([
+ 'id' => $command->organizerId,
+ 'account_id' => $command->accountId,
+ ]);
+
+ if ($organizer === null) {
+ throw new ResourceNotFoundException(__('Organizer not found.'));
+ }
+
+ if ($command->platform) {
+ $platformToUse = StripePlatform::fromString($command->platform->value);
+ } else {
+ $platformToUse = $this->stripeConfigurationService->getPrimaryPlatform();
+ }
+
+ $organizerStripePlatform = $organizer->getStripePlatformByType($platformToUse);
+
+ $stripeClient = $this->stripeClientFactory->createForPlatform($platformToUse);
+
+ $stripeConnectAccount = $this->getOrCreateStripeConnectAccount(
+ organizer: $organizer,
+ organizerStripePlatform: $organizerStripePlatform,
+ stripeClient: $stripeClient,
+ platform: $platformToUse,
+ );
+
+ $response = new CreateStripeConnectAccountResponse(
+ stripeConnectAccountType: $stripeConnectAccount->type,
+ stripeAccountId: $stripeConnectAccount->id,
+ organizer: $organizer,
+ isConnectSetupComplete: $this->stripeAccountSyncService->isStripeAccountComplete($stripeConnectAccount),
+ );
+
+ if ($response->isConnectSetupComplete) {
+ if ($organizerStripePlatform && $organizerStripePlatform->getStripeSetupCompletedAt() === null) {
+ $this->stripeAccountSyncService->markAccountAsCompleteForOrganizer($organizerStripePlatform, $stripeConnectAccount);
+ }
+
+ return $response;
+ }
+
+ $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.'),
+ );
+ }
+
+ $response->connectUrl = $connectUrl;
+
+ return $response;
+ }
+
+ /**
+ * @throws CreateStripeConnectAccountFailedException
+ */
+ private function getOrCreateStripeConnectAccount(
+ OrganizerDomainObject $organizer,
+ ?OrganizerStripePlatformDomainObject $organizerStripePlatform,
+ StripeClient $stripeClient,
+ ?StripePlatform $platform
+ ): Account {
+ try {
+ if ($organizerStripePlatform && $organizerStripePlatform->getStripeAccountId() !== null) {
+ return $stripeClient->accounts->retrieve($organizerStripePlatform->getStripeAccountId());
+ }
+
+ $stripeAccount = $stripeClient->accounts->create([
+ 'type' => $this->config->get('app.stripe_connect_account_type')
+ ?? StripeConnectAccountType::EXPRESS->value,
+ ]);
+ } catch (Throwable $e) {
+ $this->logger->error('Failed to create or fetch Stripe Connect Account: '.$e->getMessage(), [
+ 'organizerId' => $organizer->getId(),
+ 'stripeAccountId' => $organizerStripePlatform?->getStripeAccountId() ?? 'null',
+ 'platform' => $platform?->value ?? 'null',
+ 'exception' => $e,
+ ]);
+
+ throw new CreateStripeConnectAccountFailedException(
+ message: __('There are issues with creating or fetching the Stripe Connect Account. Please try again.'),
+ previous: $e,
+ );
+ }
+
+ 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->organizerStripePlatformRepository->updateWhere(
+ attributes: [
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => $stripeAccount->id,
+ OrganizerStripePlatformDomainObjectAbstract::STRIPE_CONNECT_ACCOUNT_TYPE => $stripeAccount->type,
+ ],
+ where: [
+ '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..278cf1b355
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/DTO/CopyStripeConnectAccountDTO.php
@@ -0,0 +1,14 @@
+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(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(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/SendOrganizerContactMessageHandler.php b/backend/app/Services/Application/Handlers/Organizer/SendOrganizerContactMessageHandler.php
index 406f44a923..e871e6e2bc 100644
--- a/backend/app/Services/Application/Handlers/Organizer/SendOrganizerContactMessageHandler.php
+++ b/backend/app/Services/Application/Handlers/Organizer/SendOrganizerContactMessageHandler.php
@@ -13,12 +13,10 @@
class SendOrganizerContactMessageHandler
{
public function __construct(
- private readonly Mailer $mailer,
+ private readonly Mailer $mailer,
private readonly OrganizerRepositoryInterface $organizerRepository,
- private readonly HtmlPurifierService $purifier,
- )
- {
- }
+ private readonly HtmlPurifierService $purifier,
+ ) {}
public function handle(SendOrganizerContactMessageDTO $dto): void
{
diff --git a/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php b/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php
index cc97506a71..9c00f0de8f 100644
--- a/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php
+++ b/backend/app/Services/Application/Handlers/Organizer/Settings/PartialUpdateOrganizerSettingsHandler.php
@@ -7,16 +7,13 @@
use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface;
use HiEvents\Repository\Interfaces\OrganizerSettingsRepositoryInterface;
use HiEvents\Services\Application\Handlers\Organizer\DTO\PartialUpdateOrganizerSettingsDTO;
-use Spatie\LaravelData\Data;
class PartialUpdateOrganizerSettingsHandler
{
public function __construct(
private readonly OrganizerSettingsRepositoryInterface $organizerSettingsRepository,
- private readonly OrganizerRepositoryInterface $organizerRepository,
- )
- {
- }
+ private readonly OrganizerRepositoryInterface $organizerRepository,
+ ) {}
public function handle(PartialUpdateOrganizerSettingsDTO $dto): OrganizerSettingDomainObject
{
@@ -31,16 +28,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 +70,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..948a887138
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Organizer/UpdateOrganizerLocationHandler.php
@@ -0,0 +1,60 @@
+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->databaseManager->transaction(function () use ($dto) {
+ $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/UpdateOrganizerStatusHandler.php b/backend/app/Services/Application/Handlers/Organizer/UpdateOrganizerStatusHandler.php
index bdbf4ed650..6e159eaf54 100644
--- a/backend/app/Services/Application/Handlers/Organizer/UpdateOrganizerStatusHandler.php
+++ b/backend/app/Services/Application/Handlers/Organizer/UpdateOrganizerStatusHandler.php
@@ -19,13 +19,11 @@ class UpdateOrganizerStatusHandler
{
public function __construct(
private readonly OrganizerRepositoryInterface $organizerRepository,
- private readonly AccountRepositoryInterface $accountRepository,
- private readonly EventRepositoryInterface $eventRepository,
- private readonly LoggerInterface $logger,
- private readonly DatabaseManager $databaseManager,
- )
- {
- }
+ private readonly AccountRepositoryInterface $accountRepository,
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly LoggerInterface $logger,
+ private readonly DatabaseManager $databaseManager,
+ ) {}
/**
* @throws AccountNotVerifiedException|CannotDeleteEntityException|Throwable
@@ -88,7 +86,7 @@ private function updateOrganizerStatus(UpdateOrganizerStatusDTO $updateOrganizer
$this->logger->info('Organizer status updated', [
'organizerId' => $updateOrganizerStatusDTO->organizerId,
- 'status' => $updateOrganizerStatusDTO->status
+ 'status' => $updateOrganizerStatusDTO->status,
]);
return $this->organizerRepository->findFirstWhere([
diff --git a/backend/app/Services/Application/Handlers/Account/Vat/DTO/UpsertAccountVatSettingDTO.php b/backend/app/Services/Application/Handlers/Organizer/Vat/DTO/UpsertOrganizerVatSettingDTO.php
similarity index 56%
rename from backend/app/Services/Application/Handlers/Account/Vat/DTO/UpsertAccountVatSettingDTO.php
rename to backend/app/Services/Application/Handlers/Organizer/Vat/DTO/UpsertOrganizerVatSettingDTO.php
index 3c6168f66f..db13dc6d96 100644
--- a/backend/app/Services/Application/Handlers/Account/Vat/DTO/UpsertAccountVatSettingDTO.php
+++ b/backend/app/Services/Application/Handlers/Organizer/Vat/DTO/UpsertOrganizerVatSettingDTO.php
@@ -1,15 +1,15 @@
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 72%
rename from backend/app/Services/Application/Handlers/Account/Vat/UpsertAccountVatSettingHandler.php
rename to backend/app/Services/Application/Handlers/Organizer/Vat/UpsertOrganizerVatSettingHandler.php
index 72c7c4029f..e542864215 100644
--- a/backend/app/Services/Application/Handlers/Account/Vat/UpsertAccountVatSettingHandler.php
+++ b/backend/app/Services/Application/Handlers/Organizer/Vat/UpsertOrganizerVatSettingHandler.php
@@ -2,31 +2,45 @@
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 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,
];
@@ -37,12 +51,16 @@ public function handle(UpsertAccountVatSettingDTO $command): AccountVatSettingDo
$vatNumber = strtoupper(trim($command->vatNumber));
if (preg_match('/^[A-Z]{2}[0-9A-Z]{8,15}$/', $vatNumber)) {
- $vatNumberChanged = !$existing || $existing->getVatNumber() !== $vatNumber;
+ $vatNumberChanged = ! $existing || $existing->getVatNumber() !== $vatNumber;
+ $canRetryUnchanged = $existing && in_array($existing->getVatValidationStatus(), [
+ VatValidationStatus::FAILED->value,
+ VatValidationStatus::INVALID->value,
+ ], true);
$data['vat_number'] = $vatNumber;
$data['vat_country_code'] = substr($vatNumber, 0, 2);
- if ($vatNumberChanged) {
+ if ($vatNumberChanged || $canRetryUnchanged) {
$shouldValidate = true;
$data = $this->trySyncValidation($vatNumber, $data);
}
@@ -78,8 +96,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 +119,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 +131,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 +142,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;
@@ -157,6 +160,6 @@ private function maskVatNumber(string $vatNumber): string
return $vatNumber;
}
- return substr($vatNumber, 0, 2) . str_repeat('*', $length - 4) . substr($vatNumber, -2);
+ return substr($vatNumber, 0, 2).str_repeat('*', $length - 4).substr($vatNumber, -2);
}
}
diff --git a/backend/app/Services/Application/Handlers/Product/CreateProductHandler.php b/backend/app/Services/Application/Handlers/Product/CreateProductHandler.php
index 053cac6d10..1c5fb7b895 100644
--- a/backend/app/Services/Application/Handlers/Product/CreateProductHandler.php
+++ b/backend/app/Services/Application/Handlers/Product/CreateProductHandler.php
@@ -17,18 +17,16 @@
class CreateProductHandler
{
public function __construct(
- private readonly CreateProductService $productCreateService,
+ private readonly CreateProductService $productCreateService,
private readonly GetProductCategoryService $getProductCategoryService,
- )
- {
- }
+ ) {}
/**
* @throws Throwable
*/
public function handle(UpsertProductDTO $productsData): ProductDomainObject
{
- $productPrices = $productsData->prices->map(fn(ProductPriceDTO $price) => ProductPriceDomainObject::hydrateFromArray([
+ $productPrices = $productsData->prices->map(fn (ProductPriceDTO $price) => ProductPriceDomainObject::hydrateFromArray([
ProductPriceDomainObjectAbstract::PRICE => $productsData->type === ProductPriceType::FREE ? 0.00 : $price->price,
ProductPriceDomainObjectAbstract::LABEL => $price->label,
ProductPriceDomainObjectAbstract::SALE_START_DATE => $price->sale_start_date,
@@ -43,7 +41,7 @@ public function handle(UpsertProductDTO $productsData): ProductDomainObject
);
return $this->productCreateService->createProduct(
- product: (new ProductDomainObject())
+ product: (new ProductDomainObject)
->setTitle($productsData->title)
->setType($productsData->type->name)
->setOrder($productsData->order)
diff --git a/backend/app/Services/Application/Handlers/Product/DTO/UpsertProductDTO.php b/backend/app/Services/Application/Handlers/Product/DTO/UpsertProductDTO.php
index 1980214e6e..17600dc30d 100644
--- a/backend/app/Services/Application/Handlers/Product/DTO/UpsertProductDTO.php
+++ b/backend/app/Services/Application/Handlers/Product/DTO/UpsertProductDTO.php
@@ -12,37 +12,34 @@
class UpsertProductDTO extends BaseDTO
{
public function __construct(
- public readonly int $account_id,
- public readonly int $event_id,
- public readonly int $product_category_id,
- public readonly string $title,
+ public readonly int $account_id,
+ public readonly int $event_id,
+ public readonly int $product_category_id,
+ public readonly string $title,
public readonly ProductPriceType $type,
- public readonly ProductType $product_type,
+ public readonly ProductType $product_type,
#[CollectionOf(ProductPriceDTO::class)]
- public readonly ?Collection $prices = null,
- public readonly ?float $price = 0.00,
- public readonly ?int $order = 1,
- public readonly ?int $initial_quantity_available = null,
- public readonly ?int $quantity_sold = 0,
- public readonly ?string $sale_start_date = null,
- public readonly ?string $sale_end_date = null,
- public readonly ?int $max_per_order = 100,
- public readonly ?string $description = null,
- public readonly ?int $min_per_order = 0,
- public readonly ?bool $is_hidden = false,
- public readonly ?bool $hide_before_sale_start_date = false,
- public readonly ?bool $hide_after_sale_end_date = false,
- public readonly ?bool $hide_when_sold_out = false,
- public readonly ?bool $start_collapsed = false,
- public readonly ?bool $show_quantity_remaining = false,
- public readonly ?bool $is_hidden_without_promo_code = false,
- public readonly ?array $tax_and_fee_ids = [],
- public readonly ?int $product_id = null,
- public readonly ?bool $is_highlighted = false,
- public readonly ?string $highlight_message = null,
- public readonly ?bool $waitlist_enabled = null,
- )
- {
- }
+ public readonly ?Collection $prices = null,
+ public readonly ?float $price = 0.00,
+ public readonly ?int $order = 1,
+ public readonly ?int $initial_quantity_available = null,
+ public readonly ?int $quantity_sold = 0,
+ public readonly ?string $sale_start_date = null,
+ public readonly ?string $sale_end_date = null,
+ public readonly ?int $max_per_order = 100,
+ public readonly ?string $description = null,
+ public readonly ?int $min_per_order = 0,
+ public readonly ?bool $is_hidden = false,
+ public readonly ?bool $hide_before_sale_start_date = false,
+ public readonly ?bool $hide_after_sale_end_date = false,
+ public readonly ?bool $hide_when_sold_out = false,
+ public readonly ?bool $start_collapsed = false,
+ public readonly ?bool $show_quantity_remaining = false,
+ public readonly ?bool $is_hidden_without_promo_code = false,
+ public readonly ?array $tax_and_fee_ids = [],
+ public readonly ?int $product_id = null,
+ public readonly ?bool $is_highlighted = false,
+ public readonly ?string $highlight_message = null,
+ public readonly ?bool $waitlist_enabled = null,
+ ) {}
}
-
diff --git a/backend/app/Services/Application/Handlers/Product/DeleteProductHandler.php b/backend/app/Services/Application/Handlers/Product/DeleteProductHandler.php
index 4854d40e7c..4234dff74b 100644
--- a/backend/app/Services/Application/Handlers/Product/DeleteProductHandler.php
+++ b/backend/app/Services/Application/Handlers/Product/DeleteProductHandler.php
@@ -10,9 +10,7 @@ class DeleteProductHandler
{
public function __construct(
private readonly DeleteProductService $deleteProductService,
- )
- {
- }
+ ) {}
/**
* @throws CannotDeleteEntityException
diff --git a/backend/app/Services/Application/Handlers/Product/EditProductHandler.php b/backend/app/Services/Application/Handlers/Product/EditProductHandler.php
index 4e51bfc7f0..ba20fbf0ae 100644
--- a/backend/app/Services/Application/Handlers/Product/EditProductHandler.php
+++ b/backend/app/Services/Application/Handlers/Product/EditProductHandler.php
@@ -5,10 +5,10 @@
namespace HiEvents\Services\Application\Handlers\Product;
use Exception;
+use HiEvents\DomainObjects\Enums\CapacityChangeDirection;
use HiEvents\DomainObjects\Interfaces\DomainObjectInterface;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
-use HiEvents\DomainObjects\Enums\CapacityChangeDirection;
use HiEvents\Events\CapacityChangedEvent;
use HiEvents\Exceptions\CannotChangeProductTypeException;
use HiEvents\Helper\DateHelper;
@@ -33,17 +33,15 @@
class EditProductHandler
{
public function __construct(
- private readonly ProductRepositoryInterface $productRepository,
+ private readonly ProductRepositoryInterface $productRepository,
private readonly TaxAndProductAssociationService $taxAndProductAssociationService,
- private readonly DatabaseManager $databaseManager,
- private readonly ProductPriceUpdateService $priceUpdateService,
- private readonly HtmlPurifierService $purifier,
- private readonly EventRepositoryInterface $eventRepository,
- private readonly GetProductCategoryService $getProductCategoryService,
- private readonly DomainEventDispatcherService $domainEventDispatcherService,
- )
- {
- }
+ private readonly DatabaseManager $databaseManager,
+ private readonly ProductPriceUpdateService $priceUpdateService,
+ private readonly HtmlPurifierService $purifier,
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly GetProductCategoryService $getProductCategoryService,
+ private readonly DomainEventDispatcherService $domainEventDispatcherService,
+ ) {}
/**
* @throws Throwable
@@ -156,16 +154,15 @@ private function getExistingPriceQuantities(int $productId): Collection
->findById($productId);
return $product->getProductPrices()
- ->mapWithKeys(fn(ProductPriceDomainObject $price) => [
+ ->mapWithKeys(fn (ProductPriceDomainObject $price) => [
$price->getId() => $price->getInitialQuantityAvailable(),
]);
}
private function dispatchCapacityChangedEventIfQuantityChanged(
UpsertProductDTO $productsData,
- Collection $oldPriceQuantities,
- ): void
- {
+ Collection $oldPriceQuantities,
+ ): void {
if ($productsData->prices === null) {
return;
}
@@ -180,11 +177,9 @@ private function dispatchCapacityChangedEventIfQuantityChanged(
$direction = match (true) {
($newQuantity === null && $oldQuantity !== null),
- ($newQuantity !== null && $oldQuantity !== null && $newQuantity > $oldQuantity)
- => CapacityChangeDirection::INCREASED,
+ ($newQuantity !== null && $oldQuantity !== null && $newQuantity > $oldQuantity) => CapacityChangeDirection::INCREASED,
($newQuantity !== null && $oldQuantity === null),
- ($newQuantity !== null && $oldQuantity !== null && $newQuantity < $oldQuantity)
- => CapacityChangeDirection::DECREASED,
+ ($newQuantity !== null && $oldQuantity !== null && $newQuantity < $oldQuantity) => CapacityChangeDirection::DECREASED,
default => null,
};
@@ -204,6 +199,7 @@ private function dispatchCapacityChangedEventIfQuantityChanged(
/**
* @throws CannotChangeProductTypeException
+ *
* @todo - We should probably check reserved products here as well
*/
private function validateChangeInProductType(UpsertProductDTO $productsData): void
@@ -213,7 +209,7 @@ private function validateChangeInProductType(UpsertProductDTO $productsData): vo
->findById($productsData->product_id);
$quantitySold = $product->getProductPrices()
- ->sum(fn(ProductPriceDomainObject $price) => $price->getQuantitySold());
+ ->sum(fn (ProductPriceDomainObject $price) => $price->getQuantitySold());
if ($product->getType() !== $productsData->type->name && $quantitySold > 0) {
throw new CannotChangeProductTypeException(
diff --git a/backend/app/Services/Application/Handlers/Product/GetProductsHandler.php b/backend/app/Services/Application/Handlers/Product/GetProductsHandler.php
index 09a01e0576..5d54441540 100644
--- a/backend/app/Services/Application/Handlers/Product/GetProductsHandler.php
+++ b/backend/app/Services/Application/Handlers/Product/GetProductsHandler.php
@@ -13,10 +13,8 @@ class GetProductsHandler
{
public function __construct(
private readonly ProductRepositoryInterface $productRepository,
- private readonly ProductFilterService $productFilterService,
- )
- {
- }
+ private readonly ProductFilterService $productFilterService,
+ ) {}
public function handle(int $eventId, QueryParamsDTO $queryParamsDTO): LengthAwarePaginator
{
@@ -25,10 +23,9 @@ public function handle(int $eventId, QueryParamsDTO $queryParamsDTO): LengthAwar
->loadRelation(TaxAndFeesDomainObject::class)
->findByEventId($eventId, $queryParamsDTO);
- $filteredProducts = $this->productFilterService->filter(
- productsCategories: $productPaginator->getCollection(),
+ $filteredProducts = $this->productFilterService->filterProducts(
+ products: $productPaginator->getCollection(),
hideSoldOutProducts: false,
- hideHiddenCategories: false,
);
$productPaginator->setCollection($filteredProducts);
diff --git a/backend/app/Services/Application/Handlers/Product/SortProductsHandler.php b/backend/app/Services/Application/Handlers/Product/SortProductsHandler.php
index 557bcaef23..5c67e9f440 100644
--- a/backend/app/Services/Application/Handlers/Product/SortProductsHandler.php
+++ b/backend/app/Services/Application/Handlers/Product/SortProductsHandler.php
@@ -10,11 +10,9 @@
readonly class SortProductsHandler
{
public function __construct(
- private ProductRepositoryInterface $productRepository,
+ private ProductRepositoryInterface $productRepository,
private ProductCategoryRepositoryInterface $productCategoryRepository,
- )
- {
- }
+ ) {}
/**
* @throws ResourceConflictException
@@ -25,12 +23,12 @@ public function handle(int $eventId, array $sortData): void
->loadRelation(ProductDomainObject::class)
->findWhere(['event_id' => $eventId]);
- $existingCategoryIds = $categories->map(fn($category) => $category->getId())->toArray();
- $existingProductIds = $categories->flatMap(fn($category) => $category->products->map(fn($product) => $product->getId()))->toArray();
+ $existingCategoryIds = $categories->map(fn ($category) => $category->getId())->toArray();
+ $existingProductIds = $categories->flatMap(fn ($category) => $category->products->map(fn ($product) => $product->getId()))->toArray();
$orderedCategoryIds = collect($sortData)->pluck('product_category_id')->toArray();
$orderedProductIds = collect($sortData)
- ->flatMap(fn($category) => collect($category['sorted_products'])->pluck('id'))
+ ->flatMap(fn ($category) => collect($category['sorted_products'])->pluck('id'))
->toArray();
if (array_diff($existingCategoryIds, $orderedCategoryIds) || array_diff($orderedCategoryIds, $existingCategoryIds)) {
diff --git a/backend/app/Services/Application/Handlers/ProductCategory/CreateProductCategoryHandler.php b/backend/app/Services/Application/Handlers/ProductCategory/CreateProductCategoryHandler.php
index fd51e0e50d..eccbc14b2b 100644
--- a/backend/app/Services/Application/Handlers/ProductCategory/CreateProductCategoryHandler.php
+++ b/backend/app/Services/Application/Handlers/ProductCategory/CreateProductCategoryHandler.php
@@ -12,20 +12,18 @@ class CreateProductCategoryHandler
public function __construct(
private readonly CreateProductCategoryService $productCategoryService,
private readonly HtmlPurifierService $purifier,
- )
- {
- }
+ ) {}
public function handle(UpsertProductCategoryDTO $dto): ProductCategoryDomainObject
{
- $productCategory = new ProductCategoryDomainObject();
+ $productCategory = new ProductCategoryDomainObject;
$productCategory->setName($dto->name);
$productCategory->setIsHidden($dto->is_hidden);
$productCategory->setEventId($dto->event_id);
$productCategory->setDescription($this->purifier->purify($dto->description));
$productCategory->setNoProductsMessage(
$dto->no_products_message ?? __('There are no products available in this category'
- ));
+ ));
return $this->productCategoryService->createCategory($productCategory);
}
diff --git a/backend/app/Services/Application/Handlers/ProductCategory/DTO/UpsertProductCategoryDTO.php b/backend/app/Services/Application/Handlers/ProductCategory/DTO/UpsertProductCategoryDTO.php
index 01e2d8e195..4b974b51bd 100644
--- a/backend/app/Services/Application/Handlers/ProductCategory/DTO/UpsertProductCategoryDTO.php
+++ b/backend/app/Services/Application/Handlers/ProductCategory/DTO/UpsertProductCategoryDTO.php
@@ -7,13 +7,11 @@
class UpsertProductCategoryDTO extends BaseDTO
{
public function __construct(
- public string $name,
+ public string $name,
public ?string $description,
- public bool $is_hidden,
- public int $event_id,
+ public bool $is_hidden,
+ public int $event_id,
public ?string $no_products_message = '',
- public ?int $product_category_id = null,
- )
- {
- }
+ public ?int $product_category_id = null,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/ProductCategory/DeleteProductCategoryHandler.php b/backend/app/Services/Application/Handlers/ProductCategory/DeleteProductCategoryHandler.php
index 8504a7c1d4..3ca7a3fcb6 100644
--- a/backend/app/Services/Application/Handlers/ProductCategory/DeleteProductCategoryHandler.php
+++ b/backend/app/Services/Application/Handlers/ProductCategory/DeleteProductCategoryHandler.php
@@ -10,9 +10,7 @@ class DeleteProductCategoryHandler
{
public function __construct(
private readonly DeleteProductCategoryService $deleteProductCategoryService,
- )
- {
- }
+ ) {}
/**
* @throws Throwable
diff --git a/backend/app/Services/Application/Handlers/ProductCategory/EditProductCategoryHandler.php b/backend/app/Services/Application/Handlers/ProductCategory/EditProductCategoryHandler.php
index ae013ef50b..4a6a4a1f34 100644
--- a/backend/app/Services/Application/Handlers/ProductCategory/EditProductCategoryHandler.php
+++ b/backend/app/Services/Application/Handlers/ProductCategory/EditProductCategoryHandler.php
@@ -12,9 +12,7 @@ class EditProductCategoryHandler
public function __construct(
private readonly ProductCategoryRepositoryInterface $productCategoryRepository,
private readonly HtmlPurifierService $purifier,
- )
- {
- }
+ ) {}
public function handle(UpsertProductCategoryDTO $dto): ProductCategoryDomainObject
{
diff --git a/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoriesHandler.php b/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoriesHandler.php
index 18fde07dc5..8044dcbcf4 100644
--- a/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoriesHandler.php
+++ b/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoriesHandler.php
@@ -17,10 +17,8 @@ class GetProductCategoriesHandler
{
public function __construct(
private readonly ProductCategoryRepositoryInterface $productCategoryRepository,
- private readonly ProductFilterService $productFilterService,
- )
- {
- }
+ private readonly ProductFilterService $productFilterService,
+ ) {}
public function handle(int $eventId): Collection
{
diff --git a/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoryHandler.php b/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoryHandler.php
index 0df9769462..c5274849f6 100644
--- a/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoryHandler.php
+++ b/backend/app/Services/Application/Handlers/ProductCategory/GetProductCategoryHandler.php
@@ -15,9 +15,7 @@ class GetProductCategoryHandler
{
public function __construct(
private readonly ProductCategoryRepositoryInterface $productCategoryRepository,
- )
- {
- }
+ ) {}
public function handle(int $eventId, int $productCategoryId): ProductCategoryDomainObject
{
diff --git a/backend/app/Services/Application/Handlers/PromoCode/CreatePromoCodeHandler.php b/backend/app/Services/Application/Handlers/PromoCode/CreatePromoCodeHandler.php
index 37d3e9e821..0c9c770db3 100644
--- a/backend/app/Services/Application/Handlers/PromoCode/CreatePromoCodeHandler.php
+++ b/backend/app/Services/Application/Handlers/PromoCode/CreatePromoCodeHandler.php
@@ -2,6 +2,7 @@
namespace HiEvents\Services\Application\Handlers\PromoCode;
+use HiEvents\DomainObjects\Enums\PromoCodeDiscountAppliesToEnum;
use HiEvents\DomainObjects\PromoCodeDomainObject;
use HiEvents\Exceptions\ResourceConflictException;
use HiEvents\Services\Application\Handlers\PromoCode\DTO\UpsertPromoCodeDTO;
@@ -12,9 +13,7 @@
{
public function __construct(
private CreatePromoCodeService $createPromoCodeService,
- )
- {
- }
+ ) {}
/**
* @throws ResourceConflictException
@@ -23,7 +22,7 @@ public function __construct(
public function handle(int $eventId, UpsertPromoCodeDTO $promoCodeDTO): PromoCodeDomainObject
{
return $this->createPromoCodeService->createPromoCode(
- (new PromoCodeDomainObject())
+ (new PromoCodeDomainObject)
->setEventId($eventId)
->setCode($promoCodeDTO->code)
->setDiscountType($promoCodeDTO->discount_type->name)
@@ -31,6 +30,7 @@ public function handle(int $eventId, UpsertPromoCodeDTO $promoCodeDTO): PromoCod
->setExpiryDate($promoCodeDTO->expiry_date)
->setMaxAllowedUsages($promoCodeDTO->max_allowed_usages)
->setApplicableProductIds($promoCodeDTO->applicable_product_ids)
+ ->setDiscountAppliesTo(($promoCodeDTO->discount_applies_to ?? PromoCodeDiscountAppliesToEnum::EACH_PRODUCT)->name)
);
}
}
diff --git a/backend/app/Services/Application/Handlers/PromoCode/DTO/DeletePromoCodeDTO.php b/backend/app/Services/Application/Handlers/PromoCode/DTO/DeletePromoCodeDTO.php
index 26768a3b71..2942b99ee6 100644
--- a/backend/app/Services/Application/Handlers/PromoCode/DTO/DeletePromoCodeDTO.php
+++ b/backend/app/Services/Application/Handlers/PromoCode/DTO/DeletePromoCodeDTO.php
@@ -10,7 +10,5 @@ public function __construct(
public int $promo_code_id,
public int $event_id,
public int $user_id,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/PromoCode/DTO/UpsertPromoCodeDTO.php b/backend/app/Services/Application/Handlers/PromoCode/DTO/UpsertPromoCodeDTO.php
index d262fa546a..2fec96de76 100644
--- a/backend/app/Services/Application/Handlers/PromoCode/DTO/UpsertPromoCodeDTO.php
+++ b/backend/app/Services/Application/Handlers/PromoCode/DTO/UpsertPromoCodeDTO.php
@@ -2,19 +2,19 @@
namespace HiEvents\Services\Application\Handlers\PromoCode\DTO;
+use HiEvents\DomainObjects\Enums\PromoCodeDiscountAppliesToEnum;
use HiEvents\DomainObjects\Enums\PromoCodeDiscountTypeEnum;
class UpsertPromoCodeDTO
{
public function __construct(
- public readonly string $code,
- public readonly int $event_id,
- public readonly array $applicable_product_ids,
+ public readonly string $code,
+ public readonly int $event_id,
+ public readonly array $applicable_product_ids,
public readonly PromoCodeDiscountTypeEnum $discount_type,
- public readonly ?float $discount,
- public readonly ?string $expiry_date,
- public readonly ?int $max_allowed_usages,
- )
- {
- }
+ public readonly ?float $discount,
+ public readonly ?string $expiry_date,
+ public readonly ?int $max_allowed_usages,
+ public readonly ?PromoCodeDiscountAppliesToEnum $discount_applies_to,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/PromoCode/DeletePromoCodeHandler.php b/backend/app/Services/Application/Handlers/PromoCode/DeletePromoCodeHandler.php
index 7e554e0aaa..6017a29b0d 100644
--- a/backend/app/Services/Application/Handlers/PromoCode/DeletePromoCodeHandler.php
+++ b/backend/app/Services/Application/Handlers/PromoCode/DeletePromoCodeHandler.php
@@ -10,10 +10,8 @@
{
public function __construct(
private PromoCodeRepositoryInterface $promoCodeRepository,
- private LoggerInterface $logger,
- )
- {
- }
+ private LoggerInterface $logger,
+ ) {}
public function handle(DeletePromoCodeDTO $data): void
{
diff --git a/backend/app/Services/Application/Handlers/PromoCode/UpdatePromoCodeHandler.php b/backend/app/Services/Application/Handlers/PromoCode/UpdatePromoCodeHandler.php
index efc554ee51..e0cb0179a6 100644
--- a/backend/app/Services/Application/Handlers/PromoCode/UpdatePromoCodeHandler.php
+++ b/backend/app/Services/Application/Handlers/PromoCode/UpdatePromoCodeHandler.php
@@ -17,12 +17,10 @@
readonly class UpdatePromoCodeHandler
{
public function __construct(
- private PromoCodeRepositoryInterface $promoCodeRepository,
+ private PromoCodeRepositoryInterface $promoCodeRepository,
private EventProductValidationService $eventProductValidationService,
- private EventRepositoryInterface $eventRepository,
- )
- {
- }
+ private EventRepositoryInterface $eventRepository,
+ ) {}
/**
* @throws ResourceConflictException
@@ -62,8 +60,10 @@ public function handle(int $promoCodeId, UpsertPromoCodeDTO $promoCodeDTO): Prom
PromoCodeDomainObjectAbstract::CODE => $promoCodeDTO->code,
PromoCodeDomainObjectAbstract::DISCOUNT => $promoCodeDTO->discount_type === PromoCodeDiscountTypeEnum::NONE
? 0.00
- : (float)$promoCodeDTO->discount,
+ : (float) $promoCodeDTO->discount,
PromoCodeDomainObjectAbstract::DISCOUNT_TYPE => $promoCodeDTO->discount_type?->name,
+ PromoCodeDomainObjectAbstract::DISCOUNT_APPLIES_TO => $promoCodeDTO->discount_applies_to?->name
+ ?? $promoCode->getDiscountAppliesTo(),
PromoCodeDomainObjectAbstract::EXPIRY_DATE => $promoCodeDTO->expiry_date
? DateHelper::convertToUTC($promoCodeDTO->expiry_date, $event->getTimezone())
: null,
diff --git a/backend/app/Services/Application/Handlers/Question/CreateQuestionHandler.php b/backend/app/Services/Application/Handlers/Question/CreateQuestionHandler.php
index 9ecd720358..47c4670088 100644
--- a/backend/app/Services/Application/Handlers/Question/CreateQuestionHandler.php
+++ b/backend/app/Services/Application/Handlers/Question/CreateQuestionHandler.php
@@ -12,17 +12,15 @@ class CreateQuestionHandler
{
public function __construct(
private readonly CreateQuestionService $createQuestionService,
- private readonly HtmlPurifierService $purifier,
- )
- {
- }
+ private readonly HtmlPurifierService $purifier,
+ ) {}
/**
* @throws Throwable
*/
public function handle(UpsertQuestionDTO $createQuestionDTO): QuestionDomainObject
{
- $question = (new QuestionDomainObject())
+ $question = (new QuestionDomainObject)
->setTitle($createQuestionDTO->title)
->setEventId($createQuestionDTO->event_id)
->setBelongsTo($createQuestionDTO->belongs_to->name)
diff --git a/backend/app/Services/Application/Handlers/Question/DTO/EditQuestionAnswerDTO.php b/backend/app/Services/Application/Handlers/Question/DTO/EditQuestionAnswerDTO.php
index 2f94eb76c2..bbc01bd549 100644
--- a/backend/app/Services/Application/Handlers/Question/DTO/EditQuestionAnswerDTO.php
+++ b/backend/app/Services/Application/Handlers/Question/DTO/EditQuestionAnswerDTO.php
@@ -7,10 +7,8 @@
class EditQuestionAnswerDTO extends BaseDTO
{
public function __construct(
- public int $questionAnswerId,
- public int $eventId,
+ public int $questionAnswerId,
+ public int $eventId,
public null|array|string $answer,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Question/DTO/UpsertQuestionDTO.php b/backend/app/Services/Application/Handlers/Question/DTO/UpsertQuestionDTO.php
index e83869009c..dc6e7365a8 100644
--- a/backend/app/Services/Application/Handlers/Question/DTO/UpsertQuestionDTO.php
+++ b/backend/app/Services/Application/Handlers/Question/DTO/UpsertQuestionDTO.php
@@ -9,16 +9,14 @@
class UpsertQuestionDTO extends BaseDTO
{
public function __construct(
- public string $title,
- public QuestionTypeEnum $type,
- public bool $required,
- public ?array $options,
- public int $event_id,
- public array $product_ids,
- public bool $is_hidden,
+ public string $title,
+ public QuestionTypeEnum $type,
+ public bool $required,
+ public ?array $options,
+ public int $event_id,
+ public array $product_ids,
+ public bool $is_hidden,
public QuestionBelongsTo $belongs_to,
- public ?string $description = null,
- )
- {
- }
+ public ?string $description = null,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Question/DeleteQuestionHandler.php b/backend/app/Services/Application/Handlers/Question/DeleteQuestionHandler.php
index d9cecaa71c..ddd717760b 100644
--- a/backend/app/Services/Application/Handlers/Question/DeleteQuestionHandler.php
+++ b/backend/app/Services/Application/Handlers/Question/DeleteQuestionHandler.php
@@ -11,12 +11,10 @@
readonly class DeleteQuestionHandler
{
public function __construct(
- private QuestionRepositoryInterface $questionRepository,
+ private QuestionRepositoryInterface $questionRepository,
private QuestionAnswerRepositoryInterface $questionAnswersRepository,
- private DatabaseManager $databaseManager,
- )
- {
- }
+ private DatabaseManager $databaseManager,
+ ) {}
/**
* @throws CannotDeleteEntityException
diff --git a/backend/app/Services/Application/Handlers/Question/EditQuestionAnswerHandler.php b/backend/app/Services/Application/Handlers/Question/EditQuestionAnswerHandler.php
index b263bae718..d9f937e72f 100644
--- a/backend/app/Services/Application/Handlers/Question/EditQuestionAnswerHandler.php
+++ b/backend/app/Services/Application/Handlers/Question/EditQuestionAnswerHandler.php
@@ -11,9 +11,7 @@ class EditQuestionAnswerHandler
{
public function __construct(
private readonly EditQuestionAnswerService $editQuestionAnswerService,
- )
- {
- }
+ ) {}
/**
* @throws InvalidAnswerException
diff --git a/backend/app/Services/Application/Handlers/Question/EditQuestionHandler.php b/backend/app/Services/Application/Handlers/Question/EditQuestionHandler.php
index 1e86435869..c77386ae09 100644
--- a/backend/app/Services/Application/Handlers/Question/EditQuestionHandler.php
+++ b/backend/app/Services/Application/Handlers/Question/EditQuestionHandler.php
@@ -13,16 +13,14 @@ class EditQuestionHandler
public function __construct(
private readonly EditQuestionService $editQuestionService,
private readonly HtmlPurifierService $purifier,
- )
- {
- }
+ ) {}
/**
* @throws Throwable
*/
public function handle(int $questionId, UpsertQuestionDTO $createQuestionDTO): QuestionDomainObject
{
- $question = (new QuestionDomainObject())
+ $question = (new QuestionDomainObject)
->setId($questionId)
->setTitle($createQuestionDTO->title)
->setEventId($createQuestionDTO->event_id)
diff --git a/backend/app/Services/Application/Handlers/Question/ExportAnswersHandler.php b/backend/app/Services/Application/Handlers/Question/ExportAnswersHandler.php
index 8c1c5ea151..28010b856c 100644
--- a/backend/app/Services/Application/Handlers/Question/ExportAnswersHandler.php
+++ b/backend/app/Services/Application/Handlers/Question/ExportAnswersHandler.php
@@ -9,9 +9,7 @@ class ExportAnswersHandler
{
public function __construct(
private readonly QuestionAndAnswerViewRepositoryInterface $questionAndAnswerViewRepository,
- )
- {
- }
+ ) {}
public function handle(int $eventId): Collection
{
diff --git a/backend/app/Services/Application/Handlers/Question/SortQuestionsHandler.php b/backend/app/Services/Application/Handlers/Question/SortQuestionsHandler.php
index e0387bec71..61ded15a14 100644
--- a/backend/app/Services/Application/Handlers/Question/SortQuestionsHandler.php
+++ b/backend/app/Services/Application/Handlers/Question/SortQuestionsHandler.php
@@ -9,9 +9,7 @@
{
public function __construct(
private QuestionRepositoryInterface $questionRepository,
- )
- {
- }
+ ) {}
public function handle(int $eventId, array $data): void
{
@@ -20,12 +18,12 @@ public function handle(int $eventId, array $data): void
$questionIdResult = $this->questionRepository->findWhere([
'event_id' => $eventId,
])
- ->map(fn($product) => $product->getId())
+ ->map(fn ($product) => $product->getId())
->toArray();
$extraInOrdered = array_diff($orderedQuestionIds, $questionIdResult);
- if (!empty($extraInOrdered)) {
+ if (! empty($extraInOrdered)) {
throw new ResourceNotFoundException(
__('One or more of the ordered question IDs do not exist for the event.')
);
diff --git a/backend/app/Services/Application/Handlers/Reports/DTO/GetOrganizerReportDTO.php b/backend/app/Services/Application/Handlers/Reports/DTO/GetOrganizerReportDTO.php
index d623fcaa40..cf67d410c4 100644
--- a/backend/app/Services/Application/Handlers/Reports/DTO/GetOrganizerReportDTO.php
+++ b/backend/app/Services/Application/Handlers/Reports/DTO/GetOrganizerReportDTO.php
@@ -8,15 +8,13 @@
class GetOrganizerReportDTO extends BaseDataObject
{
public function __construct(
- public readonly int $organizerId,
- public readonly OrganizerReportTypes $reportType,
- public readonly ?string $startDate,
- public readonly ?string $endDate,
- public readonly ?string $currency,
- public readonly ?int $eventId = null,
- public readonly int $page = 1,
- public readonly int $perPage = 1000,
- )
- {
- }
+ public readonly int $organizerId,
+ public readonly OrganizerReportTypes $reportType,
+ public readonly ?string $startDate,
+ public readonly ?string $endDate,
+ public readonly ?string $currency,
+ public readonly ?int $eventId = null,
+ public readonly int $page = 1,
+ public readonly int $perPage = 1000,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Reports/DTO/GetReportDTO.php b/backend/app/Services/Application/Handlers/Reports/DTO/GetReportDTO.php
index 295c9b4da9..8f9c516d32 100644
--- a/backend/app/Services/Application/Handlers/Reports/DTO/GetReportDTO.php
+++ b/backend/app/Services/Application/Handlers/Reports/DTO/GetReportDTO.php
@@ -8,11 +8,10 @@
class GetReportDTO extends BaseDTO
{
public function __construct(
- public readonly int $eventId,
+ public readonly int $eventId,
public readonly ReportTypes $reportType,
- public readonly ?string $startDate,
- public readonly ?string $endDate
- )
- {
- }
+ public readonly ?string $startDate,
+ public readonly ?string $endDate,
+ public readonly ?int $occurrenceId = null,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Reports/GetOrganizerReportHandler.php b/backend/app/Services/Application/Handlers/Reports/GetOrganizerReportHandler.php
index 89f4cdf051..b35984011c 100644
--- a/backend/app/Services/Application/Handlers/Reports/GetOrganizerReportHandler.php
+++ b/backend/app/Services/Application/Handlers/Reports/GetOrganizerReportHandler.php
@@ -13,9 +13,7 @@ class GetOrganizerReportHandler
{
public function __construct(
private readonly OrganizerReportServiceFactory $reportServiceFactory,
- )
- {
- }
+ ) {}
public function handle(GetOrganizerReportDTO $reportData): Collection|PaginatedReportDTO
{
diff --git a/backend/app/Services/Application/Handlers/Reports/GetReportHandler.php b/backend/app/Services/Application/Handlers/Reports/GetReportHandler.php
index 9541081ac0..ea9c150386 100644
--- a/backend/app/Services/Application/Handlers/Reports/GetReportHandler.php
+++ b/backend/app/Services/Application/Handlers/Reports/GetReportHandler.php
@@ -11,9 +11,7 @@ class GetReportHandler
{
public function __construct(
private readonly ReportServiceFactory $reportServiceFactory,
- )
- {
- }
+ ) {}
public function handle(GetReportDTO $reportData): Collection
{
@@ -23,6 +21,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/DTO/EditAttendeePublicDTO.php b/backend/app/Services/Application/Handlers/SelfService/DTO/EditAttendeePublicDTO.php
index 525bba9173..e6878960ed 100644
--- a/backend/app/Services/Application/Handlers/SelfService/DTO/EditAttendeePublicDTO.php
+++ b/backend/app/Services/Application/Handlers/SelfService/DTO/EditAttendeePublicDTO.php
@@ -15,6 +15,5 @@ public function __construct(
public readonly ?string $email,
public readonly string $ipAddress,
public readonly ?string $userAgent,
- ) {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/SelfService/DTO/EditOrderPublicDTO.php b/backend/app/Services/Application/Handlers/SelfService/DTO/EditOrderPublicDTO.php
index ed5b7e8e19..a269b046c6 100644
--- a/backend/app/Services/Application/Handlers/SelfService/DTO/EditOrderPublicDTO.php
+++ b/backend/app/Services/Application/Handlers/SelfService/DTO/EditOrderPublicDTO.php
@@ -14,6 +14,5 @@ public function __construct(
public readonly ?string $email,
public readonly string $ipAddress,
public readonly ?string $userAgent,
- ) {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/SelfService/DTO/ResendEmailPublicDTO.php b/backend/app/Services/Application/Handlers/SelfService/DTO/ResendEmailPublicDTO.php
index ca11b282e4..1720e96128 100644
--- a/backend/app/Services/Application/Handlers/SelfService/DTO/ResendEmailPublicDTO.php
+++ b/backend/app/Services/Application/Handlers/SelfService/DTO/ResendEmailPublicDTO.php
@@ -12,6 +12,5 @@ public function __construct(
public readonly ?string $attendeeShortId,
public readonly string $ipAddress,
public readonly ?string $userAgent,
- ) {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/SelfService/EditAttendeePublicHandler.php b/backend/app/Services/Application/Handlers/SelfService/EditAttendeePublicHandler.php
index 02ac9a82e4..5322729684 100644
--- a/backend/app/Services/Application/Handlers/SelfService/EditAttendeePublicHandler.php
+++ b/backend/app/Services/Application/Handlers/SelfService/EditAttendeePublicHandler.php
@@ -21,8 +21,7 @@ public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly EventRepositoryInterface $eventRepository,
private readonly SelfServiceEditAttendeeService $selfServiceEditAttendeeService,
- ) {
- }
+ ) {}
/**
* @throws SelfServiceDisabledException
@@ -38,7 +37,7 @@ public function handle(EditAttendeePublicDTO $dto): EditAttendeeResultDTO
AttendeeDomainObjectAbstract::EVENT_ID => $dto->eventId,
]);
- if (!$attendee) {
+ if (! $attendee) {
throw new ResourceNotFoundException(__('Attendee not found'));
}
diff --git a/backend/app/Services/Application/Handlers/SelfService/EditOrderPublicHandler.php b/backend/app/Services/Application/Handlers/SelfService/EditOrderPublicHandler.php
index 7c8e9d4654..dbbf7c09c1 100644
--- a/backend/app/Services/Application/Handlers/SelfService/EditOrderPublicHandler.php
+++ b/backend/app/Services/Application/Handlers/SelfService/EditOrderPublicHandler.php
@@ -16,8 +16,7 @@ public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly EventRepositoryInterface $eventRepository,
private readonly SelfServiceEditOrderService $selfServiceEditOrderService,
- ) {
- }
+ ) {}
public function handle(EditOrderPublicDTO $dto): EditOrderResultDTO
{
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/SelfService/ResendOrderConfirmationPublicHandler.php b/backend/app/Services/Application/Handlers/SelfService/ResendOrderConfirmationPublicHandler.php
index f755dcc0cf..f536b52a07 100644
--- a/backend/app/Services/Application/Handlers/SelfService/ResendOrderConfirmationPublicHandler.php
+++ b/backend/app/Services/Application/Handlers/SelfService/ResendOrderConfirmationPublicHandler.php
@@ -15,8 +15,7 @@ public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly EventRepositoryInterface $eventRepository,
private readonly SelfServiceResendEmailService $selfServiceResendEmailService,
- ) {
- }
+ ) {}
public function handle(ResendEmailPublicDTO $dto): void
{
diff --git a/backend/app/Services/Application/Handlers/SelfService/SelfServiceValidationTrait.php b/backend/app/Services/Application/Handlers/SelfService/SelfServiceValidationTrait.php
index d430492028..b9bc7447b6 100644
--- a/backend/app/Services/Application/Handlers/SelfService/SelfServiceValidationTrait.php
+++ b/backend/app/Services/Application/Handlers/SelfService/SelfServiceValidationTrait.php
@@ -12,8 +12,8 @@ trait SelfServiceValidationTrait
{
private function validateSelfServiceEnabled(EventDomainObject $event): void
{
- if (!$event->getEventSettings()?->getAllowAttendeeSelfEdit()) {
- throw new SelfServiceDisabledException();
+ if (! $event->getEventSettings()?->getAllowAttendeeSelfEdit()) {
+ throw new SelfServiceDisabledException;
}
}
@@ -26,7 +26,7 @@ private function loadAndValidateEvent(int $eventId): EventDomainObject
->loadRelation(EventSettingDomainObject::class)
->findById($eventId);
- if (!$event) {
+ if (! $event) {
throw new ResourceNotFoundException(__('Event not found'));
}
@@ -39,7 +39,7 @@ private function loadAndValidateOrder(string $orderShortId, int $eventId): Order
{
$order = $this->orderRepository->findByShortId($orderShortId);
- if (!$order || $order->getEventId() !== $eventId) {
+ if (! $order || $order->getEventId() !== $eventId) {
throw new ResourceNotFoundException(__('Order not found'));
}
diff --git a/backend/app/Services/Application/Handlers/Sitemap/GetSitemapEventsHandler.php b/backend/app/Services/Application/Handlers/Sitemap/GetSitemapEventsHandler.php
index 03150c92dc..8543a88f0b 100644
--- a/backend/app/Services/Application/Handlers/Sitemap/GetSitemapEventsHandler.php
+++ b/backend/app/Services/Application/Handlers/Sitemap/GetSitemapEventsHandler.php
@@ -12,14 +12,13 @@
class GetSitemapEventsHandler
{
private const CACHE_KEY_PREFIX = 'sitemap:events:';
+
private const MIN_PAGE = 1;
public function __construct(
private readonly EventRepositoryInterface $eventRepository,
private readonly SitemapGeneratorService $sitemapGenerator,
- )
- {
- }
+ ) {}
public function handle(int $page): string
{
@@ -36,7 +35,7 @@ public function handle(int $page): string
}
$cacheTtl = (int) config('sitemap.cache_ttl');
- $cacheKey = self::CACHE_KEY_PREFIX . $page;
+ $cacheKey = self::CACHE_KEY_PREFIX.$page;
return Cache::remember($cacheKey, $cacheTtl, function () use ($page, $eventsPerPage): string {
$events = $this->eventRepository->getSitemapEvents($page, $eventsPerPage);
diff --git a/backend/app/Services/Application/Handlers/Sitemap/GetSitemapIndexHandler.php b/backend/app/Services/Application/Handlers/Sitemap/GetSitemapIndexHandler.php
index 8e501615ca..c0c0c783b1 100644
--- a/backend/app/Services/Application/Handlers/Sitemap/GetSitemapIndexHandler.php
+++ b/backend/app/Services/Application/Handlers/Sitemap/GetSitemapIndexHandler.php
@@ -12,14 +12,14 @@
class GetSitemapIndexHandler
{
private const CACHE_KEY = 'sitemap:index';
+
private const MIN_PAGES = 1;
public function __construct(
private readonly EventRepositoryInterface $eventRepository,
private readonly OrganizerRepositoryInterface $organizerRepository,
private readonly SitemapGeneratorService $sitemapGenerator,
- ) {
- }
+ ) {}
public function handle(): string
{
diff --git a/backend/app/Services/Application/Handlers/Sitemap/GetSitemapOrganizersHandler.php b/backend/app/Services/Application/Handlers/Sitemap/GetSitemapOrganizersHandler.php
index 8652110de9..f3c69208ea 100644
--- a/backend/app/Services/Application/Handlers/Sitemap/GetSitemapOrganizersHandler.php
+++ b/backend/app/Services/Application/Handlers/Sitemap/GetSitemapOrganizersHandler.php
@@ -12,13 +12,13 @@
class GetSitemapOrganizersHandler
{
private const CACHE_KEY_PREFIX = 'sitemap:organizers:';
+
private const MIN_PAGE = 1;
public function __construct(
private readonly OrganizerRepositoryInterface $organizerRepository,
private readonly SitemapGeneratorService $sitemapGenerator,
- ) {
- }
+ ) {}
/**
* @throws ResourceNotFoundException
@@ -38,7 +38,7 @@ public function handle(int $page): string
}
$cacheTtl = (int) config('sitemap.cache_ttl');
- $cacheKey = self::CACHE_KEY_PREFIX . $page;
+ $cacheKey = self::CACHE_KEY_PREFIX.$page;
return Cache::remember($cacheKey, $cacheTtl, function () use ($page, $organizersPerPage): string {
$organizers = $this->organizerRepository->getSitemapOrganizers($page, $organizersPerPage);
diff --git a/backend/app/Services/Application/Handlers/TaxAndFee/CreateTaxOrFeeHandler.php b/backend/app/Services/Application/Handlers/TaxAndFee/CreateTaxOrFeeHandler.php
index faaa849b69..ae9b64d449 100644
--- a/backend/app/Services/Application/Handlers/TaxAndFee/CreateTaxOrFeeHandler.php
+++ b/backend/app/Services/Application/Handlers/TaxAndFee/CreateTaxOrFeeHandler.php
@@ -19,10 +19,9 @@ class CreateTaxOrFeeHandler
public function __construct(
TaxAndFeeRepositoryInterface $taxRepository,
- LoggerInterface $logger,
- DuplicateTaxService $duplicateTaxService
- )
- {
+ LoggerInterface $logger,
+ DuplicateTaxService $duplicateTaxService
+ ) {
$this->taxRepository = $taxRepository;
$this->logger = $logger;
$this->duplicateTaxService = $duplicateTaxService;
diff --git a/backend/app/Services/Application/Handlers/TaxAndFee/DTO/DeleteTaxDTO.php b/backend/app/Services/Application/Handlers/TaxAndFee/DTO/DeleteTaxDTO.php
index d6914b72b7..9fa6f2409f 100644
--- a/backend/app/Services/Application/Handlers/TaxAndFee/DTO/DeleteTaxDTO.php
+++ b/backend/app/Services/Application/Handlers/TaxAndFee/DTO/DeleteTaxDTO.php
@@ -9,7 +9,5 @@ class DeleteTaxDTO extends BaseDTO
public function __construct(
public readonly int $taxId,
public readonly int $accountId,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/TaxAndFee/DTO/UpsertTaxDTO.php b/backend/app/Services/Application/Handlers/TaxAndFee/DTO/UpsertTaxDTO.php
index 34d4e66bf6..11d8494b13 100644
--- a/backend/app/Services/Application/Handlers/TaxAndFee/DTO/UpsertTaxDTO.php
+++ b/backend/app/Services/Application/Handlers/TaxAndFee/DTO/UpsertTaxDTO.php
@@ -9,16 +9,14 @@
class UpsertTaxDTO extends BaseDTO
{
public function __construct(
- public readonly string $name,
- public readonly ?string $description,
+ public readonly string $name,
+ public readonly ?string $description,
public readonly TaxCalculationType $calculation_type,
- public readonly TaxType $type,
- public readonly float $rate,
- public readonly bool $is_active,
- public readonly bool $is_default,
- public readonly int $account_id,
- public readonly ?int $id = null,
- )
- {
- }
+ public readonly TaxType $type,
+ public readonly float $rate,
+ public readonly bool $is_active,
+ public readonly bool $is_default,
+ public readonly int $account_id,
+ public readonly ?int $id = null,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/TaxAndFee/DeleteTaxHandler.php b/backend/app/Services/Application/Handlers/TaxAndFee/DeleteTaxHandler.php
index 11a8cabae8..4f36597414 100644
--- a/backend/app/Services/Application/Handlers/TaxAndFee/DeleteTaxHandler.php
+++ b/backend/app/Services/Application/Handlers/TaxAndFee/DeleteTaxHandler.php
@@ -15,11 +15,9 @@
{
public function __construct(
private TaxAndFeeRepositoryInterface $taxRepository,
- private LoggerInterface $logger,
- private DatabaseManager $databaseManager
- )
- {
- }
+ private LoggerInterface $logger,
+ private DatabaseManager $databaseManager
+ ) {}
/**
* @throws ResourceConflictException
@@ -33,8 +31,8 @@ public function handle(DeleteTaxDTO $taxData): void
TaxAndFeesDomainObjectAbstract::ACCOUNT_ID => $taxData->accountId,
]);
- if (!$tax) {
- throw new ResourceNotFoundException();
+ if (! $tax) {
+ throw new ResourceNotFoundException;
}
$this->taxRepository->deleteWhere([
diff --git a/backend/app/Services/Application/Handlers/TaxAndFee/EditTaxHandler.php b/backend/app/Services/Application/Handlers/TaxAndFee/EditTaxHandler.php
index 50e1163a2f..861a33754e 100644
--- a/backend/app/Services/Application/Handlers/TaxAndFee/EditTaxHandler.php
+++ b/backend/app/Services/Application/Handlers/TaxAndFee/EditTaxHandler.php
@@ -19,10 +19,9 @@ class EditTaxHandler
public function __construct(
TaxAndFeeRepositoryInterface $taxRepository,
- LoggerInterface $logger,
- DuplicateTaxService $duplicateTaxService
- )
- {
+ LoggerInterface $logger,
+ DuplicateTaxService $duplicateTaxService
+ ) {
$this->taxRepository = $taxRepository;
$this->logger = $logger;
$this->duplicateTaxService = $duplicateTaxService;
diff --git a/backend/app/Services/Application/Handlers/TicketLookup/DTO/GetOrdersByLookupTokenDTO.php b/backend/app/Services/Application/Handlers/TicketLookup/DTO/GetOrdersByLookupTokenDTO.php
index 0ea8adb1fc..3cfbf7332a 100644
--- a/backend/app/Services/Application/Handlers/TicketLookup/DTO/GetOrdersByLookupTokenDTO.php
+++ b/backend/app/Services/Application/Handlers/TicketLookup/DTO/GetOrdersByLookupTokenDTO.php
@@ -8,6 +8,5 @@ class GetOrdersByLookupTokenDTO extends BaseDataObject
{
public function __construct(
public readonly string $token,
- ) {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/TicketLookup/DTO/SendTicketLookupEmailDTO.php b/backend/app/Services/Application/Handlers/TicketLookup/DTO/SendTicketLookupEmailDTO.php
index e8aa94b0b9..3cca584139 100644
--- a/backend/app/Services/Application/Handlers/TicketLookup/DTO/SendTicketLookupEmailDTO.php
+++ b/backend/app/Services/Application/Handlers/TicketLookup/DTO/SendTicketLookupEmailDTO.php
@@ -8,6 +8,5 @@ class SendTicketLookupEmailDTO extends BaseDataObject
{
public function __construct(
public readonly string $email,
- ) {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/TicketLookup/GetOrdersByLookupTokenHandler.php b/backend/app/Services/Application/Handlers/TicketLookup/GetOrdersByLookupTokenHandler.php
index 5e7aa59b44..f7cdce59e0 100644
--- a/backend/app/Services/Application/Handlers/TicketLookup/GetOrdersByLookupTokenHandler.php
+++ b/backend/app/Services/Application/Handlers/TicketLookup/GetOrdersByLookupTokenHandler.php
@@ -5,6 +5,7 @@
use Carbon\Carbon;
use HiEvents\DomainObjects\AttendeeDomainObject;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\Generated\EventDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract;
@@ -32,19 +33,19 @@ public function __construct(
private readonly TicketLookupTokenRepositoryInterface $ticketLookupTokenRepository,
private readonly OrderRepositoryInterface $orderRepository,
private readonly OfflinePaymentInstructionsRenderService $offlinePaymentInstructionsRenderService,
- ) {
- }
+ ) {}
/**
- * @throws InvalidTicketLookupTokenException
* @return Collection
+ *
+ * @throws InvalidTicketLookupTokenException
*/
public function handle(GetOrdersByLookupTokenDTO $dto): Collection
{
$tokenRecord = $this->validateAndFetchToken($dto->token);
return $this->getOrdersForEmail($tokenRecord->getEmail())
- ->each(fn(OrderDomainObject $order) => $this->offlinePaymentInstructionsRenderService->renderForOrder($order));
+ ->each(fn (OrderDomainObject $order) => $this->offlinePaymentInstructionsRenderService->renderForOrder($order));
}
/**
@@ -54,7 +55,7 @@ private function validateAndFetchToken(string $token): TicketLookupTokenDomainOb
{
$tokenRecord = $this->ticketLookupTokenRepository->findFirstWhere(['token' => $token]);
- if (!$tokenRecord) {
+ if (! $tokenRecord) {
throw new InvalidTicketLookupTokenException(__('Invalid or expired link. Please request a new one.'));
}
@@ -84,10 +85,10 @@ private function getOrdersForEmail(string $email): Collection
nested: [
new Relationship(
domainObject: ProductPriceDomainObject::class,
- )
+ ),
],
name: ProductDomainObjectAbstract::SINGULAR_NAME,
- )
+ ),
],
))
->loadRelation(new Relationship(
@@ -102,7 +103,10 @@ private function getOrdersForEmail(string $email): Collection
),
new Relationship(
domainObject: ImageDomainObject::class,
- )
+ ),
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ ),
],
name: EventDomainObjectAbstract::SINGULAR_NAME
))
diff --git a/backend/app/Services/Application/Handlers/TicketLookup/SendTicketLookupEmailHandler.php b/backend/app/Services/Application/Handlers/TicketLookup/SendTicketLookupEmailHandler.php
index ba5ff3bbd1..e70618852f 100644
--- a/backend/app/Services/Application/Handlers/TicketLookup/SendTicketLookupEmailHandler.php
+++ b/backend/app/Services/Application/Handlers/TicketLookup/SendTicketLookupEmailHandler.php
@@ -27,8 +27,7 @@ public function __construct(
private readonly Mailer $mailer,
private readonly LoggerInterface $logger,
private readonly DatabaseManager $databaseManager,
- ) {
- }
+ ) {}
/**
* @throws Throwable
@@ -43,6 +42,7 @@ public function handle(SendTicketLookupEmailDTO $dto): void
$this->logger->info('Ticket lookup requested for email with no orders', [
'email' => $email,
]);
+
return;
}
diff --git a/backend/app/Services/Application/Handlers/User/CancelEmailChangeHandler.php b/backend/app/Services/Application/Handlers/User/CancelEmailChangeHandler.php
index f4c086b20a..79cd7d8bb5 100644
--- a/backend/app/Services/Application/Handlers/User/CancelEmailChangeHandler.php
+++ b/backend/app/Services/Application/Handlers/User/CancelEmailChangeHandler.php
@@ -15,10 +15,9 @@ class CancelEmailChangeHandler
private UserRepositoryInterface $userRepository;
public function __construct(
- LoggerInterface $logger,
+ LoggerInterface $logger,
UserRepositoryInterface $userRepository,
- )
- {
+ ) {
$this->logger = $logger;
$this->userRepository = $userRepository;
}
diff --git a/backend/app/Services/Application/Handlers/User/ConfirmEmailAddressHandler.php b/backend/app/Services/Application/Handlers/User/ConfirmEmailAddressHandler.php
index d3adb5e83a..e4430906c0 100644
--- a/backend/app/Services/Application/Handlers/User/ConfirmEmailAddressHandler.php
+++ b/backend/app/Services/Application/Handlers/User/ConfirmEmailAddressHandler.php
@@ -11,9 +11,7 @@
{
public function __construct(
private EmailConfirmationService $emailConfirmationService,
- )
- {
- }
+ ) {}
/**
* @throws DecryptionFailedException|Throwable
diff --git a/backend/app/Services/Application/Handlers/User/ConfirmEmailChangeHandler.php b/backend/app/Services/Application/Handlers/User/ConfirmEmailChangeHandler.php
index 1294d3522b..a0df652e72 100644
--- a/backend/app/Services/Application/Handlers/User/ConfirmEmailChangeHandler.php
+++ b/backend/app/Services/Application/Handlers/User/ConfirmEmailChangeHandler.php
@@ -15,13 +15,11 @@
readonly class ConfirmEmailChangeHandler
{
public function __construct(
- private LoggerInterface $logger,
+ private LoggerInterface $logger,
private UserRepositoryInterface $userRepository,
private EncryptedPayloadService $encryptedPayloadService,
- private DatabaseManager $databaseManager,
- )
- {
- }
+ private DatabaseManager $databaseManager,
+ ) {}
/**
* @throws DecryptionFailedException|Throwable
diff --git a/backend/app/Services/Application/Handlers/User/ConfirmEmailWithCodeHandler.php b/backend/app/Services/Application/Handlers/User/ConfirmEmailWithCodeHandler.php
index a34d6c5958..5cdbb17565 100644
--- a/backend/app/Services/Application/Handlers/User/ConfirmEmailWithCodeHandler.php
+++ b/backend/app/Services/Application/Handlers/User/ConfirmEmailWithCodeHandler.php
@@ -14,12 +14,10 @@ class ConfirmEmailWithCodeHandler
{
public function __construct(
private readonly EmailVerificationCodeService $emailVerificationCodeService,
- private readonly UserRepositoryInterface $userRepository,
- private readonly DatabaseManager $databaseManager,
- private readonly VerifyUserEmailService $verifyUserEmailService,
- )
- {
- }
+ private readonly UserRepositoryInterface $userRepository,
+ private readonly DatabaseManager $databaseManager,
+ private readonly VerifyUserEmailService $verifyUserEmailService,
+ ) {}
public function handle(ConfirmEmailWithCodeDTO $dto): void
{
@@ -30,7 +28,7 @@ public function handle(ConfirmEmailWithCodeDTO $dto): void
throw new ResourceConflictException(__('Your email address has already been verified.'));
}
- if (!$this->emailVerificationCodeService->verifyCode($user->getEmail(), $dto->code)) {
+ if (! $this->emailVerificationCodeService->verifyCode($user->getEmail(), $dto->code)) {
throw new InvalidEmailVerificationCodeException(__('The verification code is invalid or has expired.'));
}
diff --git a/backend/app/Services/Application/Handlers/User/CreateUserHandler.php b/backend/app/Services/Application/Handlers/User/CreateUserHandler.php
index ccfd65c1f5..c6986e8d3f 100644
--- a/backend/app/Services/Application/Handlers/User/CreateUserHandler.php
+++ b/backend/app/Services/Application/Handlers/User/CreateUserHandler.php
@@ -19,14 +19,12 @@
readonly class CreateUserHandler
{
public function __construct(
- private UserRepositoryInterface $userRepository,
- private AccountRepositoryInterface $accountRepository,
- private SendUserInvitationService $sendUserInvitationService,
+ private UserRepositoryInterface $userRepository,
+ private AccountRepositoryInterface $accountRepository,
+ private SendUserInvitationService $sendUserInvitationService,
private AccountUserAssociationService $accountUserAssociationService,
- private DatabaseManager $databaseManager,
- )
- {
- }
+ private DatabaseManager $databaseManager,
+ ) {}
/**
* @throws ResourceConflictException
@@ -90,7 +88,7 @@ private function getExistingUser(CreateUserDTO $userData): ?UserDomainObject
return null;
}
- if ($existingUser->accounts->some(fn($account) => $account->getId() === $userData->account_id)) {
+ if ($existingUser->accounts->some(fn ($account) => $account->getId() === $userData->account_id)) {
throw new ResourceConflictException(
__('The email :email already exists on this account', [
'email' => $userData->email,
diff --git a/backend/app/Services/Application/Handlers/User/DTO/CancelEmailChangeDTO.php b/backend/app/Services/Application/Handlers/User/DTO/CancelEmailChangeDTO.php
index cc495a10b5..67e43df9b6 100644
--- a/backend/app/Services/Application/Handlers/User/DTO/CancelEmailChangeDTO.php
+++ b/backend/app/Services/Application/Handlers/User/DTO/CancelEmailChangeDTO.php
@@ -9,7 +9,5 @@ class CancelEmailChangeDTO extends BaseDTO
public function __construct(
public int $userId,
public int $accountId,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/User/DTO/ConfirmEmailChangeDTO.php b/backend/app/Services/Application/Handlers/User/DTO/ConfirmEmailChangeDTO.php
index bcb7fc03aa..c4f7bf53bd 100644
--- a/backend/app/Services/Application/Handlers/User/DTO/ConfirmEmailChangeDTO.php
+++ b/backend/app/Services/Application/Handlers/User/DTO/ConfirmEmailChangeDTO.php
@@ -9,7 +9,5 @@ class ConfirmEmailChangeDTO extends BaseDTO
public function __construct(
public string $token,
public int $accountId,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/User/DTO/ConfirmEmailWithCodeDTO.php b/backend/app/Services/Application/Handlers/User/DTO/ConfirmEmailWithCodeDTO.php
index f157afe000..a53960f97c 100644
--- a/backend/app/Services/Application/Handlers/User/DTO/ConfirmEmailWithCodeDTO.php
+++ b/backend/app/Services/Application/Handlers/User/DTO/ConfirmEmailWithCodeDTO.php
@@ -7,6 +7,8 @@
class ConfirmEmailWithCodeDTO extends BaseDataObject
{
public string $code;
+
public int $userId;
+
public int $accountId;
}
diff --git a/backend/app/Services/Application/Handlers/User/DTO/CreateUserDTO.php b/backend/app/Services/Application/Handlers/User/DTO/CreateUserDTO.php
index 1e871c54bb..489359b64e 100644
--- a/backend/app/Services/Application/Handlers/User/DTO/CreateUserDTO.php
+++ b/backend/app/Services/Application/Handlers/User/DTO/CreateUserDTO.php
@@ -10,15 +10,13 @@
class CreateUserDTO extends BaseDataObject
{
public function __construct(
- public string $first_name,
- public ?string $last_name = null,
- public string $email,
- public int $invited_by,
- public int $account_id,
+ public string $first_name,
+ public ?string $last_name,
+ public string $email,
+ public int $invited_by,
+ public int $account_id,
#[WithCast(EnumCast::class)]
- public Role $role,
- )
- {
- }
+ public Role $role,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/User/DTO/UpdateMeDTO.php b/backend/app/Services/Application/Handlers/User/DTO/UpdateMeDTO.php
index 6bdfc044c5..ba95f618a1 100644
--- a/backend/app/Services/Application/Handlers/User/DTO/UpdateMeDTO.php
+++ b/backend/app/Services/Application/Handlers/User/DTO/UpdateMeDTO.php
@@ -7,8 +7,8 @@
class UpdateMeDTO extends BaseDTO
{
public function __construct(
- public readonly int $id,
- public readonly int $account_id,
+ public readonly int $id,
+ public readonly int $account_id,
public readonly ?string $first_name,
public readonly ?string $last_name,
public readonly ?string $email,
@@ -16,8 +16,6 @@ public function __construct(
public readonly ?string $password,
public readonly ?string $current_password,
public readonly ?string $locale,
- public readonly ?bool $marketing_opt_in = null,
- )
- {
- }
+ public readonly ?bool $marketing_opt_in = null,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/User/DTO/UpdateUserDTO.php b/backend/app/Services/Application/Handlers/User/DTO/UpdateUserDTO.php
index 3b64b3b555..93032dec18 100644
--- a/backend/app/Services/Application/Handlers/User/DTO/UpdateUserDTO.php
+++ b/backend/app/Services/Application/Handlers/User/DTO/UpdateUserDTO.php
@@ -9,14 +9,12 @@
class UpdateUserDTO extends BaseDTO
{
public function __construct(
- public readonly int $id,
- public readonly int $account_id,
- public readonly string $first_name,
- public readonly string $last_name,
- public readonly Role $role,
+ public readonly int $id,
+ public readonly int $account_id,
+ public readonly string $first_name,
+ public readonly string $last_name,
+ public readonly Role $role,
public readonly UserStatus $status,
- public readonly int $updated_by_user_id,
- )
- {
- }
+ public readonly int $updated_by_user_id,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/User/Exception/InvalidEmailVerificationCodeException.php b/backend/app/Services/Application/Handlers/User/Exception/InvalidEmailVerificationCodeException.php
index e40643dd8e..502c7a6f6e 100644
--- a/backend/app/Services/Application/Handlers/User/Exception/InvalidEmailVerificationCodeException.php
+++ b/backend/app/Services/Application/Handlers/User/Exception/InvalidEmailVerificationCodeException.php
@@ -4,7 +4,4 @@
use Exception;
-class InvalidEmailVerificationCodeException extends Exception
-{
-
-}
+class InvalidEmailVerificationCodeException extends Exception {}
diff --git a/backend/app/Services/Application/Handlers/User/ResendEmailConfirmationHandler.php b/backend/app/Services/Application/Handlers/User/ResendEmailConfirmationHandler.php
index 6a0d1f024b..86e6e8e33b 100644
--- a/backend/app/Services/Application/Handlers/User/ResendEmailConfirmationHandler.php
+++ b/backend/app/Services/Application/Handlers/User/ResendEmailConfirmationHandler.php
@@ -9,9 +9,7 @@ class ResendEmailConfirmationHandler
{
public function __construct(
private readonly EmailConfirmationService $emailConfirmationService,
- )
- {
- }
+ ) {}
public function handle(UserDomainObject $user, int $accountId): void
{
diff --git a/backend/app/Services/Application/Handlers/User/UpdateMeHandler.php b/backend/app/Services/Application/Handlers/User/UpdateMeHandler.php
index 965a9257b6..1464616e60 100644
--- a/backend/app/Services/Application/Handlers/User/UpdateMeHandler.php
+++ b/backend/app/Services/Application/Handlers/User/UpdateMeHandler.php
@@ -10,18 +10,15 @@
use HiEvents\Services\Infrastructure\Encryption\EncryptedPayloadService;
use Illuminate\Contracts\Hashing\Hasher;
use Illuminate\Contracts\Mail\Mailer;
-use Symfony\Component\Routing\Exception\ResourceNotFoundException;
readonly class UpdateMeHandler
{
public function __construct(
private UserRepositoryInterface $userRepository,
- private Hasher $hasher,
- private Mailer $mailer,
+ private Hasher $hasher,
+ private Mailer $mailer,
private EncryptedPayloadService $encryptedPayloadService,
- )
- {
- }
+ ) {}
/**
* @throws PasswordInvalidException
@@ -76,7 +73,7 @@ private function isChangingPassword(UpdateMeDTO $updateUserData): bool
*/
private function validateCurrentPassword(UpdateMeDTO $updateUserData, UserDomainObject $existingUser): void
{
- if (!$this->hasher->check($updateUserData->current_password, $existingUser->getPassword())) {
+ if (! $this->hasher->check($updateUserData->current_password, $existingUser->getPassword())) {
throw new PasswordInvalidException('Current password is invalid');
}
}
@@ -100,15 +97,11 @@ private function sendEmailChangeConfirmation(UserDomainObject $existingUser): vo
->to($existingUser->getEmail())
->locale($existingUser->getLocale())
->send(new ConfirmEmailChangeMail($existingUser, $this->encryptedPayloadService->encryptPayload([
- 'id' => $existingUser->getId(),
- ]))
+ 'id' => $existingUser->getId(),
+ ]))
);
}
- /**
- * @param UpdateMeDTO $updateUserData
- * @return bool
- */
private function isUpdatingDetails(UpdateMeDTO $updateUserData): bool
{
return $updateUserData->first_name !== null || $updateUserData->last_name !== null || $updateUserData->timezone !== null || $updateUserData->email !== null;
diff --git a/backend/app/Services/Application/Handlers/User/UpdateUserHandler.php b/backend/app/Services/Application/Handlers/User/UpdateUserHandler.php
index f4af75e60c..3935fa0fd9 100644
--- a/backend/app/Services/Application/Handlers/User/UpdateUserHandler.php
+++ b/backend/app/Services/Application/Handlers/User/UpdateUserHandler.php
@@ -17,13 +17,11 @@
class UpdateUserHandler
{
public function __construct(
- private readonly UserRepositoryInterface $userRepository,
- private readonly LoggerInterface $logger,
+ private readonly UserRepositoryInterface $userRepository,
+ private readonly LoggerInterface $logger,
private readonly AccountUserRepositoryInterface $accountUserRepository,
- private readonly DatabaseManager $databaseManager,
- )
- {
- }
+ private readonly DatabaseManager $databaseManager,
+ ) {}
/**
* @throws CannotUpdateResourceException|Throwable
diff --git a/backend/app/Services/Application/Handlers/Waitlist/CancelWaitlistEntryHandler.php b/backend/app/Services/Application/Handlers/Waitlist/CancelWaitlistEntryHandler.php
index a22d046975..005f586c43 100644
--- a/backend/app/Services/Application/Handlers/Waitlist/CancelWaitlistEntryHandler.php
+++ b/backend/app/Services/Application/Handlers/Waitlist/CancelWaitlistEntryHandler.php
@@ -11,9 +11,7 @@ class CancelWaitlistEntryHandler
{
public function __construct(
private readonly CancelWaitlistEntryService $cancelWaitlistEntryService,
- )
- {
- }
+ ) {}
/**
* @throws ResourceConflictException
diff --git a/backend/app/Services/Application/Handlers/Waitlist/CreateWaitlistEntryHandler.php b/backend/app/Services/Application/Handlers/Waitlist/CreateWaitlistEntryHandler.php
index 7813771413..c51d5cb7bd 100644
--- a/backend/app/Services/Application/Handlers/Waitlist/CreateWaitlistEntryHandler.php
+++ b/backend/app/Services/Application/Handlers/Waitlist/CreateWaitlistEntryHandler.php
@@ -2,25 +2,33 @@
namespace HiEvents\Services\Application\Handlers\Waitlist;
+use HiEvents\DomainObjects\Enums\EventType;
+use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract;
use HiEvents\DomainObjects\WaitlistEntryDomainObject;
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 CreateWaitlistEntryService $createWaitlistEntryService,
private readonly EventSettingsRepositoryInterface $eventSettingsRepository,
- private readonly ProductPriceRepositoryInterface $productPriceRepository,
- private readonly ProductRepositoryInterface $productRepository,
- )
- {
- }
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly ProductPriceRepositoryInterface $productPriceRepository,
+ private readonly ProductRepositoryInterface $productRepository,
+ private readonly EventOccurrenceRepositoryInterface $occurrenceRepository,
+ private readonly OccurrencePurchaseEligibilityService $occurrenceEligibilityService,
+ ) {}
/**
* @throws ResourceConflictException
@@ -28,6 +36,41 @@ 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 = new CreateWaitlistEntryDTO(
+ event_id: $dto->event_id,
+ product_price_id: $dto->product_price_id,
+ email: $dto->email,
+ first_name: $dto->first_name,
+ last_name: $dto->last_name,
+ locale: $dto->locale,
+ event_occurrence_id: $occurrence->getId(),
+ );
+ }
+ }
+
$eventSettings = $this->eventSettingsRepository->findFirstWhere([
'event_id' => $dto->event_id,
]);
@@ -43,6 +86,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..9537f6c401 100644
--- a/backend/app/Services/Application/Handlers/Waitlist/DTO/CreateWaitlistEntryDTO.php
+++ b/backend/app/Services/Application/Handlers/Waitlist/DTO/CreateWaitlistEntryDTO.php
@@ -7,13 +7,12 @@
class CreateWaitlistEntryDTO extends BaseDataObject
{
public function __construct(
- public int $event_id,
- public int $product_price_id,
- public string $email,
- public string $first_name,
+ public int $event_id,
+ public int $product_price_id,
+ public string $email,
+ public string $first_name,
public ?string $last_name = null,
- public string $locale = 'en',
- )
- {
- }
+ 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..b57e332d87 100644
--- a/backend/app/Services/Application/Handlers/Waitlist/DTO/OfferWaitlistEntryDTO.php
+++ b/backend/app/Services/Application/Handlers/Waitlist/DTO/OfferWaitlistEntryDTO.php
@@ -7,11 +7,10 @@
class OfferWaitlistEntryDTO extends BaseDataObject
{
public function __construct(
- public int $event_id,
+ public int $event_id,
public ?int $product_price_id = null,
public ?int $entry_id = null,
- public int $quantity = 1,
- )
- {
- }
+ public int $quantity = 1,
+ public ?int $event_occurrence_id = null,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Waitlist/DTO/WaitlistProductStatsDTO.php b/backend/app/Services/Application/Handlers/Waitlist/DTO/WaitlistProductStatsDTO.php
index 19bf586d85..333130a1e2 100644
--- a/backend/app/Services/Application/Handlers/Waitlist/DTO/WaitlistProductStatsDTO.php
+++ b/backend/app/Services/Application/Handlers/Waitlist/DTO/WaitlistProductStatsDTO.php
@@ -12,7 +12,5 @@ public function __construct(
public int $waiting,
public int $offered,
public ?int $available,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Waitlist/DTO/WaitlistStatsDTO.php b/backend/app/Services/Application/Handlers/Waitlist/DTO/WaitlistStatsDTO.php
index 4a803063bd..fed97caf34 100644
--- a/backend/app/Services/Application/Handlers/Waitlist/DTO/WaitlistStatsDTO.php
+++ b/backend/app/Services/Application/Handlers/Waitlist/DTO/WaitlistStatsDTO.php
@@ -15,7 +15,5 @@ public function __construct(
public int $expired,
/** @var WaitlistProductStatsDTO[] */
public array $products = [],
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Waitlist/GetWaitlistEntriesHandler.php b/backend/app/Services/Application/Handlers/Waitlist/GetWaitlistEntriesHandler.php
index bd80bb2a9c..827de67e4e 100644
--- a/backend/app/Services/Application/Handlers/Waitlist/GetWaitlistEntriesHandler.php
+++ b/backend/app/Services/Application/Handlers/Waitlist/GetWaitlistEntriesHandler.php
@@ -10,9 +10,7 @@ class GetWaitlistEntriesHandler
{
public function __construct(
private readonly WaitlistEntryRepositoryInterface $waitlistEntryRepository,
- )
- {
- }
+ ) {}
public function handle(int $eventId, QueryParamsDTO $queryParams): LengthAwarePaginator
{
diff --git a/backend/app/Services/Application/Handlers/Waitlist/GetWaitlistStatsHandler.php b/backend/app/Services/Application/Handlers/Waitlist/GetWaitlistStatsHandler.php
index 69e56fab92..a002d5c631 100644
--- a/backend/app/Services/Application/Handlers/Waitlist/GetWaitlistStatsHandler.php
+++ b/backend/app/Services/Application/Handlers/Waitlist/GetWaitlistStatsHandler.php
@@ -11,18 +11,20 @@
class GetWaitlistStatsHandler
{
public function __construct(
- private readonly WaitlistEntryRepositoryInterface $waitlistEntryRepository,
+ private readonly WaitlistEntryRepositoryInterface $waitlistEntryRepository,
private readonly AvailableProductQuantitiesFetchService $availableQuantitiesService,
- )
- {
- }
+ ) {}
- 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);
@@ -56,6 +58,7 @@ private function getAvailableCountForPrice(object $quantities, int $priceId): in
if ($available === Constants::INFINITE) {
return Constants::INFINITE;
}
+
return $available;
}
}
diff --git a/backend/app/Services/Application/Handlers/Waitlist/OfferWaitlistEntryHandler.php b/backend/app/Services/Application/Handlers/Waitlist/OfferWaitlistEntryHandler.php
index 1a6ad7bf2b..b7d83a476d 100644
--- a/backend/app/Services/Application/Handlers/Waitlist/OfferWaitlistEntryHandler.php
+++ b/backend/app/Services/Application/Handlers/Waitlist/OfferWaitlistEntryHandler.php
@@ -13,12 +13,10 @@
class OfferWaitlistEntryHandler
{
public function __construct(
- private readonly ProcessWaitlistService $processWaitlistService,
+ private readonly ProcessWaitlistService $processWaitlistService,
private readonly EventSettingsRepositoryInterface $eventSettingsRepository,
- private readonly EventRepositoryInterface $eventRepository,
- )
- {
- }
+ private readonly EventRepositoryInterface $eventRepository,
+ ) {}
public function handle(OfferWaitlistEntryDTO $dto): Collection
{
@@ -44,6 +42,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/Application/Handlers/Webhook/CreateWebhookHandler.php b/backend/app/Services/Application/Handlers/Webhook/CreateWebhookHandler.php
index a8f283a164..ef206a03e6 100644
--- a/backend/app/Services/Application/Handlers/Webhook/CreateWebhookHandler.php
+++ b/backend/app/Services/Application/Handlers/Webhook/CreateWebhookHandler.php
@@ -12,22 +12,20 @@ class CreateWebhookHandler
{
public function __construct(
private readonly CreateWebhookService $createWebhookService,
- private readonly DatabaseManager $databaseManager,
- )
- {
- }
+ private readonly DatabaseManager $databaseManager,
+ ) {}
/**
* @throws Throwable
*/
public function handle(CreateWebhookDTO $upsertWebhookDTO): mixed
{
- return $this->databaseManager->transaction(fn() => $this->createWebhook($upsertWebhookDTO));
+ return $this->databaseManager->transaction(fn () => $this->createWebhook($upsertWebhookDTO));
}
private function createWebhook(CreateWebhookDTO $upsertWebhookDTO): WebhookDomainObject
{
- $webhookDomainObject = (new WebhookDomainObject())
+ $webhookDomainObject = (new WebhookDomainObject)
->setUrl($upsertWebhookDTO->url)
->setEventTypes($upsertWebhookDTO->eventTypes)
->setEventId($upsertWebhookDTO->eventId)
diff --git a/backend/app/Services/Application/Handlers/Webhook/DTO/CreateWebhookDTO.php b/backend/app/Services/Application/Handlers/Webhook/DTO/CreateWebhookDTO.php
index 1ee6d9e216..fb649224bb 100644
--- a/backend/app/Services/Application/Handlers/Webhook/DTO/CreateWebhookDTO.php
+++ b/backend/app/Services/Application/Handlers/Webhook/DTO/CreateWebhookDTO.php
@@ -8,14 +8,12 @@
class CreateWebhookDTO extends BaseDTO
{
public function __construct(
- public string $url,
- public array $eventTypes,
- public int $userId,
- public int $accountId,
+ public string $url,
+ public array $eventTypes,
+ public int $userId,
+ public int $accountId,
public WebhookStatus $status,
- public ?int $eventId = null,
- public ?int $organizerId = null,
- )
- {
- }
+ public ?int $eventId = null,
+ public ?int $organizerId = null,
+ ) {}
}
diff --git a/backend/app/Services/Application/Handlers/Webhook/DTO/EditWebhookDTO.php b/backend/app/Services/Application/Handlers/Webhook/DTO/EditWebhookDTO.php
index 5c23e15615..3de89cf302 100644
--- a/backend/app/Services/Application/Handlers/Webhook/DTO/EditWebhookDTO.php
+++ b/backend/app/Services/Application/Handlers/Webhook/DTO/EditWebhookDTO.php
@@ -7,16 +7,15 @@
class EditWebhookDTO extends CreateWebhookDTO
{
public function __construct(
- public int $webhookId,
- string $url,
- array $eventTypes,
- int $userId,
- int $accountId,
+ public int $webhookId,
+ string $url,
+ array $eventTypes,
+ int $userId,
+ int $accountId,
WebhookStatus $status,
- ?int $eventId = null,
- ?int $organizerId = null,
- )
- {
+ ?int $eventId = null,
+ ?int $organizerId = null,
+ ) {
parent::__construct(
url: $url,
eventTypes: $eventTypes,
diff --git a/backend/app/Services/Application/Handlers/Webhook/DeleteWebhookHandler.php b/backend/app/Services/Application/Handlers/Webhook/DeleteWebhookHandler.php
index c65da1c674..80ed8248b3 100644
--- a/backend/app/Services/Application/Handlers/Webhook/DeleteWebhookHandler.php
+++ b/backend/app/Services/Application/Handlers/Webhook/DeleteWebhookHandler.php
@@ -10,12 +10,10 @@
class DeleteWebhookHandler
{
public function __construct(
- private readonly WebhookRepositoryInterface $webhookRepository,
+ private readonly WebhookRepositoryInterface $webhookRepository,
private readonly WebhookLogRepositoryInterface $webhookLogRepository,
- private readonly DatabaseManager $databaseManager,
- )
- {
- }
+ private readonly DatabaseManager $databaseManager,
+ ) {}
public function handle(int $webhookId, int $accountId, ?int $eventId = null, ?int $organizerId = null): void
{
@@ -30,7 +28,7 @@ public function handle(int $webhookId, int $accountId, ?int $eventId = null, ?in
$webhook = $this->webhookRepository->findFirstWhere($where);
- if (!$webhook) {
+ if (! $webhook) {
throw new ResourceNotFoundException(__(
key: 'Webhook not found for ID: :webhookId',
replace: [
diff --git a/backend/app/Services/Application/Handlers/Webhook/EditWebhookHandler.php b/backend/app/Services/Application/Handlers/Webhook/EditWebhookHandler.php
index 18fb57eca8..6392fa030f 100644
--- a/backend/app/Services/Application/Handlers/Webhook/EditWebhookHandler.php
+++ b/backend/app/Services/Application/Handlers/Webhook/EditWebhookHandler.php
@@ -12,10 +12,8 @@ class EditWebhookHandler
{
public function __construct(
private readonly WebhookRepositoryInterface $webhookRepository,
- private readonly DatabaseManager $databaseManager,
- )
- {
- }
+ private readonly DatabaseManager $databaseManager,
+ ) {}
public function handle(EditWebhookDTO $dto): WebhookDomainObject
{
@@ -31,7 +29,7 @@ public function handle(EditWebhookDTO $dto): WebhookDomainObject
/** @var WebhookDomainObject $webhook */
$webhook = $this->webhookRepository->findFirstWhere($where);
- if (!$webhook) {
+ if (! $webhook) {
throw new ResourceNotFoundException(__(
key: 'Webhook not found for ID: :webhookId',
replace: [
diff --git a/backend/app/Services/Application/Handlers/Webhook/GetWebhookHandler.php b/backend/app/Services/Application/Handlers/Webhook/GetWebhookHandler.php
index b2bb2ebc43..b1919cac79 100644
--- a/backend/app/Services/Application/Handlers/Webhook/GetWebhookHandler.php
+++ b/backend/app/Services/Application/Handlers/Webhook/GetWebhookHandler.php
@@ -9,9 +9,7 @@ class GetWebhookHandler
{
public function __construct(
private readonly WebhookRepositoryInterface $webhookRepository,
- )
- {
- }
+ ) {}
public function handle(int $webhookId, int $accountId, ?int $eventId = null, ?int $organizerId = null): WebhookDomainObject
{
diff --git a/backend/app/Services/Application/Handlers/Webhook/GetWebhookLogsHandler.php b/backend/app/Services/Application/Handlers/Webhook/GetWebhookLogsHandler.php
index 8d0737f96b..579bfddf5d 100644
--- a/backend/app/Services/Application/Handlers/Webhook/GetWebhookLogsHandler.php
+++ b/backend/app/Services/Application/Handlers/Webhook/GetWebhookLogsHandler.php
@@ -11,10 +11,8 @@ class GetWebhookLogsHandler
{
public function __construct(
private readonly WebhookLogRepositoryInterface $webhookLogRepository,
- private readonly WebhookRepositoryInterface $webhookRepository,
- )
- {
- }
+ private readonly WebhookRepositoryInterface $webhookRepository,
+ ) {}
public function handle(int $webhookId, int $accountId, ?int $eventId = null, ?int $organizerId = null): LengthAwarePaginator
{
@@ -30,7 +28,7 @@ public function handle(int $webhookId, int $accountId, ?int $eventId = null, ?in
where: $where
);
- if (!$webhook) {
+ if (! $webhook) {
throw new ResourceNotFoundException(__('Webhook not found'));
}
diff --git a/backend/app/Services/Application/Handlers/Webhook/GetWebhooksHandler.php b/backend/app/Services/Application/Handlers/Webhook/GetWebhooksHandler.php
index c68b99ffad..f8cf5b399f 100644
--- a/backend/app/Services/Application/Handlers/Webhook/GetWebhooksHandler.php
+++ b/backend/app/Services/Application/Handlers/Webhook/GetWebhooksHandler.php
@@ -10,9 +10,7 @@ class GetWebhooksHandler
{
public function __construct(
private readonly WebhookRepositoryInterface $webhookRepository,
- )
- {
- }
+ ) {}
public function handler(int $accountId, ?int $eventId = null, ?int $organizerId = null): Collection
{
diff --git a/backend/app/Services/Application/Locale/LocaleService.php b/backend/app/Services/Application/Locale/LocaleService.php
index 674c8e1981..33aa1ba09e 100644
--- a/backend/app/Services/Application/Locale/LocaleService.php
+++ b/backend/app/Services/Application/Locale/LocaleService.php
@@ -9,9 +9,7 @@ class LocaleService
{
public function __construct(
private readonly Repository $config,
- )
- {
- }
+ ) {}
public function getLocaleOrDefault(?string $locale): string
{
diff --git a/backend/app/Services/Domain/Account/AccountUserAssociationService.php b/backend/app/Services/Domain/Account/AccountUserAssociationService.php
index eac2fdf943..04faa08453 100644
--- a/backend/app/Services/Domain/Account/AccountUserAssociationService.php
+++ b/backend/app/Services/Domain/Account/AccountUserAssociationService.php
@@ -14,19 +14,16 @@
{
public function __construct(
private AccountUserRepositoryInterface $accountUserRepository,
- )
- {
- }
+ ) {}
public function associate(
- UserDomainObject $user,
+ UserDomainObject $user,
AccountDomainObject $account,
- Role $role,
- ?UserStatus $status = null,
- ?int $invitedByUserId = null,
- bool $isAccountOwner = false,
- ): AccountUserDomainObject
- {
+ Role $role,
+ ?UserStatus $status = null,
+ ?int $invitedByUserId = null,
+ bool $isAccountOwner = false,
+ ): AccountUserDomainObject {
if ($role === Role::SUPERADMIN) {
throw new UnauthorizedException(__('Cannot associate a user with SUPERADMIN role to an account'));
}
diff --git a/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php b/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php
index 7dceaaef96..58565e6f67 100644
--- a/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php
+++ b/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php
@@ -13,26 +13,24 @@
class SendAttendeeTicketService
{
public function __construct(
- private readonly Mailer $mailer,
+ private readonly Mailer $mailer,
private readonly MailBuilderService $mailBuilderService,
- )
- {
- }
+ ) {}
public function send(
- OrderDomainObject $order,
- AttendeeDomainObject $attendee,
- EventDomainObject $event,
+ OrderDomainObject $order,
+ AttendeeDomainObject $attendee,
+ EventDomainObject $event,
EventSettingDomainObject $eventSettings,
- OrganizerDomainObject $organizer,
- ): void
- {
+ OrganizerDomainObject $organizer,
+ ): void {
$mail = $this->mailBuilderService->buildAttendeeTicketMail(
$attendee,
$order,
$event,
$eventSettings,
- $organizer
+ $organizer,
+ $attendee->getEventOccurrence(),
);
$this->mailer
diff --git a/backend/app/Services/Domain/Auth/AuthUserService.php b/backend/app/Services/Domain/Auth/AuthUserService.php
index b6daa29ccd..74af3a5d36 100644
--- a/backend/app/Services/Domain/Auth/AuthUserService.php
+++ b/backend/app/Services/Domain/Auth/AuthUserService.php
@@ -18,15 +18,13 @@ public function __construct(
/**
* @var AuthManager
*/
- private AuthManager $authManager,
+ private AuthManager $authManager,
private AccountUserRepositoryInterface $accountUserRepository,
- )
- {
- }
+ ) {}
public function getAuthenticatedAccountId(): ?int
{
- if (!$this->authManager->check()) {
+ if (! $this->authManager->check()) {
return null;
}
@@ -42,7 +40,7 @@ public function getAuthenticatedAccountId(): ?int
public function getAuthenticatedUserRole(): ?Role
{
- if (!$this->authManager->check()) {
+ if (! $this->authManager->check()) {
return null;
}
diff --git a/backend/app/Services/Domain/Auth/DTO/LoginResponse.php b/backend/app/Services/Domain/Auth/DTO/LoginResponse.php
index 15aecb4d73..058e1deedc 100644
--- a/backend/app/Services/Domain/Auth/DTO/LoginResponse.php
+++ b/backend/app/Services/Domain/Auth/DTO/LoginResponse.php
@@ -9,11 +9,9 @@
class LoginResponse extends BaseDTO
{
public function __construct(
- public Collection $accounts,
- public readonly ?string $token,
+ public Collection $accounts,
+ public readonly ?string $token,
public readonly UserDomainObject $user,
- public readonly ?int $accountId = null,
- )
- {
- }
+ public readonly ?int $accountId = null,
+ ) {}
}
diff --git a/backend/app/Services/Domain/Auth/LoginService.php b/backend/app/Services/Domain/Auth/LoginService.php
index 0fb407acd1..66c650264f 100644
--- a/backend/app/Services/Domain/Auth/LoginService.php
+++ b/backend/app/Services/Domain/Auth/LoginService.php
@@ -18,12 +18,10 @@
readonly class LoginService
{
public function __construct(
- private JWTAuth $jwtAuth,
- private LoggerInterface $logger,
+ private JWTAuth $jwtAuth,
+ private LoggerInterface $logger,
private AccountUserRepositoryInterface $accountUserRepository,
- )
- {
- }
+ ) {}
/**
* @throws UnauthorizedException
@@ -36,7 +34,7 @@ public function authenticate(string $email, string $password, ?int $requestedAcc
'password' => $password,
]);
- if (!$token) {
+ if (! $token) {
throw new UnauthorizedException(__('Username or Password are incorrect'));
}
@@ -49,7 +47,7 @@ public function authenticate(string $email, string $password, ?int $requestedAcc
'user_id' => $user->getId(),
]);
- $accounts = $userAccounts->map(fn($accountUser) => $accountUser->getAccount());
+ $accounts = $userAccounts->map(fn ($accountUser) => $accountUser->getAccount());
$accountId = $this->getAccountId($accounts, $requestedAccountId);
@@ -80,7 +78,7 @@ private function getAccountId(Collection $accounts, ?int $requestedAccountId): ?
}
if ($requestedAccountId) {
- $verifiedAccount = $accounts->firstWhere(fn(AccountDomainObject $account) => $account->getId() === $requestedAccountId);
+ $verifiedAccount = $accounts->firstWhere(fn (AccountDomainObject $account) => $account->getId() === $requestedAccountId);
if ($verifiedAccount === null) {
throw new UnauthorizedException(__('Account not found'));
@@ -94,12 +92,11 @@ private function getAccountId(Collection $accounts, ?int $requestedAccountId): ?
private function getToken(
Collection $accounts,
- string $email,
- string $password,
- ?int $requestedAccountId,
- ?Role $userRole,
- ): ?string
- {
+ string $email,
+ string $password,
+ ?int $requestedAccountId,
+ ?Role $userRole,
+ ): ?string {
$accountId = $this->getAccountId($accounts, $requestedAccountId);
// if there's no account, we can't generate a token. The user will be prompted to select an account
@@ -118,7 +115,7 @@ private function getToken(
'password' => $password,
]);
- if (!$token) {
+ if (! $token) {
throw new UnauthorizedException(__('Username or Password are incorrect'));
}
@@ -129,7 +126,7 @@ private function validateUserStatus(int $accountId, Collection $userAccounts): v
{
/** @var AccountUserDomainObject $currentAccount */
$currentAccount = $userAccounts
- ->first(fn(AccountUserDomainObject $userAccount) => $userAccount->getAccountId() === $accountId);
+ ->first(fn (AccountUserDomainObject $userAccount) => $userAccount->getAccountId() === $accountId);
if ($currentAccount->getStatus() !== UserStatus::ACTIVE->name) {
$this->logger->info(__('Attempt to log in to a non-active account'), $currentAccount->toArray());
@@ -146,7 +143,7 @@ private function getUserRole(?int $accountId, Collection $userAccounts): ?Role
/** @var AccountUserDomainObject $currentAccount */
$currentAccount = $userAccounts
- ->first(fn(AccountUserDomainObject $userAccount) => $userAccount->getAccountId() === $accountId);
+ ->first(fn (AccountUserDomainObject $userAccount) => $userAccount->getAccountId() === $accountId);
return Role::from($currentAccount?->getRole());
}
diff --git a/backend/app/Services/Domain/Auth/ResetPasswordTokenValidateService.php b/backend/app/Services/Domain/Auth/ResetPasswordTokenValidateService.php
index 294f97037e..9d037b306d 100644
--- a/backend/app/Services/Domain/Auth/ResetPasswordTokenValidateService.php
+++ b/backend/app/Services/Domain/Auth/ResetPasswordTokenValidateService.php
@@ -11,13 +11,13 @@
class ResetPasswordTokenValidateService
{
private PasswordResetTokenRepositoryInterface $passwordResetTokenRepository;
+
private Repository $config;
public function __construct(
PasswordResetTokenRepositoryInterface $passwordResetTokenRepository,
- Repository $config
- )
- {
+ Repository $config
+ ) {
$this->passwordResetTokenRepository = $passwordResetTokenRepository;
$this->config = $config;
}
@@ -28,7 +28,7 @@ public function __construct(
public function validateAndFetchToken(string $token): PasswordResetTokenDomainObject
{
$resetToken = $this->passwordResetTokenRepository->findFirstWhere(['token' => $token]);
- if (!$resetToken) {
+ if (! $resetToken) {
throw new InvalidPasswordResetTokenException(__('Invalid reset token'));
}
diff --git a/backend/app/Services/Domain/CapacityAssignment/CapacityAssignmentProductAssociationService.php b/backend/app/Services/Domain/CapacityAssignment/CapacityAssignmentProductAssociationService.php
index e7f1b68de7..abd849bba7 100644
--- a/backend/app/Services/Domain/CapacityAssignment/CapacityAssignmentProductAssociationService.php
+++ b/backend/app/Services/Domain/CapacityAssignment/CapacityAssignmentProductAssociationService.php
@@ -9,17 +9,14 @@ class CapacityAssignmentProductAssociationService
{
public function __construct(
private readonly ProductRepositoryInterface $productRepository,
- public readonly DatabaseManager $databaseManager,
- )
- {
- }
+ public readonly DatabaseManager $databaseManager,
+ ) {}
public function addCapacityToProducts(
- int $capacityAssignmentId,
+ int $capacityAssignmentId,
?array $productIds,
- bool $removePreviousAssignments = true
- ): void
- {
+ bool $removePreviousAssignments = true
+ ): void {
$this->databaseManager->transaction(function () use ($capacityAssignmentId, $productIds, $removePreviousAssignments) {
$this->associateProductsWithCapacityAssignment(
capacityAssignmentId: $capacityAssignmentId,
@@ -30,11 +27,10 @@ public function addCapacityToProducts(
}
private function associateProductsWithCapacityAssignment(
- int $capacityAssignmentId,
+ int $capacityAssignmentId,
?array $productIds,
- bool $removePreviousAssignments = true
- ): void
- {
+ bool $removePreviousAssignments = true
+ ): void {
if (empty($productIds)) {
return;
}
diff --git a/backend/app/Services/Domain/CapacityAssignment/CreateCapacityAssignmentService.php b/backend/app/Services/Domain/CapacityAssignment/CreateCapacityAssignmentService.php
index 4c7e68c6e6..e1c0b5844f 100644
--- a/backend/app/Services/Domain/CapacityAssignment/CreateCapacityAssignmentService.php
+++ b/backend/app/Services/Domain/CapacityAssignment/CreateCapacityAssignmentService.php
@@ -15,23 +15,20 @@
class CreateCapacityAssignmentService
{
public function __construct(
- private readonly DatabaseManager $databaseManager,
- private readonly CapacityAssignmentRepositoryInterface $capacityAssignmentRepository,
- private readonly EventProductValidationService $eventProductValidationService,
+ private readonly DatabaseManager $databaseManager,
+ private readonly CapacityAssignmentRepositoryInterface $capacityAssignmentRepository,
+ private readonly EventProductValidationService $eventProductValidationService,
private readonly CapacityAssignmentProductAssociationService $capacityAssignmentProductAssociationService,
- private readonly ProductPriceRepositoryInterface $productPriceRepository,
- )
- {
- }
+ private readonly ProductPriceRepositoryInterface $productPriceRepository,
+ ) {}
/**
* @throws UnrecognizedProductIdException
*/
public function createCapacityAssignment(
CapacityAssignmentDomainObject $capacityAssignment,
- array $productIds,
- ): CapacityAssignmentDomainObject
- {
+ array $productIds,
+ ): CapacityAssignmentDomainObject {
$this->eventProductValidationService->validateProductIds($productIds, $capacityAssignment->getEventId());
return $this->persistAssignmentAndAssociateProducts($capacityAssignment, $productIds);
@@ -39,9 +36,8 @@ public function createCapacityAssignment(
private function persistAssignmentAndAssociateProducts(
CapacityAssignmentDomainObject $capacityAssignment,
- ?array $productIds,
- ): CapacityAssignmentDomainObject
- {
+ ?array $productIds,
+ ): CapacityAssignmentDomainObject {
return $this->databaseManager->transaction(function () use ($capacityAssignment, $productIds) {
/** @var CapacityAssignmentDomainObject $capacityAssignment */
$capacityAssignment = $this->capacityAssignmentRepository->create([
@@ -69,6 +65,6 @@ private function getUsedCapacity(array $productIds): int
{
$productPrices = $this->productPriceRepository->findWhereIn('product_id', $productIds);
- return $productPrices->sum(fn(ProductPriceDomainObject $productPrice) => $productPrice->getQuantitySold());
+ return $productPrices->sum(fn (ProductPriceDomainObject $productPrice) => $productPrice->getQuantitySold());
}
}
diff --git a/backend/app/Services/Domain/CapacityAssignment/Exception/ProductsDoNotBelongToEventException.php b/backend/app/Services/Domain/CapacityAssignment/Exception/ProductsDoNotBelongToEventException.php
index 1505af45b5..174b5f0751 100644
--- a/backend/app/Services/Domain/CapacityAssignment/Exception/ProductsDoNotBelongToEventException.php
+++ b/backend/app/Services/Domain/CapacityAssignment/Exception/ProductsDoNotBelongToEventException.php
@@ -4,7 +4,4 @@
use Exception;
-class ProductsDoNotBelongToEventException extends Exception
-{
-
-}
+class ProductsDoNotBelongToEventException extends Exception {}
diff --git a/backend/app/Services/Domain/CapacityAssignment/UpdateCapacityAssignmentService.php b/backend/app/Services/Domain/CapacityAssignment/UpdateCapacityAssignmentService.php
index b355cd00ac..1e4341e220 100644
--- a/backend/app/Services/Domain/CapacityAssignment/UpdateCapacityAssignmentService.php
+++ b/backend/app/Services/Domain/CapacityAssignment/UpdateCapacityAssignmentService.php
@@ -13,22 +13,19 @@
class UpdateCapacityAssignmentService
{
public function __construct(
- private readonly DatabaseManager $databaseManager,
- private readonly CapacityAssignmentRepositoryInterface $capacityAssignmentRepository,
- private readonly EventProductValidationService $eventProductValidationService,
+ private readonly DatabaseManager $databaseManager,
+ private readonly CapacityAssignmentRepositoryInterface $capacityAssignmentRepository,
+ private readonly EventProductValidationService $eventProductValidationService,
private readonly CapacityAssignmentProductAssociationService $capacityAssignmentProductAssociationService,
- )
- {
- }
+ ) {}
/**
* @throws UnrecognizedProductIdException
*/
public function updateCapacityAssignment(
CapacityAssignmentDomainObject $capacityAssignment,
- ?array $productIds = null,
- ): CapacityAssignmentDomainObject
- {
+ ?array $productIds = null,
+ ): CapacityAssignmentDomainObject {
if ($productIds !== null) {
$this->eventProductValidationService->validateProductIds($productIds, $capacityAssignment->getEventId());
}
@@ -38,9 +35,8 @@ public function updateCapacityAssignment(
private function updateAssignmentAndAssociateProducts(
CapacityAssignmentDomainObject $capacityAssignment,
- ?array $productIds
- ): CapacityAssignmentDomainObject
- {
+ ?array $productIds
+ ): CapacityAssignmentDomainObject {
return $this->databaseManager->transaction(function () use ($capacityAssignment, $productIds) {
/** @var CapacityAssignmentDomainObject $capacityAssignment */
$this->capacityAssignmentRepository->updateWhere(
diff --git a/backend/app/Services/Domain/CheckInList/CheckInListActivityValidator.php b/backend/app/Services/Domain/CheckInList/CheckInListActivityValidator.php
new file mode 100644
index 0000000000..2d19d0c8b5
--- /dev/null
+++ b/backend/app/Services/Domain/CheckInList/CheckInListActivityValidator.php
@@ -0,0 +1,26 @@
+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'));
+ }
+ }
+}
diff --git a/backend/app/Services/Domain/CheckInList/CheckInListDataService.php b/backend/app/Services/Domain/CheckInList/CheckInListDataService.php
index 840a3bd7c1..07b4a42a80 100644
--- a/backend/app/Services/Domain/CheckInList/CheckInListDataService.php
+++ b/backend/app/Services/Domain/CheckInList/CheckInListDataService.php
@@ -17,34 +17,49 @@ class CheckInListDataService
{
public function __construct(
private readonly CheckInListRepositoryInterface $checkInListRepository,
- private readonly AttendeeRepositoryInterface $attendeeRepository,
- )
- {
- }
+ private readonly AttendeeRepositoryInterface $attendeeRepository,
+ ) {}
/**
* @throws CannotCheckInException
*/
public function verifyAttendeeBelongsToCheckInList(
CheckInListDomainObject $checkInList,
- AttendeeDomainObject $attendee,
- ): void
- {
- $allowedProductIds = $checkInList->getProducts()->map(fn($product) => $product->getId())->toArray() ?? [];
+ AttendeeDomainObject $attendee,
+ ): void {
+ $allowedProductIds = $checkInList->getProducts()?->map(fn ($product) => $product->getId())->toArray() ?? [];
- if (!in_array($attendee->getProductId(), $allowedProductIds, true)) {
+ 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(),
])
);
}
+
+ 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(),
+ ])
+ );
+ }
}
/**
* @return Collection
- * @throws Exception
*
+ * @throws Exception
* @throws CannotCheckInException
*/
public function getAttendees(Collection $attendeePublicIds): Collection
@@ -59,8 +74,8 @@ public function getAttendees(Collection $attendeePublicIds): Collection
if (count($attendees) !== count($attendeePublicIds)) {
throw new CannotCheckInException(__('Invalid attendee code detected: :attendees ', [
'attendees' => implode(', ', array_diff(
- $attendeePublicIds,
- $attendees->pluck(AttendeeDomainObjectAbstract::PUBLIC_ID)->toArray())
+ $attendeePublicIds,
+ $attendees->pluck(AttendeeDomainObjectAbstract::PUBLIC_ID)->toArray())
),
]));
}
diff --git a/backend/app/Services/Domain/CheckInList/CheckInListProductAssociationService.php b/backend/app/Services/Domain/CheckInList/CheckInListProductAssociationService.php
index 2153c44620..ba9bd6a71d 100644
--- a/backend/app/Services/Domain/CheckInList/CheckInListProductAssociationService.php
+++ b/backend/app/Services/Domain/CheckInList/CheckInListProductAssociationService.php
@@ -9,17 +9,14 @@ class CheckInListProductAssociationService
{
public function __construct(
private readonly ProductRepositoryInterface $productRepository,
- public readonly DatabaseManager $databaseManager,
- )
- {
- }
+ public readonly DatabaseManager $databaseManager,
+ ) {}
public function addCheckInListToProducts(
- int $checkInListId,
+ int $checkInListId,
?array $productIds,
- bool $removePreviousAssignments = true
- ): void
- {
+ bool $removePreviousAssignments = true
+ ): void {
$this->databaseManager->transaction(function () use ($checkInListId, $productIds, $removePreviousAssignments) {
$this->associateProductsWithCheckInList(
checkInListId: $checkInListId,
@@ -30,21 +27,20 @@ public function addCheckInListToProducts(
}
private function associateProductsWithCheckInList(
- int $checkInListId,
+ int $checkInListId,
?array $productIds,
- bool $removePreviousAssignments = true
- ): void
- {
- if (empty($productIds)) {
- return;
- }
-
+ bool $removePreviousAssignments = true
+ ): void {
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..570df33643 100644
--- a/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInService.php
+++ b/backend/app/Services/Domain/CheckInList/CreateAttendeeCheckInService.php
@@ -12,7 +12,6 @@
use HiEvents\DomainObjects\Generated\AttendeeCheckInDomainObjectAbstract;
use HiEvents\DomainObjects\Status\AttendeeStatus;
use HiEvents\Exceptions\CannotCheckInException;
-use HiEvents\Helper\DateHelper;
use HiEvents\Helper\IdHelper;
use HiEvents\Repository\Interfaces\AttendeeCheckInRepositoryInterface;
use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface;
@@ -21,6 +20,7 @@
use HiEvents\Services\Domain\CheckInList\DTO\CreateAttendeeCheckInsResponseDTO;
use HiEvents\Services\Domain\Order\MarkOrderAsPaidService;
use Illuminate\Database\ConnectionInterface;
+use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Collection;
use Throwable;
@@ -28,30 +28,26 @@ class CreateAttendeeCheckInService
{
public function __construct(
private readonly AttendeeCheckInRepositoryInterface $attendeeCheckInRepository,
- private readonly CheckInListDataService $checkInListDataService,
- private readonly EventSettingsRepositoryInterface $eventSettingsRepository,
- private readonly ConnectionInterface $db,
- private readonly MarkOrderAsPaidService $markOrderAsPaidService,
- )
- {
- }
+ private readonly CheckInListDataService $checkInListDataService,
+ private readonly EventSettingsRepositoryInterface $eventSettingsRepository,
+ private readonly ConnectionInterface $db,
+ private readonly MarkOrderAsPaidService $markOrderAsPaidService,
+ private readonly CheckInListActivityValidator $checkInListActivityValidator,
+ ) {}
/**
- * @param string $checkInListUuid
- * @param string $checkInUserIpAddress
- * @param Collection $attendeesAndActions
- * @return CreateAttendeeCheckInsResponseDTO
+ * @param Collection $attendeesAndActions
+ *
* @throws CannotCheckInException
* @throws Exception|Throwable
*/
public function checkInAttendees(
- string $checkInListUuid,
- string $checkInUserIpAddress,
+ string $checkInListUuid,
+ string $checkInUserIpAddress,
Collection $attendeesAndActions
- ): CreateAttendeeCheckInsResponseDTO
- {
+ ): CreateAttendeeCheckInsResponseDTO {
$checkInList = $this->checkInListDataService->getCheckInList($checkInListUuid);
- $this->validateCheckInListIsActive($checkInList);
+ $this->checkInListActivityValidator->assertActive($checkInList);
$attendees = $this->fetchAttendees($attendeesAndActions);
$eventSettings = $this->fetchEventSettings($checkInList->getEventId());
@@ -68,29 +64,17 @@ public function checkInAttendees(
}
/**
- * @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'));
- }
- }
-
- /**
- * @param Collection $attendeesAndActions
+ * @param Collection $attendeesAndActions
* @return Collection
+ *
* @throws CannotCheckInException
*/
private function fetchAttendees(Collection $attendeesAndActions): Collection
{
$publicIds = $attendeesAndActions->map(
- fn(AttendeeAndActionDTO $attendeeAndAction) => $attendeeAndAction->public_id
+ fn (AttendeeAndActionDTO $attendeeAndAction) => $attendeeAndAction->public_id
);
+
return $this->checkInListDataService->getAttendees($publicIds);
}
@@ -102,14 +86,13 @@ private function fetchEventSettings(int $eventId): EventSettingDomainObject
}
/**
- * @param Collection $attendees
- * @param CheckInListDomainObject $checkInList
- * @return Collection
+ * @param Collection $attendees
+ *
* @throws Exception
*/
private function fetchExistingCheckIns(Collection $attendees, CheckInListDomainObject $checkInList): Collection
{
- $attendeeIds = $attendees->map(fn(AttendeeDomainObject $attendee) => $attendee->getId())->toArray();
+ $attendeeIds = $attendees->map(fn (AttendeeDomainObject $attendee) => $attendee->getId())->toArray();
return $this->attendeeCheckInRepository->findWhereIn(
field: AttendeeCheckInDomainObjectAbstract::ATTENDEE_ID,
@@ -126,16 +109,15 @@ private function fetchExistingCheckIns(Collection $attendees, CheckInListDomainO
* @throws CannotCheckInException
*/
private function processAttendeeCheckIns(
- Collection $attendees,
- Collection $attendeesAndActions,
- CheckInListDomainObject $checkInList,
+ Collection $attendees,
+ Collection $attendeesAndActions,
+ CheckInListDomainObject $checkInList,
EventSettingDomainObject $eventSettings,
- Collection $existingCheckIns,
- string $checkInUserIpAddress
- ): CreateAttendeeCheckInsResponseDTO
- {
- $errors = new ErrorBagDTO();
- $checkIns = new Collection();
+ Collection $existingCheckIns,
+ string $checkInUserIpAddress
+ ): CreateAttendeeCheckInsResponseDTO {
+ $errors = new ErrorBagDTO;
+ $checkIns = new Collection;
foreach ($attendees as $attendee) {
$result = $this->processIndividualCheckIn(
@@ -166,18 +148,17 @@ private function processAttendeeCheckIns(
* @throws CannotCheckInException
*/
private function processIndividualCheckIn(
- AttendeeDomainObject $attendee,
- Collection $attendeesAndActions,
- CheckInListDomainObject $checkInList,
+ AttendeeDomainObject $attendee,
+ Collection $attendeesAndActions,
+ CheckInListDomainObject $checkInList,
EventSettingDomainObject $eventSettings,
- Collection $existingCheckIns,
- string $checkInUserIpAddress
- ): CheckInResultDTO
- {
+ Collection $existingCheckIns,
+ string $checkInUserIpAddress
+ ): CheckInResultDTO {
$this->checkInListDataService->verifyAttendeeBelongsToCheckInList($checkInList, $attendee);
$attendeeAction = $attendeesAndActions->first(
- fn(AttendeeAndActionDTO $action) => $action->public_id === $attendee->getPublicId()
+ fn (AttendeeAndActionDTO $action) => $action->public_id === $attendee->getPublicId()
);
$checkInAction = $attendeeAction->action;
@@ -194,33 +175,46 @@ private function processIndividualCheckIn(
return new CheckInResultDTO(error: $error);
}
- return $this->db->transaction(function () use ($attendee, $checkInList, $checkInAction, $checkInUserIpAddress) {
- $checkIn = $this->createCheckIn($attendee, $checkInList, $checkInUserIpAddress);
+ try {
+ return $this->db->transaction(function () use ($attendee, $checkInList, $checkInAction, $checkInUserIpAddress) {
+ $checkIn = $this->createCheckIn($attendee, $checkInList, $checkInUserIpAddress);
- if ($checkInAction->value === AttendeeCheckInActionType::CHECK_IN_AND_MARK_ORDER_AS_PAID->value) {
- $this->markOrderAsPaidService->markOrderAsPaid(
- orderId: $attendee->getOrderId(),
- eventId: $attendee->getEventId(),
- );
- }
+ if ($checkInAction->value === AttendeeCheckInActionType::CHECK_IN_AND_MARK_ORDER_AS_PAID->value
+ && $attendee->getStatus() === AttendeeStatus::AWAITING_PAYMENT->name
+ ) {
+ $this->markOrderAsPaidService->markOrderAsPaid(
+ orderId: $attendee->getOrderId(),
+ eventId: $attendee->getEventId(),
+ );
+ }
- return new CheckInResultDTO(checkIn: $checkIn);
- });
+ return new CheckInResultDTO(checkIn: $checkIn);
+ });
+ } catch (UniqueConstraintViolationException) {
+ return new CheckInResultDTO(
+ checkIn: $this->attendeeCheckInRepository->findFirstWhere([
+ AttendeeCheckInDomainObjectAbstract::ATTENDEE_ID => $attendee->getId(),
+ AttendeeCheckInDomainObjectAbstract::CHECK_IN_LIST_ID => $checkInList->getId(),
+ ]),
+ error: __('Attendee :attendee_name is already checked in', [
+ 'attendee_name' => $attendee->getFullName(),
+ ])
+ );
+ }
}
private function getExistingCheckIn(Collection $existingCheckIns, AttendeeDomainObject $attendee): ?object
{
return $existingCheckIns->first(
- fn($checkIn) => $checkIn->getAttendeeId() === $attendee->getId()
+ fn ($checkIn) => $checkIn->getAttendeeId() === $attendee->getId()
);
}
private function validateAttendeeStatus(
- AttendeeDomainObject $attendee,
+ AttendeeDomainObject $attendee,
AttendeeCheckInActionType $checkInAction,
- EventSettingDomainObject $eventSettings
- ): ?string
- {
+ EventSettingDomainObject $eventSettings
+ ): ?string {
$allowAttendeesAwaitingPaymentToCheckIn = $eventSettings->getAllowOrdersAwaitingOfflinePaymentToCheckIn();
if ($attendee->getStatus() === AttendeeStatus::CANCELLED->name) {
@@ -229,7 +223,7 @@ private function validateAttendeeStatus(
]);
}
- if (!$allowAttendeesAwaitingPaymentToCheckIn) {
+ if (! $allowAttendeesAwaitingPaymentToCheckIn) {
if ($checkInAction->value === AttendeeCheckInActionType::CHECK_IN->value
&& $attendee->getStatus() === AttendeeStatus::AWAITING_PAYMENT->name
) {
@@ -249,11 +243,10 @@ private function validateAttendeeStatus(
}
private function createCheckIn(
- AttendeeDomainObject $attendee,
+ AttendeeDomainObject $attendee,
CheckInListDomainObject $checkInList,
- string $checkInUserIpAddress
- ): AttendeeCheckInDomainObject
- {
+ string $checkInUserIpAddress
+ ): AttendeeCheckInDomainObject {
return $this->attendeeCheckInRepository->create([
AttendeeCheckInDomainObjectAbstract::ORDER_ID => $attendee->getOrderId(),
AttendeeCheckInDomainObjectAbstract::ATTENDEE_ID => $attendee->getId(),
@@ -262,6 +255,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..9fb58720de 100644
--- a/backend/app/Services/Domain/CheckInList/CreateCheckInListService.php
+++ b/backend/app/Services/Domain/CheckInList/CreateCheckInListService.php
@@ -15,15 +15,12 @@
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,
-
- )
- {
- }
+ private readonly DatabaseManager $databaseManager,
+ private readonly EventRepositoryInterface $eventRepository,
+ ) {}
/**
* @throws UnrecognizedProductIdException
@@ -38,6 +35,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 +43,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/DTO/CheckInResultDTO.php b/backend/app/Services/Domain/CheckInList/DTO/CheckInResultDTO.php
index 1eea6fc352..0032c9bc44 100644
--- a/backend/app/Services/Domain/CheckInList/DTO/CheckInResultDTO.php
+++ b/backend/app/Services/Domain/CheckInList/DTO/CheckInResultDTO.php
@@ -7,7 +7,5 @@ class CheckInResultDTO
public function __construct(
public readonly ?object $checkIn = null,
public readonly ?string $error = null,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Domain/CheckInList/DTO/CreateAttendeeCheckInsResponseDTO.php b/backend/app/Services/Domain/CheckInList/DTO/CreateAttendeeCheckInsResponseDTO.php
index cecfb8d793..7cfdb35e2e 100644
--- a/backend/app/Services/Domain/CheckInList/DTO/CreateAttendeeCheckInsResponseDTO.php
+++ b/backend/app/Services/Domain/CheckInList/DTO/CreateAttendeeCheckInsResponseDTO.php
@@ -9,9 +9,7 @@
class CreateAttendeeCheckInsResponseDTO extends BaseDTO
{
public function __construct(
- public Collection $attendeeCheckIns,
+ public Collection $attendeeCheckIns,
public ErrorBagDTO $errors,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Domain/CheckInList/DeleteAttendeeCheckInService.php b/backend/app/Services/Domain/CheckInList/DeleteAttendeeCheckInService.php
index 962595a484..2b82e731c3 100644
--- a/backend/app/Services/Domain/CheckInList/DeleteAttendeeCheckInService.php
+++ b/backend/app/Services/Domain/CheckInList/DeleteAttendeeCheckInService.php
@@ -13,10 +13,8 @@ class DeleteAttendeeCheckInService
{
public function __construct(
private readonly AttendeeCheckInRepositoryInterface $attendeeCheckInRepository,
- private readonly CheckInListDataService $checkInListDataService,
- )
- {
- }
+ private readonly CheckInListDataService $checkInListDataService,
+ ) {}
/**
* @throws CannotCheckInException
@@ -24,8 +22,7 @@ public function __construct(
public function deleteAttendeeCheckIn(
string $checkInListShortId,
string $checkInShortId,
- ): int
- {
+ ): int {
/** @var AttendeeCheckInDomainObject $checkIn */
$checkIn = $this->attendeeCheckInRepository
->loadRelation(new Relationship(AttendeeDomainObject::class, name: 'attendee'))
@@ -45,7 +42,7 @@ public function deleteAttendeeCheckIn(
if ($checkInList->getId() !== $checkIn->getCheckInListId()) {
// For now, let's allow this, as someone could delete the check-in list and be unable to delete the check-in
// It should be safe as to check someone out you need to know the check-in list and check in short id
- //throw new CannotCheckInException(__('Attendee does not belong to this check-in list'));
+ // throw new CannotCheckInException(__('Attendee does not belong to this check-in list'));
}
$this->attendeeCheckInRepository->deleteById($checkIn->getId());
diff --git a/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php b/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php
index 11a441deaf..ca39548bd1 100644
--- a/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php
+++ b/backend/app/Services/Domain/CheckInList/UpdateCheckInListService.php
@@ -14,14 +14,12 @@
class UpdateCheckInListService
{
public function __construct(
- private readonly DatabaseManager $databaseManager,
- private readonly EventProductValidationService $eventProductValidationService,
+ private readonly DatabaseManager $databaseManager,
+ private readonly EventProductValidationService $eventProductValidationService,
private readonly CheckInListProductAssociationService $checkInListProductAssociationService,
- private readonly CheckInListRepositoryInterface $checkInListRepository,
- private readonly EventRepositoryInterface $eventRepository,
- )
- {
- }
+ private readonly CheckInListRepositoryInterface $checkInListRepository,
+ private readonly EventRepositoryInterface $eventRepository,
+ ) {}
/**
* @throws UnrecognizedProductIdException
@@ -37,12 +35,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/CreateWebhookService.php b/backend/app/Services/Domain/CreateWebhookService.php
index 8d2701ad0f..88fd32bf55 100644
--- a/backend/app/Services/Domain/CreateWebhookService.php
+++ b/backend/app/Services/Domain/CreateWebhookService.php
@@ -3,7 +3,6 @@
namespace HiEvents\Services\Domain;
use HiEvents\DomainObjects\Generated\WebhookDomainObjectAbstract;
-use HiEvents\DomainObjects\Status\WebhookStatus;
use HiEvents\DomainObjects\WebhookDomainObject;
use HiEvents\Repository\Interfaces\WebhookRepositoryInterface;
use Illuminate\Support\Str;
@@ -13,7 +12,7 @@ class CreateWebhookService
{
public function __construct(
private readonly WebhookRepositoryInterface $webhookRepository,
- private readonly LoggerInterface $logger,
+ private readonly LoggerInterface $logger,
) {}
public function createWebhook(WebhookDomainObject $webhookDomainObject): WebhookDomainObject
diff --git a/backend/app/Services/Domain/Email/DTO/RenderedEmailTemplateDTO.php b/backend/app/Services/Domain/Email/DTO/RenderedEmailTemplateDTO.php
index 7b66600b91..be5a6c3290 100644
--- a/backend/app/Services/Domain/Email/DTO/RenderedEmailTemplateDTO.php
+++ b/backend/app/Services/Domain/Email/DTO/RenderedEmailTemplateDTO.php
@@ -10,7 +10,5 @@ public function __construct(
public readonly string $subject,
public readonly string $body,
public readonly ?array $cta = null,
- )
- {
- }
-}
\ No newline at end of file
+ ) {}
+}
diff --git a/backend/app/Services/Domain/Email/EmailContextHtmlEscaper.php b/backend/app/Services/Domain/Email/EmailContextHtmlEscaper.php
new file mode 100644
index 0000000000..022fcb8e9b
--- /dev/null
+++ b/backend/app/Services/Domain/Email/EmailContextHtmlEscaper.php
@@ -0,0 +1,37 @@
+escapeValues($context, '');
+ }
+
+ private function escapeValues(array $values, string $path): array
+ {
+ foreach ($values as $key => $value) {
+ $valuePath = $path === '' ? (string) $key : $path.'.'.$key;
+
+ if (is_array($value)) {
+ $values[$key] = $this->escapeValues($value, $valuePath);
+
+ continue;
+ }
+
+ if (is_string($value) && ! in_array($valuePath, self::PURIFIED_HTML_PATHS, true)) {
+ $values[$key] = e($value);
+ }
+ }
+
+ return $values;
+ }
+}
diff --git a/backend/app/Services/Domain/Email/EmailTemplateService.php b/backend/app/Services/Domain/Email/EmailTemplateService.php
index 9d864dc8c4..0d65b5fc51 100644
--- a/backend/app/Services/Domain/Email/EmailTemplateService.php
+++ b/backend/app/Services/Domain/Email/EmailTemplateService.php
@@ -14,9 +14,9 @@ class EmailTemplateService
public function __construct(
private readonly EmailTemplateRepositoryInterface $emailTemplateRepository,
private readonly LiquidTemplateRenderer $liquidRenderer,
- private readonly EmailTokenContextBuilder $tokenBuilder
- ) {
- }
+ private readonly EmailTokenContextBuilder $tokenBuilder,
+ private readonly EmailContextHtmlEscaper $contextHtmlEscaper,
+ ) {}
public function getTemplateByType(
EmailTemplateType $type,
@@ -35,16 +35,13 @@ public function getTemplateByType(
public function renderTemplate(EmailTemplateDomainObject $template, array $context): RenderedEmailTemplateDTO
{
$renderedSubject = $this->liquidRenderer->render($template->getSubject(), $context);
- $renderedBody = $this->liquidRenderer->render($template->getBody(), $context);
+ $renderedBody = $this->liquidRenderer->render($template->getBody(), $this->contextHtmlEscaper->escape($context));
$cta = null;
- // Handle CTA if present
if ($template->getCta()) {
$templateCta = $template->getCta();
if (isset($templateCta['label'], $templateCta['url_token'])) {
- // Replace the URL token with actual value from context
- // Handle dot notation (e.g., 'order.url' -> $context['order']['url'])
$ctaUrl = $this->getValueFromDotNotation($context, $templateCta['url_token']) ?? '#';
$cta = [
'label' => $templateCta['label'],
@@ -60,15 +57,12 @@ public function renderTemplate(EmailTemplateDomainObject $template, array $conte
);
}
- /**
- * Get default template content
- */
public function getDefaultTemplate(EmailTemplateType $type): array
{
$defaults = $this->getDefaultTemplates();
$ctaDefaults = $this->getDefaultCTAs();
- $template = $defaults[$type->value] ?? throw new ResourceNotFoundException('No default template for type ' . $type->value);
+ $template = $defaults[$type->value] ?? throw new ResourceNotFoundException('No default template for type '.$type->value);
$template['cta'] = $ctaDefaults[$type->value] ?? null;
@@ -79,9 +73,8 @@ public function previewTemplate(string $subject, string $body, EmailTemplateType
{
$context = $this->tokenBuilder->buildPreviewContext($type->value);
- $renderedBody = $this->liquidRenderer->render($body, $context);
+ $renderedBody = $this->liquidRenderer->render($body, $this->contextHtmlEscaper->escape($context));
- // Add CTA button if provided
if ($cta && isset($cta['label'])) {
$ctaUrl = $this->getValueFromDotNotation($context, $cta['url_token'] ?? '') ?? '#';
$ctaHtml = sprintf(
@@ -97,7 +90,7 @@ public function previewTemplate(string $subject, string $body, EmailTemplateType
return [
'subject' => $this->liquidRenderer->render($subject, $context),
'body' => $renderedBody,
- 'context' => $context, // Return context for debugging
+ 'context' => $context,
];
}
@@ -121,17 +114,13 @@ public function validateTemplate(string $subject, string $body): array
];
}
- /**
- * Get value from array using dot notation
- * e.g., 'order.url' will get $array['order']['url']
- */
private function getValueFromDotNotation(array $array, string $key)
{
$keys = explode('.', $key);
$value = $array;
foreach ($keys as $k) {
- if (!isset($value[$k])) {
+ if (! isset($value[$k])) {
return null;
}
$value = $value[$k];
@@ -151,6 +140,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 +184,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 b5e33ae202..a2fee4556e 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;
@@ -24,36 +27,41 @@ class EmailTokenContextBuilder
{
public function __construct(
private readonly LiquidTemplateRenderer $liquidTemplateRenderer,
- private readonly HtmlPurifierService $htmlPurifierService,
- )
- {
- }
+ private readonly HtmlPurifierService $htmlPurifierService,
+ private readonly EmailContextHtmlEscaper $contextHtmlEscaper,
+ ) {}
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);
$context = [
- // 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),
@@ -63,8 +71,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() ?? '',
@@ -72,18 +80,24 @@ public function buildOrderConfirmationContext(
'is_offline_payment' => $order->getPaymentProvider() === PaymentProviders::OFFLINE->value,
],
- // Organizer object
'organizer' => [
'name' => $organizer->getName() ?? '',
'email' => $organizer->getEmail() ?? '',
],
- // Settings object
'settings' => [
'support_email' => $eventSettings->getSupportEmail() ?? $organizer->getEmail() ?? '',
'offline_payment_instructions' => $eventSettings->getOfflinePaymentInstructions() ?? '',
'post_checkout_message' => $eventSettings->getPostCheckoutMessage() ?? '',
],
+
+ 'occurrence' => [
+ 'start_date' => $eventStartDate?->format('F j, Y') ?? '',
+ 'start_time' => $eventStartDate?->format('g:i A') ?? '',
+ 'end_date' => $eventEndDate?->format('F j, Y') ?? '',
+ 'end_time' => $eventEndDate?->format('g:i A') ?? '',
+ 'label' => $occurrence?->getLabel() ?? '',
+ ],
];
$context['settings']['offline_payment_instructions'] = $this->renderOfflinePaymentInstructions($context);
@@ -100,7 +114,7 @@ private function renderOfflinePaymentInstructions(array $context): string
}
try {
- $rendered = $this->liquidTemplateRenderer->render($instructions, $context);
+ $rendered = $this->liquidTemplateRenderer->render($instructions, $this->contextHtmlEscaper->escape($context));
return $this->htmlPurifierService->purify($rendered) ?? $instructions;
} catch (Throwable) {
@@ -109,24 +123,23 @@ private function renderOfflinePaymentInstructions(array $context): string
}
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() ?? '',
];
@@ -143,6 +156,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 = [
@@ -163,7 +235,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',
@@ -176,7 +248,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.',
@@ -189,6 +261,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',
@@ -201,6 +293,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 5cea766a80..4f0e4d55ce 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;
use HiEvents\Services\Domain\Order\OfflinePaymentInstructionsRenderService;
@@ -20,22 +24,23 @@ public function __construct(
private readonly EmailTemplateService $emailTemplateService,
private readonly EmailTokenContextBuilder $tokenContextBuilder,
private readonly OfflinePaymentInstructionsRenderService $offlinePaymentInstructionsRenderService,
- ) {
- }
+ ) {}
public function buildAttendeeTicketMail(
AttendeeDomainObject $attendee,
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(
@@ -45,6 +50,7 @@ public function buildAttendeeTicketMail(
eventSettings: $eventSettings,
organizer: $organizer,
renderedTemplate: $renderedTemplate,
+ occurrence: $occurrence,
);
}
@@ -53,16 +59,18 @@ 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,
);
- if (!$renderedTemplate) {
+ if (! $renderedTemplate) {
$this->offlinePaymentInstructionsRenderService->render($order, $event, $organizer, $eventSettings);
}
@@ -72,6 +80,7 @@ public function buildOrderSummaryMail(
organizer: $organizer,
eventSettings: $eventSettings,
invoice: $invoice,
+ occurrence: $occurrence,
renderedTemplate: $renderedTemplate,
);
}
@@ -81,7 +90,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,
@@ -90,7 +100,7 @@ private function renderAttendeeTicketTemplate(
organizerId: $organizer->getId()
);
- if (!$template) {
+ if (! $template) {
return null;
}
@@ -99,7 +109,8 @@ private function renderAttendeeTicketTemplate(
$order,
$event,
$organizer,
- $eventSettings
+ $eventSettings,
+ $occurrence,
);
return $this->emailTemplateService->renderTemplate($template, $context);
@@ -109,7 +120,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,
@@ -118,7 +130,7 @@ private function renderOrderSummaryTemplate(
organizerId: $organizer->getId()
);
- if (!$template) {
+ if (! $template) {
return null;
}
@@ -126,7 +138,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/CreateEventImageService.php b/backend/app/Services/Domain/Event/CreateEventImageService.php
index 2b448cea6d..7e23c9f17b 100644
--- a/backend/app/Services/Domain/Event/CreateEventImageService.php
+++ b/backend/app/Services/Domain/Event/CreateEventImageService.php
@@ -17,23 +17,20 @@
class CreateEventImageService
{
public function __construct(
- private readonly ImageUploadService $imageUploadService,
+ private readonly ImageUploadService $imageUploadService,
private readonly ImageRepositoryInterface $imageRepository,
- private readonly DatabaseManager $databaseManager,
- )
- {
- }
+ private readonly DatabaseManager $databaseManager,
+ ) {}
/**
* @throws Throwable
*/
public function createImage(
- int $eventId,
- int $accountId,
+ int $eventId,
+ int $accountId,
UploadedFile $image,
- ImageType $imageType,
- ): ImageDomainObject
- {
+ ImageType $imageType,
+ ): ImageDomainObject {
return $this->databaseManager->transaction(function () use ($accountId, $image, $eventId, $imageType) {
if ($imageType === ImageType::EVENT_COVER) {
$this->imageRepository->deleteWhere([
diff --git a/backend/app/Services/Domain/Event/CreateEventService.php b/backend/app/Services/Domain/Event/CreateEventService.php
index f44549e376..ceacf58b43 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;
@@ -13,6 +15,8 @@
use HiEvents\Helper\DateHelper;
use HiEvents\Helper\IdHelper;
use HiEvents\Helper\StringHelper;
+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;
@@ -27,34 +31,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);
@@ -67,10 +72,25 @@ public function createEvent(
$this->createEventStatistics($event);
+ $this->createSystemDefaultCheckInList($event);
+
return $event;
});
}
+ 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' => false,
+ 'public_show_question_answers' => false,
+ 'public_show_order_details' => false,
+ ]);
+ }
+
/**
* @throws OrganizerNotFoundException
*/
@@ -92,26 +112,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' => StringHelper::stripControlCharacters($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
@@ -129,19 +160,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;
}
@@ -162,11 +190,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();
@@ -191,7 +218,7 @@ private function createEventSettings(
: ($organizerThemeSettings['background_type'] ?? HomepageBackgroundType::COLOR->name),
];
- if (!empty($organizerThemeSettings['font_family'])) {
+ if (! empty($organizerThemeSettings['font_family'])) {
$homepageThemeSettings['font_family'] = $organizerThemeSettings['font_family'];
}
@@ -225,7 +252,9 @@ private function createEventSettings(
'organization_address' => null,
'invoice_tax_details' => null,
- 'attendee_details_collection_method' => $organizerSettings->getDefaultAttendeeDetailsCollectionMethod(),
+ 'attendee_details_collection_method' => $event->getType() === EventType::RECURRING->name
+ ? AttendeeDetailsCollectionMethod::PER_ORDER->value
+ : $organizerSettings->getDefaultAttendeeDetailsCollectionMethod(),
'show_marketing_opt_in' => $organizerSettings->getDefaultShowMarketingOptIn(),
'allow_copy_details_to_all_attendees' => true,
'pass_platform_fee_to_buyer' => $organizerSettings->getDefaultPassPlatformFeeToBuyer(),
diff --git a/backend/app/Services/Domain/Event/DTO/DuplicateEventDataDTO.php b/backend/app/Services/Domain/Event/DTO/DuplicateEventDataDTO.php
index 003ac4ba20..8df75bc5da 100644
--- a/backend/app/Services/Domain/Event/DTO/DuplicateEventDataDTO.php
+++ b/backend/app/Services/Domain/Event/DTO/DuplicateEventDataDTO.php
@@ -7,23 +7,22 @@
class DuplicateEventDataDTO extends BaseDTO
{
public function __construct(
- public int $eventId,
- public int $accountId,
- public string $title,
- public string $startDate,
- public bool $duplicateProducts = true,
- public bool $duplicateQuestions = true,
- public bool $duplicateSettings = true,
- public bool $duplicatePromoCodes = true,
- public bool $duplicateCapacityAssignments = true,
- public bool $duplicateCheckInLists = true,
- public bool $duplicateEventCoverImage = true,
- public bool $duplicateTicketLogo = true,
- public bool $duplicateWebhooks = true,
- public bool $duplicateAffiliates = true,
+ public int $eventId,
+ public int $accountId,
+ public string $title,
+ public string $startDate,
+ public bool $duplicateProducts = true,
+ public bool $duplicateQuestions = true,
+ public bool $duplicateSettings = true,
+ public bool $duplicatePromoCodes = true,
+ public bool $duplicateCapacityAssignments = true,
+ public bool $duplicateCheckInLists = true,
+ public bool $duplicateEventCoverImage = true,
+ 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/DTO/EventCheckInStatsResponseDTO.php b/backend/app/Services/Domain/Event/DTO/EventCheckInStatsResponseDTO.php
index 3045fc7d41..a44f2ecf33 100644
--- a/backend/app/Services/Domain/Event/DTO/EventCheckInStatsResponseDTO.php
+++ b/backend/app/Services/Domain/Event/DTO/EventCheckInStatsResponseDTO.php
@@ -9,7 +9,5 @@ class EventCheckInStatsResponseDTO extends BaseDTO
public function __construct(
public readonly int $total_checked_in_attendees,
public readonly int $total_attendees,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Domain/Event/DTO/EventDailyStatsResponseDTO.php b/backend/app/Services/Domain/Event/DTO/EventDailyStatsResponseDTO.php
index e15c8fd1c5..6cadcd018f 100644
--- a/backend/app/Services/Domain/Event/DTO/EventDailyStatsResponseDTO.php
+++ b/backend/app/Services/Domain/Event/DTO/EventDailyStatsResponseDTO.php
@@ -6,15 +6,13 @@
{
public function __construct(
public string $date,
- public float $total_fees,
- public float $total_tax,
- public float $total_sales_gross,
- public int $products_sold,
- public int $orders_created,
- public int $attendees_registered,
- public float $total_refunded,
+ public float $total_fees,
+ public float $total_tax,
+ public float $total_sales_gross,
+ public int $products_sold,
+ public int $orders_created,
+ public int $attendees_registered,
+ public float $total_refunded,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Domain/Event/DuplicateEventService.php b/backend/app/Services/Domain/Event/DuplicateEventService.php
index 54b7b53e0e..c37ccf3118 100644
--- a/backend/app/Services/Domain/Event/DuplicateEventService.php
+++ b/backend/app/Services/Domain/Event/DuplicateEventService.php
@@ -2,27 +2,39 @@
namespace HiEvents\Services\Domain\Event;
+use Carbon\Carbon;
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\EventLocationDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
+use HiEvents\DomainObjects\Generated\EventDomainObjectAbstract;
+use HiEvents\DomainObjects\Generated\EventLocationDomainObjectAbstract;
use HiEvents\DomainObjects\ImageDomainObject;
use HiEvents\DomainObjects\ProductCategoryDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
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\Helper\StringHelper;
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\AffiliateRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventLocationRepositoryInterface;
+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;
@@ -37,45 +49,47 @@
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,
+ private readonly EventLocationRepositoryInterface $eventLocationRepository,
+ ) {}
/**
* @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();
@@ -83,33 +97,47 @@ public function duplicateEvent(
$event
->setTitle(StringHelper::stripControlCharacters($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,
);
+ $this->cloneEventLocation($event, $newEvent->getId());
+
+ $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());
}
@@ -136,48 +164,91 @@ 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,
);
}
+ private function cloneEventLocation(EventDomainObject $event, int $newEventId): void
+ {
+ $sourceLocation = $event->getEventLocation();
+ if ($sourceLocation === null) {
+ return;
+ }
+
+ $newLocation = $this->createClonedEventLocation($sourceLocation, $newEventId);
+
+ $this->eventRepository->updateWhere(
+ attributes: [EventDomainObjectAbstract::EVENT_LOCATION_ID => $newLocation->getId()],
+ where: [EventDomainObjectAbstract::ID => $newEventId],
+ );
+ }
+
+ private function createClonedEventLocation(EventLocationDomainObject $source, int $newEventId): EventLocationDomainObject
+ {
+ return $this->eventLocationRepository->create([
+ EventLocationDomainObjectAbstract::EVENT_ID => $newEventId,
+ EventLocationDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::EVENT_LOCATION_PREFIX),
+ EventLocationDomainObjectAbstract::TYPE => $source->getType(),
+ EventLocationDomainObjectAbstract::LOCATION_ID => $source->getLocationId(),
+ EventLocationDomainObjectAbstract::ONLINE_EVENT_CONNECTION_DETAILS => $source->getOnlineEventConnectionDetails(),
+ ]);
+ }
+
+ 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())
@@ -192,9 +263,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();
+ }
+ }
}
});
@@ -211,8 +290,10 @@ private function cloneExistingProducts(
}
if ($duplicateCheckInLists) {
- $this->cloneCheckInLists($event, $newEventId, $oldProductToNewProductMap);
+ $this->cloneCheckInLists($event, $newEventId, $oldProductToNewProductMap, $oldToNewOccurrenceMap);
}
+
+ return [$oldProductToNewProductMap, $oldPriceToNewPriceMap];
}
/**
@@ -223,7 +304,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())
@@ -232,7 +313,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(),
),
);
@@ -248,7 +329,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)
@@ -270,17 +351,18 @@ 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())
->setDiscount($promoCode->getDiscount())
->setExpiryDate($promoCode->getExpiryDate())
- ->setMaxAllowedUsages($promoCode->getMaxAllowedUsages()),
+ ->setMaxAllowedUsages($promoCode->getMaxAllowedUsages())
+ ->setDiscountAppliesTo($promoCode->getDiscountAppliesTo()),
);
}
}
@@ -290,30 +372,47 @@ 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) {
+ if ($checkInList->getIsSystemDefault()) {
+ continue;
+ }
+
+ $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)
+ ->setPublicShowAttendeeNotes($checkInList->getPublicShowAttendeeNotes())
+ ->setPublicShowQuestionAnswers($checkInList->getPublicShowQuestionAnswers())
+ ->setPublicShowOrderDetails($checkInList->getPublicShowOrderDetails())
->setEventId($newEventId),
productIds: $checkInList->getProducts()
- ?->map(fn($product) => $oldProductToNewProductMap[$product->getId()])?->toArray() ?? [],
+ ?->map(fn ($product) => $oldProductToNewProductMap[$product->getId()])?->toArray() ?? [],
);
}
}
@@ -321,7 +420,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,
@@ -339,7 +438,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,
@@ -357,6 +456,10 @@ private function cloneTicketLogo(EventDomainObject $event, int $newEventId): voi
private function getEventWithRelations(string $eventId, string $accountId): EventDomainObject
{
return $this->eventRepository
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location'))
+ ->loadRelation(new Relationship(domainObject: EventOccurrenceDomainObject::class, nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location'),
+ ]))
->loadRelation(EventSettingDomainObject::class)
->loadRelation(
new Relationship(ProductCategoryDomainObject::class, [
@@ -389,7 +492,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())
@@ -414,4 +517,84 @@ 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()->utc();
+ $oldToNewOccurrenceMap = [];
+
+ $event->getEventOccurrences()
+ ?->filter(fn (EventOccurrenceDomainObject $occurrence) => Carbon::parse($occurrence->getStartDate())->utc()->gte($now)
+ && ! $occurrence->isCancelled()
+ )
+ ->each(function (EventOccurrenceDomainObject $occurrence) use ($newEventId, &$oldToNewOccurrenceMap) {
+ $sourceLocation = $occurrence->getEventLocation();
+ $newLocationId = $sourceLocation !== null
+ ? $this->createClonedEventLocation($sourceLocation, $newEventId)->getId()
+ : null;
+
+ $newOccurrence = $this->eventOccurrenceRepository->create([
+ 'event_id' => $newEventId,
+ 'event_location_id' => $newLocationId,
+ 'start_date' => $occurrence->getStartDate(),
+ 'end_date' => $occurrence->getEndDate(),
+ 'status' => EventOccurrenceStatus::ACTIVE->name,
+ 'capacity' => $occurrence->getCapacity(),
+ 'used_capacity' => 0,
+ 'label' => $occurrence->getLabel(),
+ 'is_overridden' => $occurrence->getIsOverridden(),
+ 'show_available_capacity' => $occurrence->getShowAvailableCapacity(),
+ 'short_id' => IdHelper::shortId(IdHelper::OCCURRENCE_PREFIX),
+ ]);
+ $oldToNewOccurrenceMap[$occurrence->getId()] = $newOccurrence->getId();
+ });
+
+ return $oldToNewOccurrenceMap;
+ }
+
+ private function cloneOccurrenceProductSettings(
+ array $oldToNewOccurrenceMap,
+ array $oldProductToNewProductMap,
+ array $oldPriceToNewPriceMap,
+ ): void {
+ $oldOccurrenceIds = array_keys($oldToNewOccurrenceMap);
+
+ $priceOverrideInserts = [];
+ $priceOverrides = $this->priceOverrideRepository->findWhereIn('event_occurrence_id', $oldOccurrenceIds);
+ foreach ($priceOverrides as $override) {
+ $newPriceId = $oldPriceToNewPriceMap[$override->getProductPriceId()] ?? null;
+ if ($newPriceId === null) {
+ continue;
+ }
+
+ $priceOverrideInserts[] = [
+ 'event_occurrence_id' => $oldToNewOccurrenceMap[$override->getEventOccurrenceId()],
+ 'product_price_id' => $newPriceId,
+ 'price' => $override->getPrice(),
+ ];
+ }
+ if ($priceOverrideInserts !== []) {
+ $this->priceOverrideRepository->insert($priceOverrideInserts);
+ }
+
+ $visibilityInserts = [];
+ $visibilityRecords = $this->visibilityRepository->findWhereIn('event_occurrence_id', $oldOccurrenceIds);
+ foreach ($visibilityRecords as $visibility) {
+ $newProductId = $oldProductToNewProductMap[$visibility->getProductId()] ?? null;
+ if ($newProductId === null) {
+ continue;
+ }
+
+ $visibilityInserts[] = [
+ 'event_occurrence_id' => $oldToNewOccurrenceMap[$visibility->getEventOccurrenceId()],
+ 'product_id' => $newProductId,
+ ];
+ }
+ if ($visibilityInserts !== []) {
+ $this->visibilityRepository->insert($visibilityInserts);
+ }
+ }
}
diff --git a/backend/app/Services/Domain/Event/EventDeletionService.php b/backend/app/Services/Domain/Event/EventDeletionService.php
index a34e683da9..9c53fef0cb 100644
--- a/backend/app/Services/Domain/Event/EventDeletionService.php
+++ b/backend/app/Services/Domain/Event/EventDeletionService.php
@@ -15,18 +15,16 @@ class EventDeletionService
public function __construct(
private readonly EventRepositoryInterface $eventRepository,
private readonly OrderRepositoryInterface $orderRepository,
- private readonly LoggerInterface $logger,
- private readonly DatabaseManager $databaseManager,
- )
- {
- }
+ private readonly LoggerInterface $logger,
+ private readonly DatabaseManager $databaseManager,
+ ) {}
public function canDeleteEvent(int $eventId): bool
{
return $this->orderRepository->countWhere([
- 'event_id' => $eventId,
- 'status' => OrderStatus::COMPLETED->name,
- ]) === 0;
+ 'event_id' => $eventId,
+ 'status' => OrderStatus::COMPLETED->name,
+ ]) === 0;
}
/**
@@ -36,7 +34,7 @@ public function canDeleteEvent(int $eventId): bool
public function deleteEvent(int $eventId, int $accountId): void
{
$this->databaseManager->transaction(function () use ($eventId, $accountId) {
- if (!$this->canDeleteEvent($eventId)) {
+ if (! $this->canDeleteEvent($eventId)) {
throw new CannotDeleteEntityException(
__('This event cannot be deleted because it has completed orders. Please cancel or refund all orders first.')
);
diff --git a/backend/app/Services/Domain/Event/EventOccurrenceGeneratorService.php b/backend/app/Services/Domain/Event/EventOccurrenceGeneratorService.php
new file mode 100644
index 0000000000..b3a19d9939
--- /dev/null
+++ b/backend/app/Services/Domain/Event/EventOccurrenceGeneratorService.php
@@ -0,0 +1,174 @@
+ruleParser->parse($recurrenceRule, $event->getTimezone() ?? 'UTC');
+
+ $existingOccurrences = $this->occurrenceRepository->findWhere([
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $event->getId(),
+ ]);
+
+ $existingByStartDate = collect($existingOccurrences)->keyBy(
+ fn (EventOccurrenceDomainObject $occ) => Carbon::parse($occ->getStartDate())->utc()->toDateTimeString()
+ );
+
+ $existingIds = collect($existingOccurrences)
+ ->map(fn (EventOccurrenceDomainObject $occ) => $occ->getId())
+ ->all();
+ $occurrenceIdsInUse = $this->getOccurrenceIdsInUse($existingIds);
+
+ $result = collect();
+ $matchedExistingIds = [];
+
+ foreach ($candidates as $candidate) {
+ $startDateKey = $candidate['start']->copy()->utc()->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;
+ }
+
+ $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],
+ ]);
+ }
+
+ 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/EventPageViewIncrementService.php b/backend/app/Services/Domain/Event/EventPageViewIncrementService.php
index d04d1e879a..38282b0511 100644
--- a/backend/app/Services/Domain/Event/EventPageViewIncrementService.php
+++ b/backend/app/Services/Domain/Event/EventPageViewIncrementService.php
@@ -13,17 +13,16 @@ class EventPageViewIncrementService
public function __construct(
private readonly CacheManager $cacheManager,
- private readonly Repository $config,
+ private readonly Repository $config,
private readonly QueueManager $queueManager,
- )
- {
+ ) {
$this->batchSize = $this->config->get('app.homepage_views_update_batch_size');
}
public function increment(int $eventId, string $userIpAddress): void
{
- $eventViewsCacheKey = 'event_views_' . $eventId;
- $userViewCacheKey = 'event_view_user_' . $eventId . '_' . $userIpAddress;
+ $eventViewsCacheKey = 'event_views_'.$eventId;
+ $userViewCacheKey = 'event_view_user_'.$eventId.'_'.$userIpAddress;
if ($this->cacheManager->has($userViewCacheKey)) {
return;
diff --git a/backend/app/Services/Domain/Event/EventStatsFetchService.php b/backend/app/Services/Domain/Event/EventStatsFetchService.php
index 071fe836ed..28ab7a6340 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,66 @@ public function getEventStats(EventStatsRequestDTO $requestData): EventStatsResp
}
$eventId = $requestData->event_id;
+ $occurrenceId = $requestData->occurrence_id;
+
+ if ($occurrenceId !== null) {
+ $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 +108,18 @@ 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) {
+ $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 +196,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 +211,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..2bc5759975
--- /dev/null
+++ b/backend/app/Services/Domain/Event/RecurrenceRuleParserService.php
@@ -0,0 +1,442 @@
+
+ */
+ 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';
+ $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) {
+ 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)->setTimezone($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);
+ if ($excludedOccurrences->contains($start->format('Y-m-d H:i'))) {
+ continue;
+ }
+
+ $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,
+ ];
+
+ $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 = collect($rule['days_of_month'] ?? [1])->sort()->values();
+ $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();
+
+ 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..569b01fa3a
--- /dev/null
+++ b/backend/app/Services/Domain/EventOccurrence/CancelOccurrenceAttendeesService.php
@@ -0,0 +1,163 @@
+, sales_backed_count: int}
+ */
+ public function cancelForOccurrence(int $eventId, int $occurrenceId): array
+ {
+ $statusesToCancel = [AttendeeStatus::ACTIVE->name, AttendeeStatus::AWAITING_PAYMENT->name];
+
+ $attendees = $this->attendeeRepository->findWhere([
+ AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrenceId,
+ [AttendeeDomainObjectAbstract::STATUS, 'in', $statusesToCancel],
+ ]);
+
+ if ($attendees->isEmpty()) {
+ return ['attendee_ids' => [], 'sales_backed_count' => 0];
+ }
+
+ $this->attendeeRepository->updateWhere(
+ attributes: [AttendeeDomainObjectAbstract::STATUS => AttendeeStatus::CANCELLED->name],
+ where: [
+ AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrenceId,
+ [AttendeeDomainObjectAbstract::STATUS, 'in', $statusesToCancel],
+ ],
+ );
+
+ $ordersById = $this->orderRepository
+ ->findWhereIn('id', $attendees->map(fn (AttendeeDomainObject $attendee) => $attendee->getOrderId())->unique()->values()->all())
+ ->keyBy(fn (OrderDomainObject $order) => $order->getId());
+
+ $inventoryBackedAttendees = $attendees->filter(function (AttendeeDomainObject $attendee) use ($ordersById) {
+ $order = $ordersById->get($attendee->getOrderId());
+
+ return $order !== null && in_array($order->getStatus(), [
+ OrderStatus::COMPLETED->name,
+ OrderStatus::AWAITING_OFFLINE_PAYMENT->name,
+ ], true);
+ });
+
+ $soldCountsByProductPrice = $inventoryBackedAttendees
+ ->map(fn (AttendeeDomainObject $attendee) => $attendee->getProductPriceId())
+ ->countBy();
+
+ foreach ($soldCountsByProductPrice as $productPriceId => $count) {
+ $this->productQuantityService->decreaseQuantitySold(
+ priceId: (int) $productPriceId,
+ adjustment: $count,
+ eventOccurrenceId: $occurrenceId,
+ );
+ }
+
+ $statsBackedAttendees = $attendees->filter(function (AttendeeDomainObject $attendee) use ($ordersById) {
+ $order = $ordersById->get($attendee->getOrderId());
+
+ return $order !== null && $order->getStatus() === OrderStatus::COMPLETED->name;
+ });
+
+ $this->decrementStatisticsForCancelledAttendees($eventId, $occurrenceId, $statsBackedAttendees, $ordersById);
+
+ foreach ($attendees as $attendee) {
+ $this->domainEventDispatcherService->dispatch(new AttendeeEvent(
+ type: DomainEventType::ATTENDEE_CANCELLED,
+ attendeeId: $attendee->getId(),
+ ));
+ }
+
+ $productIds = $inventoryBackedAttendees
+ ->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,
+ ));
+ }
+
+ return [
+ 'attendee_ids' => $attendees->map(fn (AttendeeDomainObject $attendee) => $attendee->getId())->values()->all(),
+ 'sales_backed_count' => $inventoryBackedAttendees->count(),
+ ];
+ }
+
+ /**
+ * @param Collection $attendees
+ * @param Collection $ordersById
+ */
+ private function decrementStatisticsForCancelledAttendees(
+ int $eventId,
+ int $occurrenceId,
+ Collection $attendees,
+ Collection $ordersById,
+ ): void {
+ $countsByOrderId = $attendees
+ ->groupBy(fn (AttendeeDomainObject $attendee) => $attendee->getOrderId())
+ ->map->count();
+
+ foreach ($countsByOrderId as $orderId => $attendeeCount) {
+ $order = $ordersById->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..ed619bd276
--- /dev/null
+++ b/backend/app/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityService.php
@@ -0,0 +1,136 @@
+occurrenceRepository->findFirstWhere([
+ 'id' => $occurrenceId,
+ 'event_id' => $eventId,
+ ]);
+
+ if ($occurrence === null || $occurrence->getEventId() !== $eventId) {
+ throw ValidationException::withMessages([
+ 'event_occurrence_id' => __('The specified event occurrence was not found'),
+ ]);
+ }
+
+ if ($occurrence->isCancelled()) {
+ throw ValidationException::withMessages([
+ 'event_occurrence_id' => $this->purchasabilityMessage(
+ $eventId,
+ __('This event occurrence has been cancelled'),
+ __('This event has been cancelled'),
+ ),
+ ]);
+ }
+
+ if ($occurrence->isPast()) {
+ throw ValidationException::withMessages([
+ 'event_occurrence_id' => $this->purchasabilityMessage(
+ $eventId,
+ __('This event occurrence has already ended'),
+ __('This event has already ended'),
+ ),
+ ]);
+ }
+
+ if ($overrideCapacity || $additionalQuantity <= 0) {
+ return $occurrence;
+ }
+
+ if ($occurrence->isSoldOut()) {
+ throw ValidationException::withMessages([
+ 'event_occurrence_id' => $this->purchasabilityMessage(
+ $eventId,
+ __('This event occurrence is sold out'),
+ __('This event is sold out'),
+ ),
+ ]);
+ }
+
+ if ($occurrence->getCapacity() !== null) {
+ $reservedForOccurrence = $reservedQuantity
+ ?? $this->orderItemRepository->getReservedQuantityForOccurrence($occurrenceId);
+
+ $available = $occurrence->getCapacity() - $occurrence->getUsedCapacity() - $reservedForOccurrence;
+ if ($additionalQuantity > $available) {
+ throw ValidationException::withMessages([
+ 'event_occurrence_id' => $this->purchasabilityMessage(
+ $eventId,
+ __('Not enough capacity available for this occurrence'),
+ __('Not enough capacity available for this event'),
+ ),
+ ]);
+ }
+ }
+
+ return $occurrence;
+ }
+
+ /**
+ * @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'),
+ ]);
+ }
+ }
+ }
+
+ private function purchasabilityMessage(int $eventId, string $multiOccurrence, string $singleOccurrence): string
+ {
+ return $this->eventHasMultipleOccurrences($eventId) ? $multiOccurrence : $singleOccurrence;
+ }
+
+ private function eventHasMultipleOccurrences(int $eventId): bool
+ {
+ return $this->occurrenceRepository->countWhere(['event_id' => $eventId]) > 1;
+ }
+}
diff --git a/backend/app/Services/Domain/EventOccurrence/PublicOccurrenceVisibilityService.php b/backend/app/Services/Domain/EventOccurrence/PublicOccurrenceVisibilityService.php
new file mode 100644
index 0000000000..bdbfce0b68
--- /dev/null
+++ b/backend/app/Services/Domain/EventOccurrence/PublicOccurrenceVisibilityService.php
@@ -0,0 +1,76 @@
+getType() === EventType::RECURRING->name
+ && ($event->getEventSettings()?->getHideSoldOutOccurrences() ?? false)
+ && ! $this->eventHasWaitlistEnabledProducts($event);
+ }
+
+ public function buildWhereConditions(int $eventId, bool $isRecurring, bool $hideSoldOutOccurrences): array
+ {
+ $where = [
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
+ [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name],
+ ];
+
+ if ($isRecurring) {
+ $where[] = self::isNotEnded();
+ }
+
+ if ($hideSoldOutOccurrences) {
+ $where[] = self::hasRemainingCapacity();
+ }
+
+ return $where;
+ }
+
+ public static function hasRemainingCapacity(): Closure
+ {
+ return static function ($query): void {
+ $query->whereNull(EventOccurrenceDomainObjectAbstract::CAPACITY)
+ ->orWhereColumn(
+ EventOccurrenceDomainObjectAbstract::USED_CAPACITY,
+ '<',
+ EventOccurrenceDomainObjectAbstract::CAPACITY,
+ );
+ };
+ }
+
+ public static function isNotEnded(): Closure
+ {
+ return static function ($query): void {
+ $query->whereRaw(
+ sprintf(
+ 'COALESCE(%s, %s) >= ?',
+ EventOccurrenceDomainObjectAbstract::END_DATE,
+ EventOccurrenceDomainObjectAbstract::START_DATE,
+ ),
+ [now()->toDateTimeString()],
+ );
+ };
+ }
+
+ private function eventHasWaitlistEnabledProducts(EventDomainObject $event): bool
+ {
+ return $event->getProductCategories()
+ ?->contains(
+ fn (ProductCategoryDomainObject $category) => $category->getProducts()
+ ?->contains(fn (ProductDomainObject $product) => $product->getWaitlistEnabled() === true) ?? false
+ ) ?? false;
+ }
+}
diff --git a/backend/app/Services/Domain/EventStatistics/EventStatisticsCancellationService.php b/backend/app/Services/Domain/EventStatistics/EventStatisticsCancellationService.php
index c0e84410a0..9b3764fce5 100644
--- a/backend/app/Services/Domain/EventStatistics/EventStatisticsCancellationService.php
+++ b/backend/app/Services/Domain/EventStatistics/EventStatisticsCancellationService.php
@@ -5,14 +5,21 @@
namespace HiEvents\Services\Domain\EventStatistics;
use HiEvents\DomainObjects\Generated\OrderDomainObjectAbstract;
+use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract;
+use HiEvents\DomainObjects\Generated\PromoCodeDomainObjectAbstract;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\DomainObjects\Status\AttendeeStatus;
use HiEvents\Exceptions\EventStatisticsVersionMismatchException;
+use HiEvents\Repository\Interfaces\AffiliateRepositoryInterface;
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\Repository\Interfaces\ProductRepositoryInterface;
+use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface;
use HiEvents\Services\Infrastructure\Utlitiy\Retry\Retrier;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Carbon;
@@ -23,20 +30,21 @@
class EventStatisticsCancellationService
{
public function __construct(
- private readonly EventStatisticRepositoryInterface $eventStatisticsRepository,
+ private readonly EventStatisticRepositoryInterface $eventStatisticsRepository,
private readonly EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository,
- private readonly AttendeeRepositoryInterface $attendeeRepository,
- private readonly OrderRepositoryInterface $orderRepository,
- private readonly LoggerInterface $logger,
- private readonly DatabaseManager $databaseManager,
- private readonly Retrier $retrier,
- )
- {
- }
+ private readonly EventOccurrenceStatisticRepositoryInterface $eventOccurrenceStatisticRepository,
+ private readonly EventOccurrenceDailyStatisticRepositoryInterface $eventOccurrenceDailyStatisticRepository,
+ private readonly AttendeeRepositoryInterface $attendeeRepository,
+ private readonly OrderRepositoryInterface $orderRepository,
+ private readonly LoggerInterface $logger,
+ private readonly DatabaseManager $databaseManager,
+ private readonly Retrier $retrier,
+ private readonly PromoCodeRepositoryInterface $promoCodeRepository,
+ private readonly ProductRepositoryInterface $productRepository,
+ private readonly AffiliateRepositoryInterface $affiliateRepository,
+ ) {}
/**
- * Decrement statistics for a cancelled order (deterministic - only decrements once)
- *
* @throws EventStatisticsVersionMismatchException
* @throws Throwable
*/
@@ -46,7 +54,6 @@ public function decrementForCancelledOrder(OrderDomainObject $order): void
->loadRelation(OrderItemDomainObject::class)
->findById($order->getId());
- // Check if statistics have already been decremented for this order
if ($order->getStatisticsDecrementedAt() !== null) {
$this->logger->info(
'Statistics already decremented for cancelled order',
@@ -56,6 +63,13 @@ public function decrementForCancelledOrder(OrderDomainObject $order): void
'decremented_at' => $order->getStatisticsDecrementedAt(),
]
);
+
+ return;
+ }
+
+ if (! $order->isOrderCompleted()) {
+ $this->markStatisticsAsDecremented($order);
+
return;
}
@@ -72,19 +86,21 @@ public function decrementForCancelledOrder(OrderDomainObject $order): void
'decremented_at' => $currentOrder->getStatisticsDecrementedAt(),
]
);
+
return;
}
- // Calculate counts to decrement
$counts = $this->calculateDecrementCounts($order);
- // Decrement aggregate statistics
$this->decrementAggregateStatistics($order, $counts, $attempt);
-
- // Decrement daily statistics
$this->decrementDailyStatistics($order, $counts, $attempt);
+ $this->decrementOccurrenceStatistics($order);
+ $this->decrementOccurrenceDailyStatistics($order);
+
+ $this->decrementPromoCodeUsage($order);
+ $this->decrementProductSalesVolume($order);
+ $this->decrementAffiliateSales($order);
- // Mark statistics as decremented
$this->markStatisticsAsDecremented($order);
});
},
@@ -105,21 +121,20 @@ public function decrementForCancelledOrder(OrderDomainObject $order): void
}
/**
- * Decrement statistics for a cancelled attendee
- *
* @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 {
@@ -139,12 +154,8 @@ public function decrementForCancelledAttendee(int $eventId, string $orderDate, i
);
}
- /**
- * Calculate the counts that need to be decremented from statistics
- */
private function calculateDecrementCounts(OrderDomainObject $order): array
{
- // Get attendees that are currently active or awaiting payment (not already cancelled)
$activeAttendees = $this->attendeeRepository->findWhereIn(
field: 'status',
values: [AttendeeStatus::ACTIVE->name, AttendeeStatus::AWAITING_PAYMENT->name],
@@ -153,13 +164,9 @@ private function calculateDecrementCounts(OrderDomainObject $order): array
$activeAttendeeCount = $activeAttendees->count();
- // Products sold should be the full order quantities - products don't get "uncancelled"
- // when individual attendees are cancelled, only when the entire order is cancelled
$productsSold = $order->getOrderItems()
- ?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()) ?? 0;
+ ?->sum(fn (OrderItemDomainObject $orderItem) => $orderItem->getQuantity()) ?? 0;
- // Attendees registered should only be the currently active attendees
- // to avoid over-decrementing when some attendees were already cancelled individually
$attendeesRegistered = $activeAttendeeCount;
return [
@@ -170,8 +177,6 @@ private function calculateDecrementCounts(OrderDomainObject $order): array
}
/**
- * Decrement aggregate event statistics for cancelled order
- *
* @throws EventStatisticsVersionMismatchException
*/
private function decrementAggregateStatistics(OrderDomainObject $order, array $counts, int $attempt): void
@@ -180,8 +185,8 @@ private function decrementAggregateStatistics(OrderDomainObject $order, array $c
'event_id' => $order->getEventId(),
]);
- if (!$eventStatistics) {
- throw new ResourceNotFoundException('Event statistics not found for event ' . $order->getEventId());
+ if (! $eventStatistics) {
+ throw new ResourceNotFoundException('Event statistics not found for event '.$order->getEventId());
}
$updates = [
@@ -203,7 +208,7 @@ private function decrementAggregateStatistics(OrderDomainObject $order, array $c
if ($updated === 0) {
throw new EventStatisticsVersionMismatchException(
'Event statistics version mismatch. Expected version '
- . $eventStatistics->getVersion() . ' but it was already updated.'
+ .$eventStatistics->getVersion().' but it was already updated.'
);
}
@@ -222,8 +227,6 @@ private function decrementAggregateStatistics(OrderDomainObject $order, array $c
}
/**
- * Decrement aggregate event statistics for cancelled attendee
- *
* @throws EventStatisticsVersionMismatchException
*/
private function decrementAggregateAttendeeStatistics(int $eventId, int $attendeeCount): void
@@ -232,12 +235,10 @@ private function decrementAggregateAttendeeStatistics(int $eventId, int $attende
'event_id' => $eventId,
]);
- if (!$eventStatistics) {
- throw new ResourceNotFoundException('Event statistics not found for event ' . $eventId);
+ if (! $eventStatistics) {
+ throw new ResourceNotFoundException('Event statistics not found for event '.$eventId);
}
- // Only decrement attendees_registered for individual attendee cancellations
- // products_sold should NOT be affected as the product was still sold
$updates = [
'attendees_registered' => max(0, $eventStatistics->getAttendeesRegistered() - $attendeeCount),
'version' => $eventStatistics->getVersion() + 1,
@@ -254,7 +255,7 @@ private function decrementAggregateAttendeeStatistics(int $eventId, int $attende
if ($updated === 0) {
throw new EventStatisticsVersionMismatchException(
'Event statistics version mismatch. Expected version '
- . $eventStatistics->getVersion() . ' but it was already updated.'
+ .$eventStatistics->getVersion().' but it was already updated.'
);
}
@@ -263,15 +264,13 @@ private function decrementAggregateAttendeeStatistics(int $eventId, int $attende
[
'event_id' => $eventId,
'attendees_decremented' => $attendeeCount,
- 'products_affected' => 0, // Products sold not affected by individual attendee cancellations
+ 'products_affected' => 0,
'new_version' => $eventStatistics->getVersion() + 1,
]
);
}
/**
- * Decrement daily event statistics for cancelled order
- *
* @throws EventStatisticsVersionMismatchException
*/
private function decrementDailyStatistics(OrderDomainObject $order, array $counts, int $attempt): void
@@ -283,7 +282,7 @@ private function decrementDailyStatistics(OrderDomainObject $order, array $count
'date' => $orderDate,
]);
- if (!$eventDailyStatistic) {
+ if (! $eventDailyStatistic) {
$this->logger->warning(
'Event daily statistics not found for event, skipping daily decrement',
[
@@ -291,6 +290,7 @@ private function decrementDailyStatistics(OrderDomainObject $order, array $count
'date' => $orderDate,
]
);
+
return;
}
@@ -314,7 +314,7 @@ private function decrementDailyStatistics(OrderDomainObject $order, array $count
if ($updated === 0) {
throw new EventStatisticsVersionMismatchException(
'Event daily statistics version mismatch. Expected version '
- . $eventDailyStatistic->getVersion() . ' but it was already updated.'
+ .$eventDailyStatistic->getVersion().' but it was already updated.'
);
}
@@ -334,8 +334,6 @@ private function decrementDailyStatistics(OrderDomainObject $order, array $count
}
/**
- * Decrement daily event statistics for cancelled attendee
- *
* @throws EventStatisticsVersionMismatchException
*/
private function decrementDailyAttendeeStatistics(int $eventId, string $orderDate, int $attendeeCount): void
@@ -347,7 +345,7 @@ private function decrementDailyAttendeeStatistics(int $eventId, string $orderDat
'date' => $formattedDate,
]);
- if (!$eventDailyStatistic) {
+ if (! $eventDailyStatistic) {
$this->logger->warning(
'Event daily statistics not found for event, skipping daily decrement for cancelled attendee',
[
@@ -355,11 +353,10 @@ private function decrementDailyAttendeeStatistics(int $eventId, string $orderDat
'date' => $formattedDate,
]
);
+
return;
}
- // Only decrement attendees_registered for individual attendee cancellations
- // products_sold should NOT be affected as the product was still sold
$updates = [
'attendees_registered' => max(0, $eventDailyStatistic->getAttendeesRegistered() - $attendeeCount),
'version' => $eventDailyStatistic->getVersion() + 1,
@@ -377,7 +374,7 @@ private function decrementDailyAttendeeStatistics(int $eventId, string $orderDat
if ($updated === 0) {
throw new EventStatisticsVersionMismatchException(
'Event daily statistics version mismatch. Expected version '
- . $eventDailyStatistic->getVersion() . ' but it was already updated.'
+ .$eventDailyStatistic->getVersion().' but it was already updated.'
);
}
@@ -387,15 +384,251 @@ private function decrementDailyAttendeeStatistics(int $eventId, string $orderDat
'event_id' => $eventId,
'date' => $formattedDate,
'attendees_decremented' => $attendeeCount,
- 'products_affected' => 0, // Products sold not affected by individual attendee cancellations
+ 'products_affected' => 0,
'new_version' => $eventDailyStatistic->getVersion() + 1,
]
);
}
/**
- * Mark that statistics have been decremented for this order
+ * @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
+ );
+ }
+ }
+
+ private function decrementPromoCodeUsage(OrderDomainObject $order): void
+ {
+ if ($order->getPromoCodeId() === null) {
+ return;
+ }
+
+ $attendeeCount = $order->getOrderItems()
+ ?->sum(fn (OrderItemDomainObject $orderItem) => $orderItem->getQuantity()) ?? 0;
+
+ $columns = [PromoCodeDomainObjectAbstract::ORDER_USAGE_COUNT => 1];
+
+ if ($attendeeCount > 0) {
+ $columns[PromoCodeDomainObjectAbstract::ATTENDEE_USAGE_COUNT] = $attendeeCount;
+ }
+
+ $this->promoCodeRepository->decrementEach(
+ where: ['id' => $order->getPromoCodeId()],
+ columns: $columns,
+ );
+
+ $this->logger->info(
+ 'Promo code usage decremented for cancelled order',
+ [
+ 'promo_code_id' => $order->getPromoCodeId(),
+ 'order_id' => $order->getId(),
+ 'attendee_count' => $attendeeCount,
+ ]
+ );
+ }
+
+ private function decrementProductSalesVolume(OrderDomainObject $order): void
+ {
+ foreach ($order->getOrderItems() ?? [] as $orderItem) {
+ $this->productRepository->decrement(
+ $orderItem->getProductId(),
+ ProductDomainObjectAbstract::SALES_VOLUME,
+ $orderItem->getTotalBeforeAdditions(),
+ );
+ }
+ }
+
+ private function decrementAffiliateSales(OrderDomainObject $order): void
+ {
+ if ($order->getAffiliateId() === null) {
+ return;
+ }
+
+ $this->affiliateRepository->decrementSales(
+ affiliateId: $order->getAffiliateId(),
+ amount: $order->getTotalGross(),
+ );
+ }
+
private function markStatisticsAsDecremented(OrderDomainObject $order): void
{
$this->orderRepository->updateFromArray($order->getId(), [
diff --git a/backend/app/Services/Domain/EventStatistics/EventStatisticsIncrementService.php b/backend/app/Services/Domain/EventStatistics/EventStatisticsIncrementService.php
index d6b7a1b826..feb84d3672 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;
@@ -23,17 +26,17 @@
class EventStatisticsIncrementService
{
public function __construct(
- private readonly PromoCodeRepositoryInterface $promoCodeRepository,
- private readonly ProductRepositoryInterface $productRepository,
- private readonly EventStatisticRepositoryInterface $eventStatisticsRepository,
+ private readonly PromoCodeRepositoryInterface $promoCodeRepository,
+ private readonly ProductRepositoryInterface $productRepository,
+ private readonly EventStatisticRepositoryInterface $eventStatisticsRepository,
private readonly EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository,
- private readonly DatabaseManager $databaseManager,
- private readonly OrderRepositoryInterface $orderRepository,
- private readonly LoggerInterface $logger,
- private readonly Retrier $retrier,
- )
- {
- }
+ private readonly EventOccurrenceStatisticRepositoryInterface $eventOccurrenceStatisticRepository,
+ private readonly EventOccurrenceDailyStatisticRepositoryInterface $eventOccurrenceDailyStatisticRepository,
+ private readonly DatabaseManager $databaseManager,
+ private readonly OrderRepositoryInterface $orderRepository,
+ private readonly LoggerInterface $logger,
+ private readonly Retrier $retrier,
+ ) {}
/**
* Increment statistics for a new order
@@ -52,6 +55,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);
});
@@ -84,10 +89,10 @@ private function incrementAggregateStatistics(OrderDomainObject $order): void
]);
$productsSold = $order->getOrderItems()
- ?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()) ?? 0;
+ ?->sum(fn (OrderItemDomainObject $orderItem) => $orderItem->getQuantity()) ?? 0;
$attendeesRegistered = $order->getTicketOrderItems()
- ?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()) ?? 0;
+ ?->sum(fn (OrderItemDomainObject $orderItem) => $orderItem->getQuantity()) ?? 0;
if ($eventStatistics === null) {
$this->eventStatisticsRepository->create([
@@ -137,7 +142,7 @@ private function incrementAggregateStatistics(OrderDomainObject $order): void
if ($updated === 0) {
throw new EventStatisticsVersionMismatchException(
'Event statistics version mismatch. Expected version '
- . $eventStatistics->getVersion() . ' but it was already updated.'
+ .$eventStatistics->getVersion().' but it was already updated.'
);
}
@@ -168,10 +173,10 @@ private function incrementDailyStatistics(OrderDomainObject $order): void
]);
$productsSold = $order->getOrderItems()
- ?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()) ?? 0;
+ ?->sum(fn (OrderItemDomainObject $orderItem) => $orderItem->getQuantity()) ?? 0;
$attendeesRegistered = $order->getTicketOrderItems()
- ?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()) ?? 0;
+ ?->sum(fn (OrderItemDomainObject $orderItem) => $orderItem->getQuantity()) ?? 0;
if ($eventDailyStatistic === null) {
$this->eventDailyStatisticRepository->create([
@@ -224,7 +229,7 @@ private function incrementDailyStatistics(OrderDomainObject $order): void
if ($updated === 0) {
throw new EventStatisticsVersionMismatchException(
'Event daily statistics version mismatch. Expected version '
- . $eventDailyStatistic->getVersion() . ' but it was already updated.'
+ .$eventDailyStatistic->getVersion().' but it was already updated.'
);
}
@@ -241,6 +246,158 @@ private function incrementDailyStatistics(OrderDomainObject $order): void
);
}
+ /**
+ * @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
*/
@@ -250,22 +407,20 @@ private function incrementPromoCodeUsage(OrderDomainObject $order): void
return;
}
- $this->promoCodeRepository->increment(
- id: $order->getPromoCodeId(),
- column: PromoCodeDomainObjectAbstract::ORDER_USAGE_COUNT,
- );
-
$attendeeCount = $order->getOrderItems()
- ?->sum(fn(OrderItemDomainObject $orderItem) => $orderItem->getQuantity()) ?? 0;
+ ?->sum(fn (OrderItemDomainObject $orderItem) => $orderItem->getQuantity()) ?? 0;
+
+ $columns = [PromoCodeDomainObjectAbstract::ORDER_USAGE_COUNT => 1];
if ($attendeeCount > 0) {
- $this->promoCodeRepository->increment(
- id: $order->getPromoCodeId(),
- column: PromoCodeDomainObjectAbstract::ATTENDEE_USAGE_COUNT,
- amount: $attendeeCount,
- );
+ $columns[PromoCodeDomainObjectAbstract::ATTENDEE_USAGE_COUNT] = $attendeeCount;
}
+ $this->promoCodeRepository->incrementEach(
+ columns: $columns,
+ where: ['id' => $order->getPromoCodeId()],
+ );
+
$this->logger->info(
'Promo code usage incremented',
[
diff --git a/backend/app/Services/Domain/EventStatistics/EventStatisticsReactivationService.php b/backend/app/Services/Domain/EventStatistics/EventStatisticsReactivationService.php
index 61428ec45b..41ed288990 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;
@@ -17,26 +19,30 @@
class EventStatisticsReactivationService
{
public function __construct(
- private readonly EventStatisticRepositoryInterface $eventStatisticsRepository,
+ private readonly EventStatisticRepositoryInterface $eventStatisticsRepository,
private readonly EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository,
- private readonly LoggerInterface $logger,
- private readonly DatabaseManager $databaseManager,
- private readonly Retrier $retrier,
- )
- {
- }
+ private readonly EventOccurrenceStatisticRepositoryInterface $eventOccurrenceStatisticRepository,
+ private readonly EventOccurrenceDailyStatisticRepositoryInterface $eventOccurrenceDailyStatisticRepository,
+ private readonly LoggerInterface $logger,
+ private readonly DatabaseManager $databaseManager,
+ private readonly Retrier $retrier,
+ ) {}
/**
* @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 {
@@ -65,8 +71,8 @@ private function incrementAggregateAttendeeStatistics(int $eventId, int $attende
'event_id' => $eventId,
]);
- if (!$eventStatistics) {
- throw new ResourceNotFoundException('Event statistics not found for event ' . $eventId);
+ if (! $eventStatistics) {
+ throw new ResourceNotFoundException('Event statistics not found for event '.$eventId);
}
$updates = [
@@ -85,7 +91,7 @@ private function incrementAggregateAttendeeStatistics(int $eventId, int $attende
if ($updated === 0) {
throw new EventStatisticsVersionMismatchException(
'Event statistics version mismatch. Expected version '
- . $eventStatistics->getVersion() . ' but it was already updated.'
+ .$eventStatistics->getVersion().' but it was already updated.'
);
}
@@ -111,7 +117,7 @@ private function incrementDailyAttendeeStatistics(int $eventId, string $orderDat
'date' => $formattedDate,
]);
- if (!$eventDailyStatistic) {
+ if (! $eventDailyStatistic) {
$this->logger->warning(
'Event daily statistics not found for event, skipping daily increment for reactivated attendee',
[
@@ -119,6 +125,7 @@ private function incrementDailyAttendeeStatistics(int $eventId, string $orderDat
'date' => $formattedDate,
]
);
+
return;
}
@@ -139,7 +146,7 @@ private function incrementDailyAttendeeStatistics(int $eventId, string $orderDat
if ($updated === 0) {
throw new EventStatisticsVersionMismatchException(
'Event daily statistics version mismatch. Expected version '
- . $eventDailyStatistic->getVersion() . ' but it was already updated.'
+ .$eventDailyStatistic->getVersion().' but it was already updated.'
);
}
@@ -153,4 +160,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..baca05e21c 100644
--- a/backend/app/Services/Domain/EventStatistics/EventStatisticsRefundService.php
+++ b/backend/app/Services/Domain/EventStatistics/EventStatisticsRefundService.php
@@ -5,22 +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 EventStatisticRepositoryInterface $eventStatisticsRepository,
private readonly EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository,
- private readonly LoggerInterface $logger,
- )
- {
- }
+ private readonly EventOccurrenceStatisticRepositoryInterface $eventOccurrenceStatisticRepository,
+ private readonly EventOccurrenceDailyStatisticRepositoryInterface $eventOccurrenceDailyStatisticRepository,
+ private readonly OrderRepositoryInterface $orderRepository,
+ private readonly LoggerInterface $logger,
+ ) {}
/**
* Update statistics when an order is refunded
@@ -29,6 +34,25 @@ public function updateForRefund(OrderDomainObject $order, MoneyValue $refundAmou
{
$this->updateAggregateStatisticsForRefund($order, $refundAmount);
$this->updateDailyStatisticsForRefund($order, $refundAmount);
+
+ $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();
+ $refundDate = now()->format('Y-m-d');
+
+ $this->updateOccurrenceStatisticsForRefund($itemsByOccurrence, $refundProportion);
+ $this->updateOccurrenceDailyStatisticsForRefund($orderWithItems->getEventId(), $itemsByOccurrence, $refundProportion, $refundDate);
}
/**
@@ -40,7 +64,7 @@ private function updateAggregateStatisticsForRefund(OrderDomainObject $order, Mo
'event_id' => $order->getEventId(),
]);
- if (!$eventStatistics) {
+ if (! $eventStatistics) {
throw new ResourceNotFoundException("Event statistics not found for event {$order->getEventId()}");
}
@@ -85,26 +109,31 @@ private function updateAggregateStatisticsForRefund(OrderDomainObject $order, Mo
*/
private function updateDailyStatisticsForRefund(OrderDomainObject $order, MoneyValue $refundAmount): void
{
- $orderDate = (new Carbon($order->getCreatedAt()))->format('Y-m-d');
+ $refundDate = now()->format('Y-m-d');
$eventDailyStatistic = $this->eventDailyStatisticRepository->findFirstWhere([
'event_id' => $order->getEventId(),
- 'date' => $orderDate,
+ 'date' => $refundDate,
]);
if ($eventDailyStatistic === null) {
- $this->logger->warning(
- 'Event daily statistics not found for refund',
- [
- 'event_id' => $order->getEventId(),
- 'date' => $orderDate,
- 'order_id' => $order->getId(),
- ]
- );
+ $this->eventDailyStatisticRepository->create([
+ 'event_id' => $order->getEventId(),
+ 'date' => $refundDate,
+ 'products_sold' => 0,
+ 'attendees_registered' => 0,
+ 'sales_total_gross' => -$refundAmount->toFloat(),
+ 'sales_total_before_additions' => 0,
+ 'total_tax' => 0,
+ 'total_fee' => 0,
+ 'total_refunded' => $refundAmount->toFloat(),
+ 'orders_created' => 0,
+ 'orders_cancelled' => 0,
+ ]);
+
return;
}
- // Calculate the proportion of the refund to the total order amount
$refundProportion = $refundAmount->toFloat() / $order->getTotalGross();
// Adjust the total_tax and total_fee based on the refund proportion
@@ -122,7 +151,7 @@ private function updateDailyStatisticsForRefund(OrderDomainObject $order, MoneyV
attributes: $updates,
where: [
'event_id' => $order->getEventId(),
- 'date' => $orderDate,
+ 'date' => $refundDate,
]
);
@@ -131,7 +160,7 @@ private function updateDailyStatisticsForRefund(OrderDomainObject $order, MoneyV
[
'event_id' => $order->getEventId(),
'order_id' => $order->getId(),
- 'date' => $orderDate,
+ 'date' => $refundDate,
'refund_amount' => $refundAmount->toFloat(),
'refund_proportion' => $refundProportion,
'original_total_gross' => $eventDailyStatistic->getSalesTotalGross(),
@@ -141,4 +170,111 @@ private function updateDailyStatisticsForRefund(OrderDomainObject $order, MoneyV
]
);
}
+
+ /**
+ * @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,
+ ]
+ );
+ }
+ }
+
+ /**
+ * @param array $itemsByOccurrence
+ */
+ private function updateOccurrenceDailyStatisticsForRefund(int $eventId, array $itemsByOccurrence, float $refundProportion, string $refundDate): 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));
+
+ $grossRefund = $occurrenceGross * $refundProportion;
+
+ $existing = $this->eventOccurrenceDailyStatisticRepository->findFirstWhere([
+ 'event_occurrence_id' => $occurrenceId,
+ 'date' => $refundDate,
+ ]);
+
+ if ($existing === null) {
+ $this->eventOccurrenceDailyStatisticRepository->create([
+ 'event_id' => $eventId,
+ 'event_occurrence_id' => $occurrenceId,
+ 'date' => $refundDate,
+ 'products_sold' => 0,
+ 'attendees_registered' => 0,
+ 'sales_total_gross' => 0,
+ 'sales_total_before_additions' => 0,
+ 'total_tax' => 0,
+ 'total_fee' => 0,
+ 'total_refunded' => $grossRefund,
+ 'orders_created' => 0,
+ 'orders_cancelled' => 0,
+ ]);
+
+ continue;
+ }
+
+ $grossDelta = $this->formatDelta($grossRefund);
+ $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' => $refundDate,
+ ]
+ );
+ }
+ }
+
+ 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/EventStatistics/Exception/EventStatisticsVersionMismatchException.php b/backend/app/Services/Domain/EventStatistics/Exception/EventStatisticsVersionMismatchException.php
index c3bd972b9d..4dd04cbeb5 100644
--- a/backend/app/Services/Domain/EventStatistics/Exception/EventStatisticsVersionMismatchException.php
+++ b/backend/app/Services/Domain/EventStatistics/Exception/EventStatisticsVersionMismatchException.php
@@ -4,7 +4,4 @@
use Exception;
-class EventStatisticsVersionMismatchException extends Exception
-{
-
-}
+class EventStatisticsVersionMismatchException extends Exception {}
diff --git a/backend/app/Services/Domain/Image/ImageUploadService.php b/backend/app/Services/Domain/Image/ImageUploadService.php
index c7fcf6f0f8..8131f3b53f 100644
--- a/backend/app/Services/Domain/Image/ImageUploadService.php
+++ b/backend/app/Services/Domain/Image/ImageUploadService.php
@@ -12,23 +12,21 @@
class ImageUploadService
{
public function __construct(
- private readonly ImageStorageService $imageStorageService,
+ private readonly ImageStorageService $imageStorageService,
private readonly ImageRepositoryInterface $imageRepository,
- private readonly ImageMetadataService $imageMetadataService,
- ) {
- }
+ private readonly ImageMetadataService $imageMetadataService,
+ ) {}
/**
* @throws CouldNotUploadImageException
*/
public function upload(
UploadedFile $image,
- int $entityId,
- string $entityType,
- string $imageType,
- int $accountId,
- ): ImageDomainObject
- {
+ int $entityId,
+ string $entityType,
+ string $imageType,
+ int $accountId,
+ ): ImageDomainObject {
$storedImage = $this->imageStorageService->store($image, $imageType);
$metadata = $this->imageMetadataService->extractMetadata($image);
diff --git a/backend/app/Services/Domain/Invoice/InvoiceCreateService.php b/backend/app/Services/Domain/Invoice/InvoiceCreateService.php
index d4486761da..81a845a6a3 100644
--- a/backend/app/Services/Domain/Invoice/InvoiceCreateService.php
+++ b/backend/app/Services/Domain/Invoice/InvoiceCreateService.php
@@ -7,7 +7,6 @@
use HiEvents\DomainObjects\InvoiceDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\DomainObjects\Status\InvoiceStatus;
-use HiEvents\Exceptions\ResourceConflictException;
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\InvoiceRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
@@ -15,15 +14,10 @@
class InvoiceCreateService
{
public function __construct(
- private readonly OrderRepositoryInterface $orderRepository,
+ private readonly OrderRepositoryInterface $orderRepository,
private readonly InvoiceRepositoryInterface $invoiceRepository,
- )
- {
- }
+ ) {}
- /**
- * @throws ResourceConflictException
- */
public function createInvoiceForOrder(int $orderId): InvoiceDomainObject
{
$existingInvoice = $this->invoiceRepository->findFirstWhere([
@@ -31,7 +25,7 @@ public function createInvoiceForOrder(int $orderId): InvoiceDomainObject
]);
if ($existingInvoice) {
- throw new ResourceConflictException(__('Invoice already exists'));
+ return $existingInvoice;
}
$order = $this->orderRepository
@@ -50,14 +44,14 @@ public function createInvoiceForOrder(int $orderId): InvoiceDomainObject
'order_id' => $orderId,
'account_id' => $event->getAccountId(),
'invoice_number' => $this->getLatestInvoiceNumber($event->getId(), $eventSettings),
- 'items' => collect($order->getOrderItems())->map(fn(OrderItemDomainObject $item) => $item->toArray())->toArray(),
+ 'items' => collect($order->getOrderItems())->map(fn (OrderItemDomainObject $item) => $item->toArray())->toArray(),
'taxes_and_fees' => $order->getTaxesAndFeesRollup(),
'issue_date' => now()->toDateString(),
'status' => $order->isOrderCompleted() ? InvoiceStatus::PAID->name : InvoiceStatus::UNPAID->name,
'total_amount' => $order->getTotalGross(),
'due_date' => $eventSettings->getInvoicePaymentTermsDays() !== null
? now()->addDays($eventSettings->getInvoicePaymentTermsDays())
- : null
+ : null,
]);
}
@@ -68,12 +62,12 @@ private function getLatestInvoiceNumber(int $eventId, EventSettingDomainObject $
$startNumber = $eventSettings->getInvoiceStartNumber() ?? 1;
$prefix = $eventSettings->getInvoicePrefix() ?? '';
- if (!$latestInvoice) {
- return $prefix . $startNumber;
+ if (! $latestInvoice) {
+ return $prefix.$startNumber;
}
- $nextInvoiceNumber = (int)preg_replace('/\D+/', '', $latestInvoice->getInvoiceNumber()) + 1;
+ $nextInvoiceNumber = (int) preg_replace('/\D+/', '', $latestInvoice->getInvoiceNumber()) + 1;
- return $prefix . $nextInvoiceNumber;
+ return $prefix.$nextInvoiceNumber;
}
}
diff --git a/backend/app/Services/Domain/Location/LocationDataSanitizer.php b/backend/app/Services/Domain/Location/LocationDataSanitizer.php
new file mode 100644
index 0000000000..702a71da54
--- /dev/null
+++ b/backend/app/Services/Domain/Location/LocationDataSanitizer.php
@@ -0,0 +1,51 @@
+ $value) {
+ if (is_string($value)) {
+ $address[$key] = $this->sanitizeText($value);
+ }
+ }
+
+ if (is_string($address['country'] ?? null)) {
+ $address['country'] = strtoupper($address['country']);
+ }
+
+ return $address;
+ }
+
+ public function cachedRawProviderResponse(?string $provider, ?string $providerPlaceId): ?array
+ {
+ if ($provider === null || $providerPlaceId === null) {
+ return null;
+ }
+
+ return $this->geoProvider->getCachedRawPlaceDetails($providerPlaceId);
+ }
+}
diff --git a/backend/app/Services/Domain/Location/LocationLockService.php b/backend/app/Services/Domain/Location/LocationLockService.php
new file mode 100644
index 0000000000..392b7d1e1d
--- /dev/null
+++ b/backend/app/Services/Domain/Location/LocationLockService.php
@@ -0,0 +1,34 @@
+databaseManager->statement(
+ 'SELECT pg_advisory_xact_lock_shared(?, ?)',
+ [self::LOCATIONS_LOCK_KEYSPACE, $locationId % self::MAX_LOCK_KEY],
+ );
+ }
+
+ public function acquireExclusiveTransactionLock(int $locationId): void
+ {
+ $this->databaseManager->statement(
+ 'SELECT pg_advisory_xact_lock(?, ?)',
+ [self::LOCATIONS_LOCK_KEYSPACE, $locationId % self::MAX_LOCK_KEY],
+ );
+ }
+}
diff --git a/backend/app/Services/Domain/Location/LocationOwnershipValidator.php b/backend/app/Services/Domain/Location/LocationOwnershipValidator.php
new file mode 100644
index 0000000000..06cff33218
--- /dev/null
+++ b/backend/app/Services/Domain/Location/LocationOwnershipValidator.php
@@ -0,0 +1,40 @@
+locationLockService->acquireSharedTransactionLock($locationId);
+
+ $location = $this->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..ab9c429eff 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,34 @@ 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);
}
+ 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 +228,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 +250,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..8697f19939 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;
@@ -22,26 +25,62 @@
class SendOrderDetailsService
{
public function __construct(
- private readonly EventRepositoryInterface $eventRepository,
- private readonly OrderRepositoryInterface $orderRepository,
- private readonly Mailer $mailer,
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly OrderRepositoryInterface $orderRepository,
+ private readonly Mailer $mailer,
private readonly SendAttendeeTicketService $sendAttendeeTicketService,
- private readonly MailBuilderService $mailBuilderService,
- )
- {
- }
+ private readonly MailBuilderService $mailBuilderService,
+ ) {}
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,19 +102,20 @@ public function sendOrderSummaryAndTicketEmails(OrderDomainObject $order): void
}
public function sendCustomerOrderSummary(
- OrderDomainObject $order,
- EventDomainObject $event,
- OrganizerDomainObject $organizer,
+ OrderDomainObject $order,
+ EventDomainObject $event,
+ OrganizerDomainObject $organizer,
EventSettingDomainObject $eventSettings,
- ?InvoiceDomainObject $invoice = null
- ): void
- {
+ ?InvoiceDomainObject $invoice = null,
+ ?EventOccurrenceDomainObject $occurrence = null,
+ ): void {
$mail = $this->mailBuilderService->buildOrderSummaryMail(
$order,
$event,
$eventSettings,
$organizer,
- $invoice
+ $invoice,
+ $occurrence ?? $this->resolvePrimaryOccurrence($order),
);
$this->mailer
@@ -84,6 +124,21 @@ public function sendCustomerOrderSummary(
->send($mail);
}
+ 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 = [];
@@ -114,7 +169,7 @@ private function sendOrderSummaryEmails(OrderDomainObject $order, EventDomainObj
invoice: $order->getLatestInvoice(),
);
- if ($order->getIsManuallyCreated() || !$event->getEventSettings()->getNotifyOrganizerOfNewOrders()) {
+ if ($order->getIsManuallyCreated() || ! $event->getEventSettings()->getNotifyOrganizerOfNewOrders()) {
return;
}
diff --git a/backend/app/Services/Domain/Message/DTO/MessagingEligibilityFailureDTO.php b/backend/app/Services/Domain/Message/DTO/MessagingEligibilityFailureDTO.php
index 1a55467674..ca62246dd9 100644
--- a/backend/app/Services/Domain/Message/DTO/MessagingEligibilityFailureDTO.php
+++ b/backend/app/Services/Domain/Message/DTO/MessagingEligibilityFailureDTO.php
@@ -8,22 +8,19 @@
class MessagingEligibilityFailureDTO extends BaseDataObject
{
/**
- * @param int $accountId
- * @param int $eventId
- * @param MessagingEligibilityFailureEnum[] $failures
+ * @param MessagingEligibilityFailureEnum[] $failures
*/
public function __construct(
public readonly int $accountId,
public readonly int $eventId,
public readonly array $failures,
- ) {
- }
+ ) {}
/**
* @return string[]
*/
public function getFailureValues(): array
{
- return array_map(fn(MessagingEligibilityFailureEnum $failure) => $failure->value, $this->failures);
+ return array_map(fn (MessagingEligibilityFailureEnum $failure) => $failure->value, $this->failures);
}
}
diff --git a/backend/app/Services/Domain/Message/DTO/MessagingTierViolationDTO.php b/backend/app/Services/Domain/Message/DTO/MessagingTierViolationDTO.php
index 9929e1095a..32bb04f3e2 100644
--- a/backend/app/Services/Domain/Message/DTO/MessagingTierViolationDTO.php
+++ b/backend/app/Services/Domain/Message/DTO/MessagingTierViolationDTO.php
@@ -8,16 +8,13 @@
class MessagingTierViolationDTO extends BaseDataObject
{
/**
- * @param int $accountId
- * @param string $tierName
- * @param MessagingTierViolationEnum[] $violations
+ * @param MessagingTierViolationEnum[] $violations
*/
public function __construct(
public readonly int $accountId,
public readonly string $tierName,
public readonly array $violations,
- ) {
- }
+ ) {}
public function getFirstViolationMessage(): string
{
diff --git a/backend/app/Services/Domain/Message/MessageDispatchService.php b/backend/app/Services/Domain/Message/MessageDispatchService.php
index 006e666efa..409c36feac 100644
--- a/backend/app/Services/Domain/Message/MessageDispatchService.php
+++ b/backend/app/Services/Domain/Message/MessageDispatchService.php
@@ -8,6 +8,7 @@
use HiEvents\DomainObjects\MessageDomainObject;
use HiEvents\DomainObjects\Status\MessageStatus;
use HiEvents\Jobs\Event\SendMessagesJob;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\MessageRepositoryInterface;
use HiEvents\Services\Application\Handlers\Message\DTO\SendMessageDTO;
use Illuminate\Support\Facades\Log;
@@ -17,22 +18,34 @@ class MessageDispatchService
{
public function __construct(
private readonly MessageRepositoryInterface $messageRepository,
- )
- {
- }
+ private readonly EventOccurrenceRepositoryInterface $eventOccurrenceRepository,
+ ) {}
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;
+ }
+
+ if ($this->isForCancelledOccurrence($message)) {
+ Log::info('Message is scoped to a cancelled or deleted occurrence, marking as CANCELLED', [
+ 'message_id' => $message->getId(),
+ 'event_occurrence_id' => $message->getEventOccurrenceId(),
+ ]);
+ $this->messageRepository->updateFromArray($message->getId(), [
+ 'status' => MessageStatus::CANCELLED->name,
+ ]);
+
return;
}
@@ -45,6 +58,7 @@ public function dispatchMessage(MessageDomainObject $message, MessageStatus $exp
Log::info('Message status changed before dispatch, skipping', [
'message_id' => $message->getId(),
]);
+
return;
}
@@ -63,6 +77,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', [
@@ -76,4 +92,45 @@ public function dispatchMessage(MessageDomainObject $message, MessageStatus $exp
throw $e;
}
}
+
+ private function isForCancelledOccurrence(MessageDomainObject $message): bool
+ {
+ $occurrenceId = $message->getEventOccurrenceId();
+
+ if ($occurrenceId !== null) {
+ $occurrence = $this->eventOccurrenceRepository->findFirstWhere(['id' => $occurrenceId]);
+
+ return $occurrence === null || $occurrence->isCancelled();
+ }
+
+ $occurrenceIds = $this->getScheduledOccurrenceIds($message);
+
+ if ($occurrenceIds === []) {
+ return false;
+ }
+
+ $liveOccurrences = $this->eventOccurrenceRepository->findWhereIn('id', $occurrenceIds);
+
+ foreach ($liveOccurrences as $occurrence) {
+ if (! $occurrence->isCancelled()) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private function getScheduledOccurrenceIds(MessageDomainObject $message): array
+ {
+ $sendData = $message->getSendData();
+ $sendDataArray = is_string($sendData) ? json_decode($sendData, true) : $sendData;
+
+ if (! is_array($sendDataArray)) {
+ return [];
+ }
+
+ $occurrenceIds = $sendDataArray['event_occurrence_ids'] ?? null;
+
+ return is_array($occurrenceIds) ? array_values(array_filter($occurrenceIds)) : [];
+ }
}
diff --git a/backend/app/Services/Domain/Message/MessagingEligibilityService.php b/backend/app/Services/Domain/Message/MessagingEligibilityService.php
index 5a5b1df3bf..c403510c33 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,12 @@ 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,17 +40,22 @@ 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;
}
- if (!$this->hasPaidOrder($accountId)) {
+ if (! $this->hasPaidOrder($accountId)) {
$failures[] = MessagingEligibilityFailureEnum::NO_PAID_ORDERS;
}
- $event = $this->eventRepository->findById($eventId);
if ($this->isEventTooNew($event->getCreatedAt())) {
$failures[] = MessagingEligibilityFailureEnum::EVENT_TOO_NEW;
}
@@ -83,7 +87,7 @@ public function checkTierLimits(int $accountId, int $recipientCount, string $mes
$violations[] = MessagingTierViolationEnum::RECIPIENT_LIMIT_EXCEEDED;
}
- if (!$tier->getLinksAllowed() && $this->containsLinks($messageContent)) {
+ if (! $tier->getLinksAllowed() && $this->containsLinks($messageContent)) {
$violations[] = MessagingTierViolationEnum::LINKS_NOT_ALLOWED;
}
diff --git a/backend/app/Services/Domain/Order/DTO/ApplicationFeeValuesDTO.php b/backend/app/Services/Domain/Order/DTO/ApplicationFeeValuesDTO.php
index daff42e679..0d1a0b150c 100644
--- a/backend/app/Services/Domain/Order/DTO/ApplicationFeeValuesDTO.php
+++ b/backend/app/Services/Domain/Order/DTO/ApplicationFeeValuesDTO.php
@@ -8,11 +8,9 @@
class ApplicationFeeValuesDTO extends BaseDataObject
{
public function __construct(
- public MoneyValue $grossApplicationFee,
- public MoneyValue $netApplicationFee,
- public ?float $applicationFeeVatRate = null,
+ public MoneyValue $grossApplicationFee,
+ public MoneyValue $netApplicationFee,
+ public ?float $applicationFeeVatRate = null,
public ?MoneyValue $applicationFeeVatAmount = null,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Domain/Order/DTO/InvoicePdfResponseDTO.php b/backend/app/Services/Domain/Order/DTO/InvoicePdfResponseDTO.php
index 0a8d95a672..83129ecea5 100644
--- a/backend/app/Services/Domain/Order/DTO/InvoicePdfResponseDTO.php
+++ b/backend/app/Services/Domain/Order/DTO/InvoicePdfResponseDTO.php
@@ -7,9 +7,7 @@
class InvoicePdfResponseDTO
{
public function __construct(
- public readonly PDF $pdf,
+ public readonly PDF $pdf,
public readonly string $filename,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Domain/Order/DTO/OrderItemPricingLineDTO.php b/backend/app/Services/Domain/Order/DTO/OrderItemPricingLineDTO.php
new file mode 100644
index 0000000000..203eb50e8d
--- /dev/null
+++ b/backend/app/Services/Domain/Order/DTO/OrderItemPricingLineDTO.php
@@ -0,0 +1,18 @@
+databaseManager->transaction(function () use ($id, $firstName, $lastName, $email, $notes, $eventId) {
$this->orderRepository->updateWhere(
attributes: array_filter([
diff --git a/backend/app/Services/Domain/Order/GenerateOrderInvoicePDFService.php b/backend/app/Services/Domain/Order/GenerateOrderInvoicePDFService.php
index 2073c609a6..27e706c20f 100644
--- a/backend/app/Services/Domain/Order/GenerateOrderInvoicePDFService.php
+++ b/backend/app/Services/Domain/Order/GenerateOrderInvoicePDFService.php
@@ -15,11 +15,9 @@
class GenerateOrderInvoicePDFService
{
public function __construct(
- private readonly OrderRepositoryInterface $orderRepository,
+ private readonly OrderRepositoryInterface $orderRepository,
private readonly InvoiceRepositoryInterface $invoiceRepository,
- )
- {
- }
+ ) {}
public function generatePdfFromOrderShortId(string $orderShortId, int $eventId): InvoicePdfResponseDTO
{
@@ -46,13 +44,13 @@ private function generatePdf(array $whereCriteria): InvoicePdfResponseDTO
], name: 'event'))
->findFirstWhere($whereCriteria);
- if (!$order) {
+ if (! $order) {
throw new ResourceNotFoundException(__('Order not found'));
}
$invoice = $this->invoiceRepository->findLatestInvoiceForOrder($order->getId());
- if (!$invoice) {
+ if (! $invoice) {
throw new ResourceNotFoundException(__('Invoice not found'));
}
@@ -64,7 +62,7 @@ private function generatePdf(array $whereCriteria): InvoicePdfResponseDTO
'eventSettings' => $order->getEvent()->getEventSettings(),
'invoice' => $invoice,
]),
- filename: $invoice->getInvoiceNumber() . '.pdf'
+ filename: $invoice->getInvoiceNumber().'.pdf'
);
}
}
diff --git a/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php b/backend/app/Services/Domain/Order/MarkOrderAsPaidService.php
index e04a5f9187..044f2f56c1 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;
@@ -37,19 +39,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 OccurrenceStatusValidator $occurrenceStatusValidator,
+ ) {}
/**
* @throws ResourceConflictException|Throwable
@@ -57,8 +58,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 +73,41 @@ 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'));
}
+ $this->occurrenceStatusValidator->assertOrderOccurrencesArePurchasable($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
@@ -163,24 +186,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/OccurrenceStatusValidator.php b/backend/app/Services/Domain/Order/OccurrenceStatusValidator.php
new file mode 100644
index 0000000000..636282b85e
--- /dev/null
+++ b/backend/app/Services/Domain/Order/OccurrenceStatusValidator.php
@@ -0,0 +1,63 @@
+resolveOccurrences($order) 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'));
+ }
+ }
+ }
+
+ public function findBlockingOccurrence(OrderDomainObject $order): ?EventOccurrenceDomainObject
+ {
+ foreach ($this->resolveOccurrences($order) as $occurrence) {
+ if ($occurrence->isCancelled() || $occurrence->isPast()) {
+ return $occurrence;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @return iterable
+ */
+ private function resolveOccurrences(OrderDomainObject $order): iterable
+ {
+ $occurrenceIds = $order->getOrderItems()
+ ?->map(fn (OrderItemDomainObject $item) => $item->getEventOccurrenceId())
+ ->filter()
+ ->unique()
+ ->values();
+
+ if ($occurrenceIds === null || $occurrenceIds->isEmpty()) {
+ return [];
+ }
+
+ return $this->occurrenceRepository->findWhereIn('id', $occurrenceIds->toArray());
+ }
+}
diff --git a/backend/app/Services/Domain/Order/OfflinePaymentInstructionsRenderService.php b/backend/app/Services/Domain/Order/OfflinePaymentInstructionsRenderService.php
index 33cea1191e..8e30b748da 100644
--- a/backend/app/Services/Domain/Order/OfflinePaymentInstructionsRenderService.php
+++ b/backend/app/Services/Domain/Order/OfflinePaymentInstructionsRenderService.php
@@ -15,9 +15,7 @@ class OfflinePaymentInstructionsRenderService
{
public function __construct(
private readonly EmailTokenContextBuilder $tokenContextBuilder,
- )
- {
- }
+ ) {}
public function renderForOrder(OrderDomainObject $order): void
{
@@ -25,7 +23,7 @@ public function renderForOrder(OrderDomainObject $order): void
$eventSettings = $event?->getEventSettings();
$organizer = $event?->getOrganizer();
- if (!$event || !$eventSettings || !$organizer) {
+ if (! $event || ! $eventSettings || ! $organizer) {
return;
}
@@ -33,13 +31,12 @@ public function renderForOrder(OrderDomainObject $order): void
}
public function render(
- OrderDomainObject $order,
- EventDomainObject $event,
- OrganizerDomainObject $organizer,
+ OrderDomainObject $order,
+ EventDomainObject $event,
+ OrganizerDomainObject $organizer,
EventSettingDomainObject $eventSettings,
- ): void
- {
- if (!$eventSettings->getOfflinePaymentInstructions()) {
+ ): void {
+ if (! $eventSettings->getOfflinePaymentInstructions()) {
return;
}
diff --git a/backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php b/backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php
index 69c81ff8b7..c9341174c3 100644
--- a/backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php
+++ b/backend/app/Services/Domain/Order/OrderApplicationFeeCalculationService.php
@@ -3,9 +3,9 @@
namespace HiEvents\Services\Domain\Order;
use Brick\Money\Currency;
-use HiEvents\DomainObjects\AccountConfigurationDomainObject;
-use HiEvents\DomainObjects\AccountVatSettingDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
+use HiEvents\DomainObjects\OrganizerConfigurationDomainObject;
+use HiEvents\DomainObjects\OrganizerVatSettingDomainObject;
use HiEvents\Services\Domain\Order\DTO\ApplicationFeeValuesDTO;
use HiEvents\Services\Domain\Order\Vat\VatRateDeterminationService;
use HiEvents\Services\Infrastructure\CurrencyConversion\CurrencyConversionClientInterface;
@@ -15,35 +15,32 @@
class OrderApplicationFeeCalculationService
{
public function __construct(
- private readonly Repository $config,
+ private readonly Repository $config,
private readonly CurrencyConversionClientInterface $currencyConversionClient,
- private readonly VatRateDeterminationService $vatRateDeterminationService,
- )
- {
- }
+ private readonly VatRateDeterminationService $vatRateDeterminationService,
+ ) {}
public function calculateApplicationFee(
- AccountConfigurationDomainObject $accountConfiguration,
- OrderDomainObject $order,
- ?AccountVatSettingDomainObject $vatSettings = null
- ): ?ApplicationFeeValuesDTO
- {
+ OrganizerConfigurationDomainObject $configuration,
+ OrderDomainObject $order,
+ ?OrganizerVatSettingDomainObject $vatSettings = null
+ ): ?ApplicationFeeValuesDTO {
$currency = $order->getCurrency();
$quantityPurchased = $this->getChargeableQuantityPurchased($order);
- if (!$this->config->get('app.saas_mode_enabled')) {
+ if (! $this->config->get('app.saas_mode_enabled')) {
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),
currency: $currency
);
- if (!$vatSettings) {
+ if (! $vatSettings) {
return new ApplicationFeeValuesDTO(
grossApplicationFee: $netApplicationFee,
netApplicationFee: $netApplicationFee,
@@ -58,20 +55,19 @@ public function calculateApplicationFee(
}
private function getConvertedFixedFee(
- AccountConfigurationDomainObject $accountConfiguration,
- string $currency
- ): MoneyValue
- {
- $baseCurrency = $accountConfiguration->getApplicationFeeCurrency();
+ OrganizerConfigurationDomainObject $configuration,
+ string $currency
+ ): MoneyValue {
+ $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,11 +94,10 @@ private function getChargeableQuantityPurchased(OrderDomainObject $order): int
* - Gross charged: £0.72 (£0.60 + £0.12)
*/
private function calculateFeeWithVat(
- AccountVatSettingDomainObject $vatSettings,
- MoneyValue $netApplicationFee,
- string $currency,
- ): ApplicationFeeValuesDTO
- {
+ OrganizerVatSettingDomainObject $vatSettings,
+ MoneyValue $netApplicationFee,
+ string $currency,
+ ): ApplicationFeeValuesDTO {
$vatRate = $this->vatRateDeterminationService->determineVatRatePercentage($vatSettings);
if ($vatRate <= 0) {
diff --git a/backend/app/Services/Domain/Order/OrderApplicationFeeService.php b/backend/app/Services/Domain/Order/OrderApplicationFeeService.php
index 01bea9fd8b..582ca57133 100644
--- a/backend/app/Services/Domain/Order/OrderApplicationFeeService.php
+++ b/backend/app/Services/Domain/Order/OrderApplicationFeeService.php
@@ -12,18 +12,15 @@ class OrderApplicationFeeService
{
public function __construct(
private readonly OrderApplicationFeeRepositoryInterface $orderApplicationFeeRepository,
- )
- {
- }
+ ) {}
public function createOrderApplicationFee(
- int $orderId,
- int $applicationFeeAmountMinorUnit,
+ int $orderId,
+ int $applicationFeeAmountMinorUnit,
OrderApplicationFeeStatus $orderApplicationFeeStatus,
- PaymentProviders $paymentMethod,
- string $currency,
- ): void
- {
+ PaymentProviders $paymentMethod,
+ string $currency,
+ ): void {
$isZeroDecimalCurrency = Currency::isZeroDecimalCurrency($currency);
$applicationFeeAmount = $isZeroDecimalCurrency
@@ -35,7 +32,7 @@ public function createOrderApplicationFee(
OrderApplicationFeeDomainObjectAbstract::AMOUNT => $applicationFeeAmount,
OrderApplicationFeeDomainObjectAbstract::STATUS => $orderApplicationFeeStatus->value,
OrderApplicationFeeDomainObjectAbstract::PAYMENT_METHOD => $paymentMethod->value,
- ORderApplicationFeeDomainObjectAbstract::CURRENCY => $currency,
+ OrderApplicationFeeDomainObjectAbstract::CURRENCY => $currency,
OrderApplicationFeeDomainObjectAbstract::PAID_AT => $orderApplicationFeeStatus->value === OrderApplicationFeeStatus::PAID->value
? now()->toDateTimeString()
: null,
diff --git a/backend/app/Services/Domain/Order/OrderCancelService.php b/backend/app/Services/Domain/Order/OrderCancelService.php
index 0c0c904431..2dcce56aa1 100644
--- a/backend/app/Services/Domain/Order/OrderCancelService.php
+++ b/backend/app/Services/Domain/Order/OrderCancelService.php
@@ -3,23 +3,26 @@
namespace HiEvents\Services\Domain\Order;
use HiEvents\DomainObjects\AttendeeDomainObject;
+use HiEvents\DomainObjects\Enums\CapacityChangeDirection;
+use HiEvents\DomainObjects\Enums\ProductType;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
+use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\DomainObjects\Status\AttendeeStatus;
use HiEvents\DomainObjects\Status\OrderStatus;
-use HiEvents\DomainObjects\Enums\CapacityChangeDirection;
use HiEvents\Events\CapacityChangedEvent;
use HiEvents\Mail\Order\OrderCancelled;
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
+use HiEvents\Services\Domain\EventStatistics\EventStatisticsCancellationService;
use HiEvents\Services\Domain\Product\ProductQuantityUpdateService;
+use HiEvents\Services\Domain\Waitlist\RevertWaitlistOffersForCancelledOrderService;
use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService;
use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType;
use HiEvents\Services\Infrastructure\DomainEvents\Events\OrderEvent;
-use HiEvents\Services\Domain\EventStatistics\EventStatisticsCancellationService;
use Illuminate\Contracts\Mail\Mailer;
use Illuminate\Database\DatabaseManager;
use Throwable;
@@ -27,17 +30,16 @@
class OrderCancelService
{
public function __construct(
- private readonly Mailer $mailer,
- private readonly AttendeeRepositoryInterface $attendeeRepository,
- private readonly EventRepositoryInterface $eventRepository,
- private readonly OrderRepositoryInterface $orderRepository,
- private readonly DatabaseManager $databaseManager,
- private readonly ProductQuantityUpdateService $productQuantityService,
- private readonly DomainEventDispatcherService $domainEventDispatcherService,
- private readonly EventStatisticsCancellationService $eventStatisticsCancellationService,
- )
- {
- }
+ private readonly Mailer $mailer,
+ private readonly AttendeeRepositoryInterface $attendeeRepository,
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly OrderRepositoryInterface $orderRepository,
+ private readonly DatabaseManager $databaseManager,
+ private readonly ProductQuantityUpdateService $productQuantityService,
+ private readonly DomainEventDispatcherService $domainEventDispatcherService,
+ private readonly EventStatisticsCancellationService $eventStatisticsCancellationService,
+ private readonly RevertWaitlistOffersForCancelledOrderService $revertWaitlistOffersService,
+ ) {}
/**
* @throws Throwable
@@ -45,27 +47,14 @@ public function __construct(
public function cancelOrder(OrderDomainObject $order): void
{
$this->databaseManager->transaction(function () use ($order) {
- // Order of operations matters here. We must decrement the stats first.
$this->eventStatisticsCancellationService->decrementForCancelledOrder($order);
$this->adjustProductQuantities($order);
$this->cancelAttendees($order);
$this->updateOrderStatus($order);
+ $capacityEvents = $this->revertWaitlistOffersService->revertOffersForOrder($order->getId());
- $event = $this->eventRepository
- ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer'))
- ->loadRelation(EventSettingDomainObject::class)
- ->findById($order->getEventId());
-
- $this->mailer
- ->to($order->getEmail())
- ->locale($order->getLocale())
- ->send(new OrderCancelled(
- order: $order,
- event: $event,
- organizer: $event->getOrganizer(),
- eventSettings: $event->getEventSettings(),
- ));
+ $this->sendOrderCancelledEmail($order);
$this->domainEventDispatcherService->dispatch(
new OrderEvent(
@@ -75,9 +64,37 @@ public function cancelOrder(OrderDomainObject $order): void
);
$this->dispatchCapacityChangedEvents($order);
+
+ $this->databaseManager->connection()->afterCommit(static function () use ($capacityEvents) {
+ foreach ($capacityEvents as $capacityEvent) {
+ event($capacityEvent);
+ }
+ });
});
}
+ private function sendOrderCancelledEmail(OrderDomainObject $order): void
+ {
+ if ($order->getEmail() === null) {
+ return;
+ }
+
+ $event = $this->eventRepository
+ ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer'))
+ ->loadRelation(EventSettingDomainObject::class)
+ ->findById($order->getEventId());
+
+ $this->mailer
+ ->to($order->getEmail())
+ ->locale($order->getLocale())
+ ->send(new OrderCancelled(
+ order: $order,
+ event: $event,
+ organizer: $event->getOrganizer(),
+ eventSettings: $event->getEventSettings(),
+ ));
+ }
+
private function cancelAttendees(OrderDomainObject $order): void
{
$this->attendeeRepository->updateWhere(
@@ -103,11 +120,41 @@ private function adjustProductQuantities(OrderDomainObject $order): void
return $attendee->getStatus() === AttendeeStatus::ACTIVE->name;
});
- $productIdCountMap = $attendees
- ->map(fn(AttendeeDomainObject $attendee) => $attendee->getProductPriceId())->countBy();
+ $groupedCounts = $attendees
+ ->map(fn (AttendeeDomainObject $attendee) => $attendee->getProductPriceId().'_'.$attendee->getEventOccurrenceId())
+ ->countBy();
- foreach ($productIdCountMap as $productPriceId => $count) {
- $this->productQuantityService->decreaseQuantitySold($productPriceId, $count);
+ foreach ($groupedCounts as $compositeKey => $count) {
+ [$productPriceId, $eventOccurrenceId] = explode('_', (string) $compositeKey);
+ $this->productQuantityService->decreaseQuantitySold(
+ (int) $productPriceId,
+ $count,
+ $eventOccurrenceId ? (int) $eventOccurrenceId : null,
+ );
+ }
+
+ $this->restoreNonTicketQuantities($order);
+ }
+
+ private function restoreNonTicketQuantities(OrderDomainObject $order): void
+ {
+ if (! $order->isOrderCompleted() && ! $order->isOrderAwaitingOfflinePayment()) {
+ return;
+ }
+
+ $orderWithItems = $this->orderRepository
+ ->loadRelation(OrderItemDomainObject::class)
+ ->findById($order->getId());
+
+ foreach ($orderWithItems->getOrderItems() ?? [] as $orderItem) {
+ if ($orderItem->getProductType() === ProductType::TICKET->name) {
+ continue;
+ }
+
+ $this->productQuantityService->decreaseQuantitySold(
+ $orderItem->getProductPriceId(),
+ $orderItem->getQuantity(),
+ );
}
}
@@ -129,15 +176,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 b73034b9db..98936ee67c 100644
--- a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php
+++ b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php
@@ -5,18 +5,25 @@
use Exception;
use HiEvents\DomainObjects\CapacityAssignmentDomainObject;
use HiEvents\DomainObjects\Enums\ProductPriceType;
+use HiEvents\DomainObjects\Enums\ProductType;
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\DomainObjects\PromoCodeDomainObject;
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;
+use HiEvents\Services\Domain\Product\ProductPriceService;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
@@ -27,25 +34,28 @@ class OrderCreateRequestValidationService
private AvailableProductQuantitiesResponseDTO $availableProductQuantities;
public function __construct(
- readonly private ProductRepositoryInterface $productRepository,
- readonly private PromoCodeRepositoryInterface $promoCodeRepository,
- readonly private EventRepositoryInterface $eventRepository,
- readonly private AvailableProductQuantitiesFetchService $fetchAvailableProductQuantitiesService,
- )
- {
- }
+ private readonly ProductRepositoryInterface $productRepository,
+ private readonly PromoCodeRepositoryInterface $promoCodeRepository,
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly EventOccurrenceRepositoryInterface $occurrenceRepository,
+ private readonly AvailableProductQuantitiesFetchService $fetchAvailableProductQuantitiesService,
+ private readonly OccurrencePurchaseEligibilityService $occurrenceEligibilityService,
+ private readonly ProductPriceService $productPriceService,
+ ) {}
/**
* @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);
$promoCode = $this->validatePromoCode($eventId, $data);
$this->validateProductSelection($data);
+ $this->validateOccurrence($eventId, $data);
$this->availableProductQuantities = $this->fetchAvailableProductQuantitiesService
->getAvailableProductQuantities(
@@ -53,8 +63,147 @@ public function validateRequestData(int $eventId, array $data = []): void
ignoreCache: true,
);
- $this->validateOverallCapacity($data);
- $this->validateProductDetails($event, $data, $promoCode);
+ $this->validateOverallCapacity($event, $data);
+
+ $this->validateProductDetailsPerOccurrence($event, $data, $promoCode);
+
+ 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();
+ }
+
+ private function validateProductDetailsPerOccurrence(EventDomainObject $event, array $data, ?PromoCodeDomainObject $promoCode): 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,
+ );
+
+ $occurrenceRequestedQuantities = $this->sumRequestedQuantities($products->all());
+
+ 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,
+ $promoCode,
+ $occurrenceRequestedQuantities,
+ );
+ }
+ }
+ } finally {
+ $this->availableProductQuantities = $eventWideAvailability;
+ }
+
+ if ($productsByOccurrence->count() > 1) {
+ $this->validateRequestedQuantitiesAcrossOccurrences($data);
+ }
+ }
+
+ /**
+ * @return array>
+ */
+ private function sumRequestedQuantities(array $productLines): array
+ {
+ $requestedQuantities = [];
+ foreach ($productLines as $line) {
+ foreach ($line['quantities'] as $quantity) {
+ if ($quantity['quantity'] <= 0) {
+ continue;
+ }
+
+ $requestedQuantities[$line['product_id']][$quantity['price_id']] =
+ ($requestedQuantities[$line['product_id']][$quantity['price_id']] ?? 0) + $quantity['quantity'];
+ }
+ }
+
+ return $requestedQuantities;
+ }
+
+ /**
+ * @throws ValidationException
+ */
+ private function validateRequestedQuantitiesAcrossOccurrences(array $data): void
+ {
+ $requestedQuantities = $this->sumRequestedQuantities($data['products']);
+ $products = $this->getProducts($data);
+ $productLines = collect($data['products']);
+
+ foreach ($requestedQuantities as $productId => $priceQuantities) {
+ $product = $products->first(fn (ProductDomainObject $p) => $p->getId() === $productId);
+ $productIndex = $productLines->search(fn ($line) => (int) $line['product_id'] === $productId);
+
+ $this->validateProductPricesQuantity(
+ quantities: collect($priceQuantities)
+ ->map(fn ($quantity, $priceId) => ['price_id' => $priceId, 'quantity' => $quantity])
+ ->values()
+ ->all(),
+ product: $product,
+ productIndex: is_int($productIndex) ? $productIndex : 0,
+ requestedQuantities: $requestedQuantities,
+ );
+ }
}
/**
@@ -62,7 +211,7 @@ public function validateRequestData(int $eventId, array $data = []): void
*/
private function validatePromoCode(int $eventId, array $data): ?PromoCodeDomainObject
{
- if (!isset($data['promo_code'])) {
+ if (! isset($data['promo_code'])) {
return null;
}
@@ -71,7 +220,7 @@ private function validatePromoCode(int $eventId, array $data): ?PromoCodeDomainO
PromoCodeDomainObjectAbstract::EVENT_ID => $eventId,
]);
- if (!$promoCode) {
+ if (! $promoCode) {
throw ValidationException::withMessages([
'promo_code' => __('This promo code is invalid'),
]);
@@ -88,6 +237,7 @@ 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',
'products.*.quantities.*.price_id' => 'required|integer',
@@ -105,41 +255,74 @@ 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');
+ $ticketProductIds = $this->getTicketProductIds($data);
+
+ foreach ($productsByOccurrence as $occurrenceId => $products) {
+ if ($occurrenceId === null || $occurrenceId === '') {
+ throw ValidationException::withMessages([
+ 'event_occurrence_id' => __('An event occurrence must be specified'),
+ ]);
+ }
+
+ $totalQuantityRequested = (int) $products
+ ->filter(fn ($product) => in_array((int) $product['product_id'], $ticketProductIds, true))
+ ->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,
+ );
+ }
+ }
+
+ /**
+ * @return int[]
+ */
+ private function getTicketProductIds(array $data): array
+ {
+ return $this->getProducts($data)
+ ->filter(fn (ProductDomainObject $product) => $product->getProductType() === ProductType::TICKET->name)
+ ->map(fn (ProductDomainObject $product) => $product->getId())
+ ->values()
+ ->all();
}
/**
- * @throws ValidationException
* @throws Exception
*/
- private function validateProductDetails(EventDomainObject $event, array $data, ?PromoCodeDomainObject $promoCode): 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, $promoCode);
- }
+ return $this->productRepository
+ ->loadRelation(ProductPriceDomainObject::class)
+ ->findWhereIn('id', $productIds->toArray());
}
/**
* @throws ValidationException
*/
- private function validateSingleProductDetails(EventDomainObject $event, int $productIndex, array $productAndQuantities, $products, ?PromoCodeDomainObject $promoCode): void
+ private function validateSingleProductDetails(EventDomainObject $event, int $productIndex, array $productAndQuantities, $products, ?PromoCodeDomainObject $promoCode, array $requestedQuantities): void
{
$productId = $productAndQuantities['product_id'];
$totalQuantity = collect($productAndQuantities['quantities'])->sum('quantity');
@@ -149,8 +332,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));
}
@@ -168,7 +351,8 @@ private function validateSingleProductDetails(EventDomainObject $event, int $pro
$this->validateProductQuantity(
productIndex: $productIndex,
productAndQuantities: $productAndQuantities,
- product: $product
+ product: $product,
+ requestedQuantities: $requestedQuantities,
);
$this->validateProductTypeAndPrice(
@@ -191,40 +375,57 @@ private function validateSingleProductDetails(EventDomainObject $event, int $pro
);
}
+ /**
+ * @throws NotFoundHttpException
+ */
+ private function validateProductVisibility(ProductDomainObject $product, ?PromoCodeDomainObject $promoCode): void
+ {
+ if ($product->getIsHidden()) {
+ throw new NotFoundHttpException(sprintf('Product ID %d not found', $product->getId()));
+ }
+
+ if ($product->getIsHiddenWithoutPromoCode()
+ && ! ($promoCode && $promoCode->appliesToProduct($product))) {
+ throw new NotFoundHttpException(sprintf('Product ID %d not found', $product->getId()));
+ }
+ }
+
/**
* @throws ValidationException
*/
- private function validateProductQuantity(int $productIndex, array $productAndQuantities, ProductDomainObject $product): void
+ private function validateProductQuantity(int $productIndex, array $productAndQuantities, ProductDomainObject $product, array $requestedQuantities): void
{
- $totalQuantity = collect($productAndQuantities['quantities'])->sum('quantity');
- $maxPerOrder = (int)$product->getMaxPerOrder() ?: 100;
+ $totalQuantity = isset($requestedQuantities[$product->getId()])
+ ? array_sum($requestedQuantities[$product->getId()])
+ : (int) collect($productAndQuantities['quantities'])->sum('quantity');
+ $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,
+ $minPerOrder = min((int) $product->getMinPerOrder() ?: 1,
$capacityMaximum ?: $maxPerOrder,
$productAvailableQuantity ?: $maxPerOrder);
$this->validateProductPricesQuantity(
quantities: $productAndQuantities['quantities'],
product: $product,
- productIndex: $productIndex
+ productIndex: $productIndex,
+ requestedQuantities: $requestedQuantities,
);
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(),
]),
@@ -233,7 +434,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(),
]),
@@ -248,39 +449,27 @@ private function validateProductEvent(EventDomainObject $event, int $productId,
}
}
- /**
- * Products the organiser has hidden must not be purchasable through the public checkout, even when the
- * product ID is known. Products hidden behind a promo code are only purchasable when a valid promo code
- * that applies to the product is supplied. This mirrors the display filtering in ProductFilterService.
- */
- private function validateProductVisibility(ProductDomainObject $product, ?PromoCodeDomainObject $promoCode): void
- {
- if ($product->getIsHidden()) {
- throw new NotFoundHttpException(sprintf('Product ID %d not found', $product->getId()));
- }
-
- if ($product->getIsHiddenWithoutPromoCode()
- && !($promoCode && $promoCode->appliesToProduct($product))) {
- throw new NotFoundHttpException(sprintf('Product ID %d not found', $product->getId()));
- }
- }
-
/**
* @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());
+ $occurrenceId = $productAndQuantities['event_occurrence_id'] ?? null;
+ $minimumPrice = $this->productPriceService->getDonationMinimumPrice(
+ product: $product,
+ priceId: (int) $productAndQuantities['quantities'][0]['price_id'],
+ eventOccurrenceId: $occurrenceId ? (int) $occurrenceId : null,
+ );
+ if ($price < $minimumPrice) {
+ $formattedPrice = Currency::format($minimumPrice, $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]),
]);
}
}
@@ -293,7 +482,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(),
]),
@@ -312,27 +501,28 @@ 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),
]);
}
$productPrices = $product->getProductPrices();
- $validPriceIds = $productPrices?->map(fn(ProductPriceDomainObject $price) => $price->getId());
- if (!in_array($priceId, $validPriceIds->toArray(), true)) {
+ $validPriceIds = $productPrices?->map(fn (ProductPriceDomainObject $price) => $price->getId());
+ if (! in_array($priceId, $validPriceIds->toArray(), true)) {
$errors["products.$productIndex.quantities.$quantityIndex.price_id"] = __('Invalid price ID');
+
continue;
}
- $selectedPrice = $productPrices?->first(fn(ProductPriceDomainObject $price) => $price->getId() === $priceId);
- if ((int)$quantity > 0 && $selectedPrice?->getIsHidden()) {
+ $selectedPrice = $productPrices?->first(fn (ProductPriceDomainObject $price) => $price->getId() === $priceId);
+ if ((int) $quantity > 0 && $selectedPrice?->getIsHidden()) {
$errors["products.$productIndex.quantities.$quantityIndex.price_id"] = __('Invalid price ID');
}
}
- if (!empty($errors)) {
+ if (! empty($errors)) {
throw ValidationException::withMessages($errors);
}
}
@@ -340,7 +530,7 @@ private function validatePriceIdAndQuantity(int $productIndex, array $productAnd
/**
* @throws ValidationException
*/
- private function validateProductPricesQuantity(array $quantities, ProductDomainObject $product, int $productIndex): void
+ private function validateProductPricesQuantity(array $quantities, ProductDomainObject $product, int $productIndex, array $requestedQuantities): void
{
foreach ($quantities as $productQuantity) {
if ($productQuantity['quantity'] === 0) {
@@ -355,21 +545,24 @@ 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']);
+
+ $requestedQuantity = $requestedQuantities[$product->getId()][$productQuantity['price_id']]
+ ?? $productQuantity['quantity'];
- if ($productQuantity['quantity'] > $numberAvailable) {
+ if ($requestedQuantity > $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() : ''),
]),
]);
}
@@ -379,24 +572,28 @@ private function validateProductPricesQuantity(array $quantities, ProductDomainO
/**
* @throws ValidationException
*/
- private function validateOverallCapacity(array $data): void
+ private function validateOverallCapacity(EventDomainObject $event, array $data): void
{
+ 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'));
if ($totalQuantity === 0) {
continue;
}
$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/OrderDiscountAllocationService.php b/backend/app/Services/Domain/Order/OrderDiscountAllocationService.php
new file mode 100644
index 0000000000..28b635aebb
--- /dev/null
+++ b/backend/app/Services/Domain/Order/OrderDiscountAllocationService.php
@@ -0,0 +1,163 @@
+ $lines
+ * @return array>
+ */
+ public function allocate(Collection $lines, PromoCodeDomainObject $promoCode, string $currency): array
+ {
+ $multiplier = Currency::isZeroDecimalCurrency($currency) ? 1 : 100;
+
+ $eligibleLines = [];
+ foreach ($lines as $index => $line) {
+ if ($this->isEligible($line, $promoCode)) {
+ $eligibleLines[$index] = [
+ 'unitMinor' => (int) round($line->prices->price * $multiplier),
+ 'quantity' => $line->product_price->quantity,
+ ];
+ }
+ }
+
+ $subtotalMinor = 0;
+ foreach ($eligibleLines as $eligibleLine) {
+ $subtotalMinor += $eligibleLine['unitMinor'] * $eligibleLine['quantity'];
+ }
+
+ if ($subtotalMinor === 0) {
+ return $this->toAllocations($lines, [], [], $multiplier);
+ }
+
+ $targetMinor = min((int) round($promoCode->getDiscount() * $multiplier), $subtotalMinor);
+
+ $perUnitMinor = [];
+ $remainingMinor = $targetMinor;
+ foreach ($eligibleLines as $index => $eligibleLine) {
+ $perUnitMinor[$index] = intdiv($targetMinor * $eligibleLine['unitMinor'], $subtotalMinor);
+ $remainingMinor -= $perUnitMinor[$index] * $eligibleLine['quantity'];
+ }
+
+ $splitUnits = [];
+ while ($remainingMinor > 0) {
+ $index = $this->nextLineToIncrement($eligibleLines, $perUnitMinor, $remainingMinor, $targetMinor, $subtotalMinor);
+
+ if ($index === null) {
+ $index = $this->lineToSplit($eligibleLines, $perUnitMinor);
+ $splitUnits[$index] = $remainingMinor;
+ break;
+ }
+
+ $perUnitMinor[$index]++;
+ $remainingMinor -= $eligibleLines[$index]['quantity'];
+ }
+
+ return $this->toAllocations($lines, $perUnitMinor, $splitUnits, $multiplier);
+ }
+
+ private function isEligible(OrderItemPricingLineDTO $line, PromoCodeDomainObject $promoCode): bool
+ {
+ return $promoCode->appliesToProduct($line->product)
+ && ! $line->product->isFreeType()
+ && ! $line->product->isDonationType()
+ && $line->prices->price > 0;
+ }
+
+ /**
+ * @param array $eligibleLines
+ * @param array $perUnitMinor
+ */
+ private function nextLineToIncrement(
+ array $eligibleLines,
+ array $perUnitMinor,
+ int $remainingMinor,
+ int $targetMinor,
+ int $subtotalMinor,
+ ): ?int {
+ $bestIndex = null;
+ $bestFraction = -1;
+
+ foreach ($eligibleLines as $index => $eligibleLine) {
+ if ($perUnitMinor[$index] >= $eligibleLine['unitMinor'] || $eligibleLine['quantity'] > $remainingMinor) {
+ continue;
+ }
+
+ $fraction = ($targetMinor * $eligibleLine['unitMinor']) % $subtotalMinor;
+
+ if ($fraction > $bestFraction) {
+ $bestFraction = $fraction;
+ $bestIndex = $index;
+ }
+ }
+
+ return $bestIndex;
+ }
+
+ /**
+ * @param array $eligibleLines
+ * @param array $perUnitMinor
+ */
+ private function lineToSplit(array $eligibleLines, array $perUnitMinor): int
+ {
+ $splitIndex = null;
+ $smallestQuantity = null;
+
+ foreach ($eligibleLines as $index => $eligibleLine) {
+ if ($perUnitMinor[$index] >= $eligibleLine['unitMinor']) {
+ continue;
+ }
+
+ if ($smallestQuantity === null || $eligibleLine['quantity'] < $smallestQuantity) {
+ $smallestQuantity = $eligibleLine['quantity'];
+ $splitIndex = $index;
+ }
+ }
+
+ return $splitIndex;
+ }
+
+ /**
+ * @param Collection $lines
+ * @param array $perUnitMinor
+ * @param array $splitUnits
+ * @return array>
+ */
+ private function toAllocations(Collection $lines, array $perUnitMinor, array $splitUnits, int $multiplier): array
+ {
+ $allocations = [];
+
+ foreach ($lines as $index => $line) {
+ $quantity = $line->product_price->quantity;
+ $unitMinorDiscount = $perUnitMinor[$index] ?? 0;
+
+ if (isset($splitUnits[$index])) {
+ $allocations[$index] = [
+ new OrderLineDiscountAllocationDTO(
+ per_unit_discount: ($unitMinorDiscount + 1) / $multiplier,
+ quantity: $splitUnits[$index],
+ ),
+ new OrderLineDiscountAllocationDTO(
+ per_unit_discount: $unitMinorDiscount / $multiplier,
+ quantity: $quantity - $splitUnits[$index],
+ ),
+ ];
+
+ continue;
+ }
+
+ $allocations[$index] = [
+ new OrderLineDiscountAllocationDTO(per_unit_discount: $unitMinorDiscount / $multiplier, quantity: $quantity),
+ ];
+ }
+
+ return $allocations;
+ }
+}
diff --git a/backend/app/Services/Domain/Order/OrderItemProcessingService.php b/backend/app/Services/Domain/Order/OrderItemProcessingService.php
index f34ccd50ff..3401d1a6da 100644
--- a/backend/app/Services/Domain/Order/OrderItemProcessingService.php
+++ b/backend/app/Services/Domain/Order/OrderItemProcessingService.php
@@ -2,24 +2,27 @@
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;
use HiEvents\Services\Application\Handlers\Order\DTO\ProductOrderDetailsDTO;
+use HiEvents\Services\Domain\Order\DTO\OrderItemPricingLineDTO;
+use HiEvents\Services\Domain\Order\DTO\OrderLineDiscountAllocationDTO;
use HiEvents\Services\Domain\Product\DTO\OrderProductPriceDTO;
+use HiEvents\Services\Domain\Product\DTO\PriceDTO;
use HiEvents\Services\Domain\Product\ProductPriceService;
use HiEvents\Services\Domain\Tax\TaxAndFeeCalculationService;
use Illuminate\Support\Collection;
@@ -27,38 +30,54 @@
class OrderItemProcessingService
{
- private ?AccountConfigurationDomainObject $accountConfiguration = null;
+ private ?OrganizerConfigurationDomainObject $organizerConfiguration = null;
+
private ?EventSettingDomainObject $eventSettings = null;
public function __construct(
- private readonly OrderRepositoryInterface $orderRepository,
- private readonly ProductRepositoryInterface $productRepository,
- private readonly TaxAndFeeCalculationService $taxCalculationService,
- private readonly ProductPriceService $productPriceService,
+ private readonly OrderRepositoryInterface $orderRepository,
+ private readonly ProductRepositoryInterface $productRepository,
+ private readonly TaxAndFeeCalculationService $taxCalculationService,
+ private readonly ProductPriceService $productPriceService,
private readonly OrderPlatformFeePassThroughService $platformFeeService,
- private readonly AccountRepositoryInterface $accountRepository,
- private readonly EventRepositoryInterface $eventRepository,
- )
- {
- }
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly OrderDiscountAllocationService $orderDiscountAllocationService,
+ ) {}
/**
- * @param OrderDomainObject $order
- * @param Collection $productsOrderDetails
- * @param EventDomainObject $event
- * @param PromoCodeDomainObject|null $promoCode
- * @return Collection
+ * @param Collection $productsOrderDetails
*/
public function process(
- OrderDomainObject $order,
- Collection $productsOrderDetails,
- EventDomainObject $event,
- ?PromoCodeDomainObject $promoCode
- ): Collection
- {
+ OrderDomainObject $order,
+ Collection $productsOrderDetails,
+ EventDomainObject $event,
+ ?PromoCodeDomainObject $promoCode,
+ ): Collection {
$this->loadPlatformFeeConfiguration($event->getId());
- $orderItems = collect();
+ $pricingLines = $this->buildPricingLines($productsOrderDetails, $event, $promoCode);
+
+ if ($promoCode?->isOrderLevelDiscount()) {
+ $pricingLines = $this->applyOrderLevelDiscount($pricingLines, $promoCode, $event->getCurrency());
+ }
+
+ return $pricingLines->map(function (OrderItemPricingLineDTO $line) use ($order, $event) {
+ return $this->orderRepository->addOrderItem(
+ $this->calculateOrderItemData($line, $order, $event->getCurrency())
+ );
+ });
+ }
+
+ /**
+ * @param Collection $productsOrderDetails
+ * @return Collection
+ */
+ private function buildPricingLines(
+ Collection $productsOrderDetails,
+ EventDomainObject $event,
+ ?PromoCodeDomainObject $promoCode,
+ ): Collection {
+ $pricingLines = collect();
foreach ($productsOrderDetails as $productOrderDetail) {
$product = $this->productRepository
@@ -75,47 +94,89 @@ 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 ($pricingLines, $promoCode, $product, $eventOccurrenceId) {
if ($productPrice->quantity === 0) {
return;
}
- $orderItemData = $this->calculateOrderItemData($product, $productPrice, $order, $promoCode, $event->getCurrency());
- $orderItems->push($this->orderRepository->addOrderItem($orderItemData));
+ $pricingLines->push(new OrderItemPricingLineDTO(
+ product: $product,
+ product_price: $productPrice,
+ prices: $this->productPriceService->getPrice($product, $productPrice, $promoCode, $eventOccurrenceId),
+ event_occurrence_id: $eventOccurrenceId,
+ ));
});
}
- return $orderItems;
+ return $pricingLines;
}
- private function loadPlatformFeeConfiguration(int $eventId): void
+ /**
+ * @param Collection $pricingLines
+ * @return Collection
+ */
+ private function applyOrderLevelDiscount(Collection $pricingLines, PromoCodeDomainObject $promoCode, string $currency): Collection
{
- $account = $this->accountRepository
- ->loadRelation(new Relationship(
- domainObject: AccountConfigurationDomainObject::class,
- name: 'configuration',
- ))
- ->findByEventId($eventId);
+ $allocations = $this->orderDiscountAllocationService->allocate($pricingLines, $promoCode, $currency);
+
+ return $pricingLines
+ ->flatMap(static function (OrderItemPricingLineDTO $line, int $index) use ($allocations) {
+ return collect($allocations[$index])->map(static function (OrderLineDiscountAllocationDTO $allocation) use ($line) {
+ if ($allocation->per_unit_discount <= 0 && $allocation->quantity === $line->product_price->quantity) {
+ return $line;
+ }
- $this->accountConfiguration = $account->getConfiguration();
+ return new OrderItemPricingLineDTO(
+ product: $line->product,
+ product_price: new OrderProductPriceDTO(
+ quantity: $allocation->quantity,
+ price_id: $line->product_price->price_id,
+ price: $line->product_price->price,
+ ),
+ prices: $allocation->per_unit_discount > 0
+ ? new PriceDTO(
+ price: Currency::round($line->prices->price - $allocation->per_unit_discount),
+ price_before_discount: $line->prices->price,
+ )
+ : $line->prices,
+ event_occurrence_id: $line->event_occurrence_id,
+ );
+ });
+ })
+ ->values();
+ }
+ private function loadPlatformFeeConfiguration(int $eventId): void
+ {
$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(
- ProductDomainObject $product,
- OrderProductPriceDTO $productPriceDetails,
- OrderDomainObject $order,
- ?PromoCodeDomainObject $promoCode,
- string $currency
- ): array
- {
- $prices = $this->productPriceService->getPrice($product, $productPriceDetails, $promoCode);
- $priceWithDiscount = $prices->price;
- $priceBeforeDiscount = $prices->price_before_discount;
+ OrderItemPricingLineDTO $line,
+ OrderDomainObject $order,
+ string $currency,
+ ): array {
+ $product = $line->product;
+ $productPriceDetails = $line->product_price;
+ $eventOccurrenceId = $line->event_occurrence_id;
+ $priceWithDiscount = $line->prices->price;
+ $priceBeforeDiscount = $line->prices->price_before_discount;
$itemTotalWithDiscount = $priceWithDiscount * $productPriceDetails->quantity;
@@ -156,17 +217,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,
@@ -190,9 +252,9 @@ private function addPlatformFeeToRollup(array $rollUp, float $platformFee): arra
private function getOrderItemLabel(ProductDomainObject $product, int $priceId): string
{
if ($product->isTieredType()) {
- return $product->getTitle() . ' - ' . $product->getProductPrices()
- ?->filter(fn($p) => $p->getId() === $priceId)->first()
- ?->getLabel();
+ return $product->getTitle().' - '.$product->getProductPrices()
+ ?->filter(fn ($p) => $p->getId() === $priceId)->first()
+ ?->getLabel();
}
return $product->getTitle();
diff --git a/backend/app/Services/Domain/Order/OrderManagementService.php b/backend/app/Services/Domain/Order/OrderManagementService.php
index 0e627482f0..1b765421fa 100644
--- a/backend/app/Services/Domain/Order/OrderManagementService.php
+++ b/backend/app/Services/Domain/Order/OrderManagementService.php
@@ -18,11 +18,9 @@
class OrderManagementService
{
public function __construct(
- readonly private OrderRepositoryInterface $orderRepository,
- readonly private TaxAndFeeOrderRollupService $taxAndFeeOrderRollupService,
- )
- {
- }
+ private readonly OrderRepositoryInterface $orderRepository,
+ private readonly TaxAndFeeOrderRollupService $taxAndFeeOrderRollupService,
+ ) {}
public function deleteExistingOrders(int $eventId, string $sessionId): void
{
@@ -34,15 +32,14 @@ public function deleteExistingOrders(int $eventId, string $sessionId): void
}
public function createNewOrder(
- int $eventId,
- EventDomainObject $event,
- int $timeOutMinutes,
- string $locale,
+ int $eventId,
+ EventDomainObject $event,
+ int $timeOutMinutes,
+ string $locale,
?PromoCodeDomainObject $promoCode,
?AffiliateDomainObject $affiliate = null,
- ?string $sessionId = null,
- ): OrderDomainObject
- {
+ ?string $sessionId = null,
+ ): OrderDomainObject {
$reservedUntil = Carbon::now()->addMinutes($timeOutMinutes);
return $this->orderRepository->create([
@@ -64,9 +61,7 @@ public function createNewOrder(
* Update order totals by summing up all order items.
* Platform fee and its tax are included at the item level.
*
- * @param OrderDomainObject $order
- * @param Collection $orderItems
- * @return OrderDomainObject
+ * @param Collection $orderItems
*/
public function updateOrderTotals(OrderDomainObject $order, Collection $orderItems): OrderDomainObject
{
diff --git a/backend/app/Services/Domain/Order/OrderPaymentPlatformFeeService.php b/backend/app/Services/Domain/Order/OrderPaymentPlatformFeeService.php
index 6343cdc8a6..cc29f8f90f 100644
--- a/backend/app/Services/Domain/Order/OrderPaymentPlatformFeeService.php
+++ b/backend/app/Services/Domain/Order/OrderPaymentPlatformFeeService.php
@@ -10,24 +10,21 @@ class OrderPaymentPlatformFeeService
{
public function __construct(
private readonly OrderPaymentPlatformFeeRepositoryInterface $orderPaymentPlatformFeeRepository,
- )
- {
- }
+ ) {}
public function createOrderPaymentPlatformFee(
- int $orderId,
- string $paymentPlatform,
- ?array $feeRollup,
- int $paymentPlatformFeeAmountMinorUnit,
- int $applicationFeeGrossAmountMinorUnit,
- string $currency,
+ int $orderId,
+ string $paymentPlatform,
+ ?array $feeRollup,
+ int $paymentPlatformFeeAmountMinorUnit,
+ int $applicationFeeGrossAmountMinorUnit,
+ string $currency,
?string $transactionId = null,
?string $chargeId = null,
- ?int $applicationFeeNetAmountMinorUnit = null,
- ?int $applicationFeeVatAmountMinorUnit = null,
- ?float $applicationFeeVatRate = null,
- ): void
- {
+ ?int $applicationFeeNetAmountMinorUnit = null,
+ ?int $applicationFeeVatAmountMinorUnit = null,
+ ?float $applicationFeeVatRate = null,
+ ): void {
$isZeroDecimalCurrency = Currency::isZeroDecimalCurrency($currency);
$paymentPlatformFeeAmount = $isZeroDecimalCurrency
diff --git a/backend/app/Services/Domain/Order/OrderPlatformFeePassThroughService.php b/backend/app/Services/Domain/Order/OrderPlatformFeePassThroughService.php
index 7f87def40b..8c22232ffb 100644
--- a/backend/app/Services/Domain/Order/OrderPlatformFeePassThroughService.php
+++ b/backend/app/Services/Domain/Order/OrderPlatformFeePassThroughService.php
@@ -3,8 +3,8 @@
namespace HiEvents\Services\Domain\Order;
use Brick\Money\Currency as BrickCurrency;
-use HiEvents\DomainObjects\AccountConfigurationDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
+use HiEvents\DomainObjects\OrganizerConfigurationDomainObject;
use HiEvents\Helper\Currency;
use HiEvents\Services\Infrastructure\CurrencyConversion\CurrencyConversionClientInterface;
use Illuminate\Config\Repository;
@@ -19,15 +19,13 @@ public static function getPlatformFeeName(): string
}
public function __construct(
- private readonly Repository $config,
+ private readonly Repository $config,
private readonly CurrencyConversionClientInterface $currencyConversionClient,
- )
- {
- }
+ ) {}
public function isEnabled(EventSettingDomainObject $eventSettings): bool
{
- if (!$this->config->get('app.saas_mode_enabled')) {
+ if (! $this->config->get('app.saas_mode_enabled')) {
return false;
}
@@ -45,19 +43,18 @@ public function isEnabled(EventSettingDomainObject $eventSettings): bool
* In other words: application_fee(total + P) = P
*/
public function calculatePlatformFee(
- AccountConfigurationDomainObject $accountConfiguration,
- EventSettingDomainObject $eventSettings,
- float $total,
- int $quantity,
- string $currency,
- ): float
- {
- if (!$this->isEnabled($eventSettings) || $total <= 0) {
+ OrganizerConfigurationDomainObject $organizerConfiguration,
+ EventSettingDomainObject $eventSettings,
+ float $total,
+ int $quantity,
+ string $currency,
+ ): float {
+ if (! $this->isEnabled($eventSettings) || $total <= 0) {
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 +67,11 @@ public function calculatePlatformFee(
}
private function getConvertedFixedFee(
- AccountConfigurationDomainObject $accountConfiguration,
- string $currency
- ): float
- {
- $baseFee = $accountConfiguration->getFixedApplicationFee();
- $baseCurrency = $accountConfiguration->getApplicationFeeCurrency();
+ OrganizerConfigurationDomainObject $organizerConfiguration,
+ string $currency
+ ): float {
+ $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..e49c8c2f37 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;
@@ -15,13 +15,12 @@ class VatRateDeterminationService
public function __construct(
private readonly Repository $config,
- )
- {
+ ) {
$this->defaultVatRate = $this->config->get('app.tax.default_vat_rate', 0.23);
$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();
@@ -30,7 +29,7 @@ public function determineVatRatePercentage(AccountVatSettingDomainObject $vatSet
return $this->defaultVatRate;
}
- $hasVatNumber = !empty($vatSetting->getVatNumber());
+ $hasVatNumber = ! empty($vatSetting->getVatNumber());
$validated = $vatSetting->getVatValidated();
// Try to determine if EU country, default to charging VAT if invalid country code
@@ -46,7 +45,7 @@ public function determineVatRatePercentage(AccountVatSettingDomainObject $vatSet
}
// 2. If outside EU → No VAT
- if (!$isEu) {
+ if (! $isEu) {
return 0.0;
}
diff --git a/backend/app/Services/Domain/Organizer/CreateDefaultOrganizerSettingsService.php b/backend/app/Services/Domain/Organizer/CreateDefaultOrganizerSettingsService.php
index 51dc943f00..1e1d7af806 100644
--- a/backend/app/Services/Domain/Organizer/CreateDefaultOrganizerSettingsService.php
+++ b/backend/app/Services/Domain/Organizer/CreateDefaultOrganizerSettingsService.php
@@ -11,9 +11,7 @@ class CreateDefaultOrganizerSettingsService
{
public function __construct(
private readonly OrganizerSettingsRepositoryInterface $organizerSettingsRepository
- )
- {
- }
+ ) {}
public function createOrganizerSettings(OrganizerDomainObject $organizer): void
{
diff --git a/backend/app/Services/Domain/Organizer/OrganizerDeletionService.php b/backend/app/Services/Domain/Organizer/OrganizerDeletionService.php
index 99d10a0e82..357f58998e 100644
--- a/backend/app/Services/Domain/Organizer/OrganizerDeletionService.php
+++ b/backend/app/Services/Domain/Organizer/OrganizerDeletionService.php
@@ -14,13 +14,11 @@ class OrganizerDeletionService
{
public function __construct(
private readonly OrganizerRepositoryInterface $organizerRepository,
- private readonly EventRepositoryInterface $eventRepository,
- private readonly EventDeletionService $eventDeletionService,
- private readonly LoggerInterface $logger,
- private readonly DatabaseManager $databaseManager,
- )
- {
- }
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly EventDeletionService $eventDeletionService,
+ private readonly LoggerInterface $logger,
+ private readonly DatabaseManager $databaseManager,
+ ) {}
public function canDeleteOrganizer(int $organizerId, int $accountId): bool
{
@@ -37,7 +35,7 @@ public function canDeleteOrganizer(int $organizerId, int $accountId): bool
]);
foreach ($events as $event) {
- if (!$this->eventDeletionService->canDeleteEvent($event->getId())) {
+ if (! $this->eventDeletionService->canDeleteEvent($event->getId())) {
return false;
}
}
@@ -60,7 +58,7 @@ public function getCannotDeleteReason(int $organizerId, int $accountId): ?string
]);
foreach ($events as $event) {
- if (!$this->eventDeletionService->canDeleteEvent($event->getId())) {
+ if (! $this->eventDeletionService->canDeleteEvent($event->getId())) {
return __('This organizer has events with completed orders. Please cancel or refund all orders first.');
}
}
diff --git a/backend/app/Services/Domain/Organizer/OrganizerFetchService.php b/backend/app/Services/Domain/Organizer/OrganizerFetchService.php
index 84b418ba0b..32fe42b35b 100644
--- a/backend/app/Services/Domain/Organizer/OrganizerFetchService.php
+++ b/backend/app/Services/Domain/Organizer/OrganizerFetchService.php
@@ -10,9 +10,7 @@ class OrganizerFetchService
{
public function __construct(
public readonly OrganizerRepositoryInterface $organizerRepository,
- )
- {
- }
+ ) {}
/**
* @throws OrganizerNotFoundException
diff --git a/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentRequestDTO.php b/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentRequestDTO.php
index d6fdb9ea3e..3b160ca779 100644
--- a/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentRequestDTO.php
+++ b/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentRequestDTO.php
@@ -4,21 +4,21 @@
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/DTOs/CreatePaymentIntentResponseDTO.php b/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentResponseDTO.php
index 7b8202112b..5d75c416a7 100644
--- a/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentResponseDTO.php
+++ b/backend/app/Services/Domain/Payment/Stripe/DTOs/CreatePaymentIntentResponseDTO.php
@@ -8,14 +8,12 @@
readonly class CreatePaymentIntentResponseDTO
{
public function __construct(
- public ?string $paymentIntentId = null,
- public ?string $clientSecret = null,
- public ?string $accountId = null,
- public ?string $error = null,
+ public ?string $paymentIntentId = null,
+ public ?string $clientSecret = null,
+ public ?string $accountId = null,
+ public ?string $error = null,
public ?ApplicationFeeValuesDTO $applicationFeeData = null,
- public ?StripePlatform $stripePlatform = null,
- public ?string $publicKey = null,
- )
- {
- }
+ public ?StripePlatform $stripePlatform = null,
+ public ?string $publicKey = null,
+ ) {}
}
diff --git a/backend/app/Services/Domain/Payment/Stripe/DTOs/StripePayoutCreationDTO.php b/backend/app/Services/Domain/Payment/Stripe/DTOs/StripePayoutCreationDTO.php
index 3f8044096c..6fb60f62fb 100644
--- a/backend/app/Services/Domain/Payment/Stripe/DTOs/StripePayoutCreationDTO.php
+++ b/backend/app/Services/Domain/Payment/Stripe/DTOs/StripePayoutCreationDTO.php
@@ -14,7 +14,5 @@ public function __construct(
public readonly ?\DateTimeInterface $payoutDate,
public readonly ?string $status,
public readonly ?array $metadata,
- ) {
- }
+ ) {}
}
-
diff --git a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/AccountUpdateHandler.php b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/AccountUpdateHandler.php
index 98fbfaa1b8..2259541714 100644
--- a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/AccountUpdateHandler.php
+++ b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/AccountUpdateHandler.php
@@ -2,35 +2,17 @@
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/EventHandlers/ChargeRefundUpdatedHandler.php b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandler.php
index e21e4f7f2b..6212d7f075 100644
--- a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandler.php
+++ b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/ChargeRefundUpdatedHandler.php
@@ -23,16 +23,14 @@
class ChargeRefundUpdatedHandler
{
public function __construct(
- private readonly OrderRepositoryInterface $orderRepository,
+ private readonly OrderRepositoryInterface $orderRepository,
private readonly StripePaymentsRepositoryInterface $stripePaymentsRepository,
- private readonly Logger $logger,
- private readonly DatabaseManager $databaseManager,
- private readonly EventStatisticsRefundService $eventStatisticsRefundService,
- private readonly OrderRefundRepositoryInterface $orderRefundRepository,
- private readonly DomainEventDispatcherService $domainEventDispatcherService,
- )
- {
- }
+ private readonly Logger $logger,
+ private readonly DatabaseManager $databaseManager,
+ private readonly EventStatisticsRefundService $eventStatisticsRefundService,
+ private readonly OrderRefundRepositoryInterface $orderRefundRepository,
+ private readonly DomainEventDispatcherService $domainEventDispatcherService,
+ ) {}
/**
* @throws Throwable
@@ -41,10 +39,10 @@ public function handleEvent(Refund $refund): void
{
$this->databaseManager->transaction(function () use ($refund) {
$stripePayment = $this->stripePaymentsRepository->findFirstWhere([
- 'payment_intent_id' => $refund->payment_intent
+ 'payment_intent_id' => $refund->payment_intent,
]);
- if (!$stripePayment) {
+ if (! $stripePayment) {
return;
}
@@ -66,6 +64,7 @@ public function handleEvent(Refund $refund): void
if ($refund->status !== 'succeeded') {
$this->handleFailure($refund, $order);
+
return;
}
diff --git a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/ChargeSucceededHandler.php b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/ChargeSucceededHandler.php
index fdaee890ec..3365a7d993 100644
--- a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/ChargeSucceededHandler.php
+++ b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/ChargeSucceededHandler.php
@@ -14,12 +14,10 @@
class ChargeSucceededHandler
{
public function __construct(
- private readonly StripePaymentsRepository $stripePaymentsRepository,
+ private readonly StripePaymentsRepository $stripePaymentsRepository,
private readonly StripePaymentPlatformFeeExtractionService $platformFeeExtractionService,
- private readonly LoggerInterface $logger,
- )
- {
- }
+ private readonly LoggerInterface $logger,
+ ) {}
public function handleEvent(Charge $charge): void
{
@@ -34,31 +32,34 @@ public function handleEvent(Charge $charge): void
'charge_id' => $charge->id,
'status' => $charge->status,
]);
+
return;
}
- /**@var StripePaymentDomainObject $stripePayment */
+ /** @var StripePaymentDomainObject $stripePayment */
$stripePayment = $this->stripePaymentsRepository
->loadRelation(new Relationship(OrderDomainObject::class, name: 'order'))
->findFirstWhere([
StripePaymentDomainObjectAbstract::PAYMENT_INTENT_ID => $charge->payment_intent,
]);
- if (!$stripePayment) {
+ if (! $stripePayment) {
$this->logger->warning(__('Stripe payment not found for charge'), [
'charge_id' => $charge->id,
'payment_intent_id' => $charge->payment_intent,
]);
+
return;
}
$order = $stripePayment->getOrder();
- if (!$order) {
+ if (! $order) {
$this->logger->warning(__('Order not found for charge'), [
'charge_id' => $charge->id,
'payment_intent_id' => $charge->payment_intent,
'stripe_payment_id' => $stripePayment->getId(),
]);
+
return;
}
diff --git a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentFailedHandler.php b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentFailedHandler.php
index 72f6b30ca0..4ca8031cea 100644
--- a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentFailedHandler.php
+++ b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentFailedHandler.php
@@ -19,13 +19,11 @@
readonly class PaymentIntentFailedHandler
{
public function __construct(
- private OrderRepositoryInterface $orderRepository,
- private StripePaymentsRepository $stripePaymentsRepository,
- private DatabaseManager $databaseManager,
+ private OrderRepositoryInterface $orderRepository,
+ private StripePaymentsRepository $stripePaymentsRepository,
+ private DatabaseManager $databaseManager,
private StripePaymentUpdateFromPaymentIntentService $stripePaymentUpdateFromPaymentIntentService,
- )
- {
- }
+ ) {}
/**
* @throws Throwable
@@ -40,20 +38,38 @@ public function handleEvent(PaymentIntent $paymentIntent): void
StripePaymentDomainObjectAbstract::PAYMENT_INTENT_ID => $paymentIntent->id,
]);
+ if (! $stripePayment) {
+ return;
+ }
+
$this->stripePaymentUpdateFromPaymentIntentService->updateStripePaymentInfo($paymentIntent, $stripePayment);
$updatedOrder = $this->updateOrderStatuses($stripePayment);
- OrderStatusChangedEvent::dispatch($updatedOrder);
+ if ($updatedOrder !== null) {
+ OrderStatusChangedEvent::dispatch($updatedOrder);
+ }
});
}
- private function updateOrderStatuses(StripePaymentDomainObjectAbstract $stripePayment): OrderDomainObject
+ private function updateOrderStatuses(StripePaymentDomainObjectAbstract $stripePayment): ?OrderDomainObject
{
+ $affected = $this->orderRepository->updateWhere(
+ attributes: [
+ OrderDomainObjectAbstract::PAYMENT_STATUS => OrderPaymentStatus::PAYMENT_FAILED->name,
+ ],
+ where: [
+ OrderDomainObjectAbstract::ID => $stripePayment->getOrderId(),
+ OrderDomainObjectAbstract::PAYMENT_STATUS => OrderPaymentStatus::AWAITING_PAYMENT->name,
+ ],
+ );
+
+ if ($affected === 0) {
+ return null;
+ }
+
return $this->orderRepository
->loadRelation(OrderItemDomainObject::class)
- ->updateFromArray($stripePayment->getOrderId(), [
- OrderDomainObjectAbstract::PAYMENT_STATUS => OrderPaymentStatus::PAYMENT_FAILED->name,
- ]);
+ ->findById($stripePayment->getOrderId());
}
}
diff --git a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentSucceededHandler.php b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentSucceededHandler.php
index c9e608d56b..a01703856e 100644
--- a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentSucceededHandler.php
+++ b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentSucceededHandler.php
@@ -27,6 +27,7 @@
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
+use HiEvents\Services\Domain\Order\OccurrenceStatusValidator;
use HiEvents\Services\Domain\Order\OrderApplicationFeeService;
use HiEvents\Services\Domain\Payment\Stripe\StripeRefundExpiredOrderService;
use HiEvents\Services\Domain\Product\ProductQuantityUpdateService;
@@ -43,21 +44,20 @@
class PaymentIntentSucceededHandler
{
public function __construct(
- private readonly OrderRepositoryInterface $orderRepository,
- private readonly StripePaymentsRepository $stripePaymentsRepository,
- private readonly AffiliateRepositoryInterface $affiliateRepository,
- private readonly ProductQuantityUpdateService $quantityUpdateService,
- private readonly StripeRefundExpiredOrderService $refundExpiredOrderService,
- private readonly AttendeeRepositoryInterface $attendeeRepository,
- private readonly DatabaseManager $databaseManager,
- private readonly LoggerInterface $logger,
- private readonly Repository $cache,
- private readonly DomainEventDispatcherService $domainEventDispatcherService,
- private readonly OrderApplicationFeeService $orderApplicationFeeService,
+ private readonly OrderRepositoryInterface $orderRepository,
+ private readonly StripePaymentsRepository $stripePaymentsRepository,
+ private readonly AffiliateRepositoryInterface $affiliateRepository,
+ private readonly ProductQuantityUpdateService $quantityUpdateService,
+ private readonly StripeRefundExpiredOrderService $refundExpiredOrderService,
+ private readonly AttendeeRepositoryInterface $attendeeRepository,
+ private readonly DatabaseManager $databaseManager,
+ private readonly LoggerInterface $logger,
+ private readonly Repository $cache,
+ private readonly DomainEventDispatcherService $domainEventDispatcherService,
+ private readonly OrderApplicationFeeService $orderApplicationFeeService,
private readonly EventSettingsRepositoryInterface $eventSettingsRepository,
- )
- {
- }
+ private readonly OccurrenceStatusValidator $occurrenceStatusValidator,
+ ) {}
/**
* @throws Throwable
@@ -72,20 +72,22 @@ public function handleEvent(PaymentIntent $paymentIntent): void
return;
}
- $this->databaseManager->transaction(function () use ($paymentIntent) {
+ $result = $this->databaseManager->transaction(function () use ($paymentIntent) {
/** @var StripePaymentDomainObjectAbstract $stripePayment */
$stripePayment = $this->stripePaymentsRepository
- ->loadRelation(new Relationship(OrderDomainObject::class, name: 'order'))
+ ->loadRelation(new Relationship(OrderDomainObject::class, name: 'order', nested: [
+ new Relationship(OrderItemDomainObject::class),
+ ]))
->findFirstWhere([
StripePaymentDomainObjectAbstract::PAYMENT_INTENT_ID => $paymentIntent->id,
]);
- if (!$stripePayment) {
+ if (! $stripePayment) {
$this->logger->error('Payment intent not found when handling payment intent succeeded event', [
'paymentIntent' => $paymentIntent->toArray(),
]);
- return;
+ return null;
}
$this->validatePaymentAndOrderStatus($stripePayment, $paymentIntent);
@@ -103,19 +105,25 @@ public function handleEvent(PaymentIntent $paymentIntent): void
EventSettingDomainObjectAbstract::EVENT_ID => $updatedOrder->getEventId(),
]);
- event(new OrderStatusChangedEvent($updatedOrder, createInvoice: $eventSettings->getEnableInvoicing()));
-
- $this->domainEventDispatcherService->dispatch(
- new OrderEvent(
- type: DomainEventType::ORDER_CREATED,
- orderId: $updatedOrder->getId()
- ),
- );
-
$this->markPaymentIntentAsHandled($paymentIntent, $updatedOrder);
$this->storeApplicationFeePayment($updatedOrder, $paymentIntent);
+
+ return ['order' => $updatedOrder, 'eventSettings' => $eventSettings];
});
+
+ if ($result === null) {
+ return;
+ }
+
+ event(new OrderStatusChangedEvent($result['order'], createInvoice: $result['eventSettings']->getEnableInvoicing()));
+
+ $this->domainEventDispatcherService->dispatch(
+ new OrderEvent(
+ type: DomainEventType::ORDER_CREATED,
+ orderId: $result['order']->getId()
+ ),
+ );
}
private function updateOrderStatuses(StripePaymentDomainObjectAbstract $stripePayment): OrderDomainObject
@@ -170,30 +178,44 @@ private function updateStripePaymentInfo(PaymentIntent $paymentIntent, StripePay
* @throws UnknownCurrencyException
* @throws NumberFormatException
* @throws StripeClientConfigurationException
+ *
* @todo We could check to see if there are products available, and if so, complete the order.
* This would be a better user experience.
- *
*/
private function handleExpiredOrder(
StripePaymentDomainObjectAbstract $stripePayment,
- PaymentIntent $paymentIntent,
- ): void
- {
+ PaymentIntent $paymentIntent,
+ ): void {
if ((new Carbon($stripePayment->getOrder()?->getReservedUntil()))->isPast()) {
- $this->refundExpiredOrderService->refundExpiredOrder(
- paymentIntent: $paymentIntent,
- stripePayment: $stripePayment,
- order: $stripePayment->getOrder(),
- );
-
- throw new CannotAcceptPaymentException(
+ $this->rejectAndRefund(
+ $stripePayment,
+ $paymentIntent,
__('Payment was successful, but order has expired. Order: :id', [
- 'id' => $stripePayment->getOrderId()
+ 'id' => $stripePayment->getOrderId(),
])
);
}
}
+ /**
+ * @throws CannotAcceptPaymentException
+ */
+ private function rejectAndRefund(
+ StripePaymentDomainObjectAbstract $stripePayment,
+ PaymentIntent $paymentIntent,
+ string $message,
+ ): void {
+ $this->refundExpiredOrderService->refundExpiredOrder(
+ paymentIntent: $paymentIntent,
+ stripePayment: $stripePayment,
+ order: $stripePayment->getOrder(),
+ );
+
+ $this->cache->put('payment_intent_handled_'.$paymentIntent->id, true, 3600);
+
+ throw new CannotAcceptPaymentException($message);
+ }
+
/**
* @throws ApiErrorException
* @throws RoundingNecessaryException
@@ -204,10 +226,9 @@ private function handleExpiredOrder(
*/
private function validatePaymentAndOrderStatus(
StripePaymentDomainObjectAbstract $stripePayment,
- PaymentIntent $paymentIntent
- ): void
- {
- if (!in_array($stripePayment->getOrder()?->getPaymentStatus(), [
+ PaymentIntent $paymentIntent
+ ): void {
+ if (! in_array($stripePayment->getOrder()?->getPaymentStatus(), [
OrderPaymentStatus::AWAITING_PAYMENT->name,
OrderPaymentStatus::PAYMENT_FAILED->name,
], true)) {
@@ -218,6 +239,31 @@ private function validatePaymentAndOrderStatus(
);
}
+ if (in_array($stripePayment->getOrder()?->getStatus(), [
+ OrderStatus::CANCELLED->name,
+ OrderStatus::ABANDONED->name,
+ ], true)) {
+ $this->rejectAndRefund(
+ $stripePayment,
+ $paymentIntent,
+ __('Payment was successful, but the order is no longer valid. Order: :id', [
+ 'id' => $stripePayment->getOrderId(),
+ ])
+ );
+ }
+
+ $order = $stripePayment->getOrder();
+
+ if ($order !== null && $this->occurrenceStatusValidator->findBlockingOccurrence($order) !== null) {
+ $this->rejectAndRefund(
+ $stripePayment,
+ $paymentIntent,
+ __('Payment was successful, but the event date is no longer available. Order: :id', [
+ 'id' => $stripePayment->getOrderId(),
+ ])
+ );
+ }
+
$this->handleExpiredOrder($stripePayment, $paymentIntent);
}
@@ -243,12 +289,12 @@ private function markPaymentIntentAsHandled(PaymentIntent $paymentIntent, OrderD
'currency' => $paymentIntent->currency,
]);
- $this->cache->put('payment_intent_handled_' . $paymentIntent->id, true, 3600);
+ $this->cache->put('payment_intent_handled_'.$paymentIntent->id, true, 3600);
}
private function isPaymentIntentAlreadyHandled(PaymentIntent $paymentIntent): bool
{
- return $this->cache->has('payment_intent_handled_' . $paymentIntent->id);
+ return $this->cache->has('payment_intent_handled_'.$paymentIntent->id);
}
private function storeApplicationFeePayment(OrderDomainObject $updatedOrder, PaymentIntent $paymentIntent): void
diff --git a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PayoutPaidHandler.php b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PayoutPaidHandler.php
index 8ab381d709..66e15a7384 100644
--- a/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PayoutPaidHandler.php
+++ b/backend/app/Services/Domain/Payment/Stripe/EventHandlers/PayoutPaidHandler.php
@@ -4,10 +4,10 @@
use HiEvents\DomainObjects\Generated\StripePaymentDomainObjectAbstract;
use HiEvents\Repository\Eloquent\StripePaymentsRepository;
+use HiEvents\Services\Domain\Payment\Stripe\DTOs\StripePayoutCreationDTO;
+use HiEvents\Services\Domain\Payment\Stripe\StripePayoutService;
use HiEvents\Services\Infrastructure\Stripe\StripeClientFactory;
use HiEvents\Services\Infrastructure\Stripe\StripeConfigurationService;
-use HiEvents\Services\Domain\Payment\Stripe\StripePayoutService;
-use HiEvents\Services\Domain\Payment\Stripe\DTOs\StripePayoutCreationDTO;
use Psr\Log\LoggerInterface;
use Stripe\ApplicationFee;
use Stripe\Payout;
@@ -18,14 +18,12 @@ class PayoutPaidHandler
private const PAGE_LIMIT = 100;
public function __construct(
- private readonly StripePaymentsRepository $stripePaymentsRepository,
- private readonly StripeClientFactory $stripeClientFactory,
- private readonly LoggerInterface $logger,
+ private readonly StripePaymentsRepository $stripePaymentsRepository,
+ private readonly StripeClientFactory $stripeClientFactory,
+ private readonly LoggerInterface $logger,
private readonly StripeConfigurationService $stripeConfigurationService,
- private readonly StripePayoutService $stripePayoutService,
- )
- {
- }
+ private readonly StripePayoutService $stripePayoutService,
+ ) {}
public function handleEvent(Payout $payout, ?string $connectedAccountId = null): void
{
@@ -35,6 +33,7 @@ public function handleEvent(Payout $payout, ?string $connectedAccountId = null):
'payout_id' => $payout->id,
'account' => $connectedAccountId,
]);
+
return;
}
@@ -50,6 +49,7 @@ public function handleEvent(Payout $payout, ?string $connectedAccountId = null):
'payout_id' => $payout->id,
'status' => $payout->status,
]);
+
return;
}
@@ -80,18 +80,19 @@ public function handleEvent(Payout $payout, ?string $connectedAccountId = null):
]);
$applicationFeeTxns = collect($transactions->data)
- ->filter(fn($txn) => $txn->type === 'application_fee' && $txn->source instanceof ApplicationFee)
+ ->filter(fn ($txn) => $txn->type === 'application_fee' && $txn->source instanceof ApplicationFee)
->values();
if ($applicationFeeTxns->isEmpty()) {
$this->logger->debug('No application_fee transactions found for this page');
$lastId = count($transactions->data) ? end($transactions->data)->id : null;
$page++;
+
continue;
}
$chargeIds = $applicationFeeTxns
- ->map(fn($txn) => $txn->source->originating_transaction ?? $txn->source->charge ?? $txn->source->fee_source->charge ?? null)
+ ->map(fn ($txn) => $txn->source->originating_transaction ?? $txn->source->charge ?? $txn->source->fee_source->charge ?? null)
->filter()
->unique()
->values();
@@ -100,6 +101,7 @@ public function handleEvent(Payout $payout, ?string $connectedAccountId = null):
$this->logger->debug('No valid charge IDs found for this payout page');
$lastId = count($transactions->data) ? end($transactions->data)->id : null;
$page++;
+
continue;
}
@@ -113,7 +115,7 @@ public function handleEvent(Payout $payout, ?string $connectedAccountId = null):
'payout_stripe_fee' => abs($txn->fee ?? 0),
'payout_net_amount' => $txn->net ?? null,
'payout_currency' => strtoupper($txn->currency ?? ''),
- 'payout_exchange_rate' => $txn->exchange_rate ? (float)$txn->exchange_rate : null,
+ 'payout_exchange_rate' => $txn->exchange_rate ? (float) $txn->exchange_rate : null,
];
}
}
@@ -122,7 +124,7 @@ public function handleEvent(Payout $payout, ?string $connectedAccountId = null):
->findWhereIn(StripePaymentDomainObjectAbstract::CHARGE_ID, $chargeIds->toArray());
$foundPayments = $payments
- ->filter(fn($payment) => $payment->getChargeId() !== null);
+ ->filter(fn ($payment) => $payment->getChargeId() !== null);
$this->logger->debug('Found matching Stripe payments for payout reconciliation', [
'payout_id' => $payout->id,
@@ -130,7 +132,7 @@ public function handleEvent(Payout $payout, ?string $connectedAccountId = null):
'total_charge_ids' => $chargeIds->count(),
]);
- $foundChargeIds = $foundPayments->map(fn($payment) => $payment->getChargeId())->values();
+ $foundChargeIds = $foundPayments->map(fn ($payment) => $payment->getChargeId())->values();
$missing = $chargeIds->diff($foundChargeIds);
if ($missing->isNotEmpty()) {
@@ -148,7 +150,7 @@ public function handleEvent(Payout $payout, ?string $connectedAccountId = null):
$chargeId = $payment->getChargeId();
$txnData = $chargeToTxnData[$chargeId] ?? null;
- if (!$txnData) {
+ if (! $txnData) {
continue;
}
@@ -187,7 +189,7 @@ public function handleEvent(Payout $payout, ?string $connectedAccountId = null):
stripePlatform: $this->stripeConfigurationService->getPrimaryPlatform()?->value ?? null,
amountMinor: $payout->amount ?? null,
currency: $payout->currency ?? null,
- payoutDate: isset($payout->arrival_date) ? (new \DateTimeImmutable())->setTimestamp($payout->arrival_date) : null,
+ payoutDate: isset($payout->arrival_date) ? (new \DateTimeImmutable)->setTimestamp($payout->arrival_date) : null,
status: $payout->status,
metadata: $payout->metadata?->toArray(),
);
diff --git a/backend/app/Services/Domain/Payment/Stripe/NoStripeCountryCodeException.php b/backend/app/Services/Domain/Payment/Stripe/NoStripeCountryCodeException.php
index a955f91afb..4cf47e1012 100644
--- a/backend/app/Services/Domain/Payment/Stripe/NoStripeCountryCodeException.php
+++ b/backend/app/Services/Domain/Payment/Stripe/NoStripeCountryCodeException.php
@@ -4,7 +4,4 @@
use Exception;
-class NoStripeCountryCodeException extends Exception
-{
-
-}
+class NoStripeCountryCodeException extends Exception {}
diff --git a/backend/app/Services/Domain/Payment/Stripe/StripeAccountSyncService.php b/backend/app/Services/Domain/Payment/Stripe/StripeAccountSyncService.php
index 47c4881700..ac8cda3606 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,167 @@
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,
+ ) {}
+
+ public function isStripeAccountComplete(Account $stripeAccount): bool
{
+ return $stripeAccount->charges_enabled && $stripeAccount->payouts_enabled;
}
- /**
- * Sync Stripe account status and details to our database
- */
- public function syncStripeAccountStatus(
- AccountStripePlatformDomainObject $accountStripePlatform,
- Account $stripeAccount
- ): void
+ public function createStripeAccountSetupUrl(Account $stripeAccount, StripeClient $stripeClient, int $organizerId): ?string
{
- $isAccountSetupCompleted = $this->isStripeAccountComplete($stripeAccount);
- $isCurrentlyComplete = $accountStripePlatform->getStripeSetupCompletedAt() !== null;
+ try {
+ $refreshUrl = sprintf(Url::getFrontEndUrlFromConfig(Url::STRIPE_CONNECT_REFRESH_URL), $organizerId);
+ $returnUrl = sprintf(Url::getFrontEndUrlFromConfig(Url::STRIPE_CONNECT_RETURN_URL), $organizerId);
- // Only update if status has actually changed
- if ($isCurrentlyComplete === $isAccountSetupCompleted) {
- // Still update account details even if status hasn't changed
- $this->updateAccountDetails($stripeAccount);
- return;
- }
+ $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
- */
- 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
+ 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(
+ public function markAccountAsCompleteForOrganizer(
+ OrganizerStripePlatformDomainObject $organizerStripePlatform,
Account $stripeAccount,
- bool $isAccountSetupCompleted
- ): void
- {
- $this->accountStripePlatformRepository->updateWhere(
+ ): void {
+ $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
- {
- $this->accountStripePlatformRepository->updateWhere(
+ public function seedVatSettingForOrganizerIfMissing(
+ int $organizerId,
+ ?string $countryCode,
+ ?string $stripeAccountId = null,
+ ?int $organizerStripePlatformId = null,
+ ): void {
+ 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,102 +207,36 @@ private function buildAccountDetails(Account $stripeAccount): string
], JSON_THROW_ON_ERROR);
}
- public function createStripeAccountSetupUrl(Account $stripeAccount, StripeClient $stripeClient): ?string
- {
- 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;
+ private function updateOrganizerCountryAndVerificationStatus(
+ OrganizerStripePlatformDomainObject $organizerStripePlatform,
+ Account $stripeAccount,
+ ): void {
+ $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()) {
+ if (! $account->getCountry()) {
$updates['country'] = strtoupper($stripeAccount->country);
}
- if (!$account->getIsManuallyVerified()) {
+ if (! $account->getIsManuallyVerified()) {
$updates['is_manually_verified'] = true;
}
- if (!empty($updates)) {
+ if (! empty($updates)) {
$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..ebd9c34a1e 100644
--- a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php
+++ b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentCreationService.php
@@ -19,24 +19,21 @@
class StripePaymentIntentCreationService
{
public function __construct(
- private readonly LoggerInterface $logger,
- private readonly Repository $config,
- private readonly StripeCustomerRepositoryInterface $stripeCustomerRepository,
- private readonly DatabaseManager $databaseManager,
+ private readonly LoggerInterface $logger,
+ private readonly Repository $config,
+ private readonly StripeCustomerRepositoryInterface $stripeCustomerRepository,
+ private readonly DatabaseManager $databaseManager,
private readonly OrderApplicationFeeCalculationService $orderApplicationFeeCalculationService,
- )
- {
- }
+ ) {}
/**
* @throws CreatePaymentIntentFailedException
*/
public function retrievePaymentIntentClientSecretWithClient(
StripeClient $stripeClient,
- string $paymentIntentId,
- ?string $accountId = null,
- ): string
- {
+ string $paymentIntentId,
+ ?string $accountId = null,
+ ): string {
try {
return $stripeClient->paymentIntents->retrieve(
id: $paymentIntentId,
@@ -59,21 +56,22 @@ public function retrievePaymentIntentClientSecretWithClient(
* @throws ApiErrorException|Throwable
*/
public function createPaymentIntentWithClient(
- StripeClient $stripeClient,
+ StripeClient $stripeClient,
CreatePaymentIntentRequestDTO $paymentIntentDTO
- ): CreatePaymentIntentResponseDTO
- {
+ ): CreatePaymentIntentResponseDTO {
try {
$this->databaseManager->beginTransaction();
- $accountConfiguration = $paymentIntentDTO->account->getConfiguration();
- $bypassApplicationFees = $accountConfiguration?->getBypassApplicationFees() ?? false;
+ $configuration = $paymentIntentDTO->configuration;
+ $bypassApplicationFees = $configuration?->getBypassApplicationFees() ?? false;
- $applicationFee = $this->orderApplicationFeeCalculationService->calculateApplicationFee(
- accountConfiguration: $accountConfiguration,
- order: $paymentIntentDTO->order,
- vatSettings: $paymentIntentDTO->vatSettings,
- );
+ $applicationFee = $configuration
+ ? $this->orderApplicationFeeCalculationService->calculateApplicationFee(
+ configuration: $configuration,
+ order: $paymentIntentDTO->order,
+ vatSettings: $paymentIntentDTO->vatSettings,
+ )
+ : null;
$paymentIntent = $stripeClient->paymentIntents->create([
'amount' => $paymentIntentDTO->amount->toMinorUnit(),
@@ -84,7 +82,7 @@ public function createPaymentIntentWithClient(
'enabled' => true,
],
...($paymentIntentDTO->description ? ['description' => $paymentIntentDTO->description] : []),
- ...($applicationFee && !$bypassApplicationFees ? ['application_fee_amount' => $applicationFee->grossApplicationFee->toMinorUnit()] : []),
+ ...($applicationFee && ! $bypassApplicationFees ? ['application_fee_amount' => $applicationFee->grossApplicationFee->toMinorUnit()] : []),
], $this->getStripeAccountData($paymentIntentDTO));
$this->logger->debug('Stripe payment intent created', [
@@ -123,7 +121,7 @@ public function createPaymentIntentWithClient(
*/
private function getStripeAccountData(CreatePaymentIntentRequestDTO $paymentIntentDTO): array
{
- if (!$this->config->get('app.saas_mode_enabled')) {
+ if (! $this->config->get('app.saas_mode_enabled')) {
return [];
}
@@ -140,7 +138,7 @@ private function getStripeAccountData(CreatePaymentIntentRequestDTO $paymentInte
}
return [
- 'stripe_account' => $paymentIntentDTO->stripeAccountId
+ 'stripe_account' => $paymentIntentDTO->stripeAccountId,
];
}
@@ -148,10 +146,9 @@ private function getStripeAccountData(CreatePaymentIntentRequestDTO $paymentInte
* @throws ApiErrorException|CreatePaymentIntentFailedException
*/
private function upsertStripeCustomerWithClient(
- StripeClient $stripeClient,
+ StripeClient $stripeClient,
CreatePaymentIntentRequestDTO $paymentIntentDTO
- ): StripeCustomerDomainObject
- {
+ ): StripeCustomerDomainObject {
$customer = $this->stripeCustomerRepository->findFirstWhere([
'email' => $paymentIntentDTO->order->getEmail(),
'stripe_account_id' => $paymentIntentDTO->stripeAccountId,
@@ -208,9 +205,8 @@ private function upsertStripeCustomerWithClient(
private function getPaymentIntentMetadata(
CreatePaymentIntentRequestDTO $paymentIntentDTO,
- ?ApplicationFeeValuesDTO $applicationFee
- ): array
- {
+ ?ApplicationFeeValuesDTO $applicationFee
+ ): array {
$metaData = [
'order_id' => $paymentIntentDTO->order->getId(),
'event_id' => $paymentIntentDTO->order->getEventId(),
diff --git a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php
index 23946a6764..c31405a1ea 100644
--- a/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php
+++ b/backend/app/Services/Domain/Payment/Stripe/StripePaymentIntentRefundService.php
@@ -14,28 +14,37 @@
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 {
+ $opts = array_merge(
+ $this->getStripeAccountData($payment),
+ [
+ 'idempotency_key' => sprintf(
+ 'refund_%s_amount_%d',
+ $payment->getPaymentIntentId(),
+ $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/Payment/Stripe/StripePaymentPlatformFeeExtractionService.php b/backend/app/Services/Domain/Payment/Stripe/StripePaymentPlatformFeeExtractionService.php
index af1add07d2..137b2f4620 100644
--- a/backend/app/Services/Domain/Payment/Stripe/StripePaymentPlatformFeeExtractionService.php
+++ b/backend/app/Services/Domain/Payment/Stripe/StripePaymentPlatformFeeExtractionService.php
@@ -16,27 +16,24 @@
class StripePaymentPlatformFeeExtractionService
{
public function __construct(
- private readonly StripeClientFactory $stripeClientFactory,
- private readonly OrderPaymentPlatformFeeService $orderPaymentPlatformFeeService,
+ private readonly StripeClientFactory $stripeClientFactory,
+ private readonly OrderPaymentPlatformFeeService $orderPaymentPlatformFeeService,
private readonly OrderPaymentPlatformFeeRepositoryInterface $orderPaymentPlatformFeeRepository,
- private readonly LoggerInterface $logger,
- )
- {
- }
+ private readonly LoggerInterface $logger,
+ ) {}
public function extractAndStorePlatformFee(
- OrderDomainObject $order,
- Charge $charge,
+ OrderDomainObject $order,
+ Charge $charge,
StripePaymentDomainObject $stripePayment
- ): void
- {
+ ): void {
try {
$this->logger->info(__('Extracting platform fee for order'), [
'order_id' => $order->getId(),
'charge_id' => $charge->id,
]);
- if (!$charge->balance_transaction || is_string($charge->balance_transaction)) {
+ if (! $charge->balance_transaction || is_string($charge->balance_transaction)) {
$this->logger->info(__('Retrieving balance transaction from Stripe'), [
'charge_id' => $charge->id,
'order_id' => $order->getId(),
@@ -58,11 +55,12 @@ public function extractAndStorePlatformFee(
$charge = $stripeClient->charges->retrieve($charge->id, $params, $opts);
}
- if (!$charge->balance_transaction || is_string($charge->balance_transaction)) {
+ if (! $charge->balance_transaction || is_string($charge->balance_transaction)) {
$this->logger->warning(__('No balance transaction found for charge'), [
'charge_id' => $charge->id,
'order_id' => $order->getId(),
]);
+
return;
}
@@ -79,6 +77,7 @@ public function extractAndStorePlatformFee(
'transaction_id' => $balanceTransaction->id,
'charge_id' => $charge->id,
]);
+
return;
}
$feeDetails = $this->extractFeeDetails($balanceTransaction);
@@ -183,9 +182,8 @@ private function extractApplicationFee(array $feeDetails): int
private function convertApplicationFeeToSettlementCurrency(
StripePaymentDomainObject $stripePayment,
$balanceTransaction
- ): array
- {
- if (!config('app.tax.eu_vat_handling_enabled')) {
+ ): array {
+ if (! config('app.tax.eu_vat_handling_enabled')) {
return [
'net' => null,
'vat' => null,
@@ -218,8 +216,8 @@ private function convertApplicationFeeToSettlementCurrency(
: $vatMajor;
// Convert to minor units (settlement currency)
- $netAmountMinorUnit = $netConverted !== null ? (int)round($netConverted * 100) : null;
- $vatAmountMinorUnit = $vatConverted !== null ? (int)round($vatConverted * 100) : null;
+ $netAmountMinorUnit = $netConverted !== null ? (int) round($netConverted * 100) : null;
+ $vatAmountMinorUnit = $vatConverted !== null ? (int) round($vatConverted * 100) : null;
return [
'net' => $netAmountMinorUnit,
diff --git a/backend/app/Services/Domain/Payment/Stripe/StripePaymentUpdateFromPaymentIntentService.php b/backend/app/Services/Domain/Payment/Stripe/StripePaymentUpdateFromPaymentIntentService.php
index c8af9fe915..5c7cc4680e 100644
--- a/backend/app/Services/Domain/Payment/Stripe/StripePaymentUpdateFromPaymentIntentService.php
+++ b/backend/app/Services/Domain/Payment/Stripe/StripePaymentUpdateFromPaymentIntentService.php
@@ -10,9 +10,7 @@
{
public function __construct(
private StripePaymentsRepository $stripePaymentsRepository,
- )
- {
- }
+ ) {}
public function updateStripePaymentInfo(PaymentIntent $paymentIntent, StripePaymentDomainObjectAbstract $stripePayment): void
{
diff --git a/backend/app/Services/Domain/Payment/Stripe/StripePayoutService.php b/backend/app/Services/Domain/Payment/Stripe/StripePayoutService.php
index cc8292f74b..69843b5342 100644
--- a/backend/app/Services/Domain/Payment/Stripe/StripePayoutService.php
+++ b/backend/app/Services/Domain/Payment/Stripe/StripePayoutService.php
@@ -2,9 +2,9 @@
namespace HiEvents\Services\Domain\Payment\Stripe;
-use HiEvents\Repository\Interfaces\StripePayoutsRepositoryInterface;
-use HiEvents\Repository\Interfaces\StripePaymentsRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderPaymentPlatformFeeRepositoryInterface;
+use HiEvents\Repository\Interfaces\StripePaymentsRepositoryInterface;
+use HiEvents\Repository\Interfaces\StripePayoutsRepositoryInterface;
use HiEvents\Services\Domain\Payment\Stripe\DTOs\StripePayoutCreationDTO;
use Psr\Log\LoggerInterface;
@@ -15,8 +15,7 @@ public function __construct(
private readonly StripePaymentsRepositoryInterface $stripePaymentsRepository,
private readonly OrderPaymentPlatformFeeRepositoryInterface $orderPaymentPlatformFeeRepository,
private readonly LoggerInterface $logger,
- ) {
- }
+ ) {}
public function createOrUpdatePayout(StripePayoutCreationDTO $dto): void
{
@@ -28,12 +27,13 @@ public function createOrUpdatePayout(StripePayoutCreationDTO $dto): void
if ($payments->isEmpty()) {
$this->logger->warning('No payments found for payout', ['payout_id' => $dto->payoutId]);
+
return;
}
// Get charge IDs to query order_payment_platform_fees
$chargeIds = $payments
- ->map(fn($payment) => $payment->getChargeId())
+ ->map(fn ($payment) => $payment->getChargeId())
->filter()
->unique()
->values()
@@ -41,6 +41,7 @@ public function createOrUpdatePayout(StripePayoutCreationDTO $dto): void
if (empty($chargeIds)) {
$this->logger->warning('No charge IDs found for payout payments', ['payout_id' => $dto->payoutId]);
+
return;
}
@@ -62,6 +63,7 @@ public function createOrUpdatePayout(StripePayoutCreationDTO $dto): void
// Still create the payout record without VAT data
$this->createOrUpdatePayoutRecord($dto, null, null, false);
+
return;
}
@@ -99,17 +101,18 @@ public function createOrUpdatePayout(StripePayoutCreationDTO $dto): void
'payout_currency' => $payoutCurrency,
'charge_id' => $platformFee->getChargeId(),
]);
+
continue; // Skip this payment
}
// Simple conversion: settlement currency (major units) → payout currency (minor units)
// Since currencies match, just multiply by 100
if ($vatMajor !== null) {
- $totalVatMinor += (int)round($vatMajor * 100);
+ $totalVatMinor += (int) round($vatMajor * 100);
}
if ($netMajor !== null) {
- $totalNetMinor += (int)round($netMajor * 100);
+ $totalNetMinor += (int) round($netMajor * 100);
}
}
@@ -121,8 +124,7 @@ private function createOrUpdatePayoutRecord(
?int $totalVatMinor,
?int $totalNetMinor,
bool $reconciled
- ): void
- {
+ ): void {
$attributes = [
'payout_id' => $dto->payoutId,
'stripe_platform' => $dto->stripePlatform,
diff --git a/backend/app/Services/Domain/Payment/Stripe/StripeRefundExpiredOrderService.php b/backend/app/Services/Domain/Payment/Stripe/StripeRefundExpiredOrderService.php
index 4336f53864..3f34595235 100644
--- a/backend/app/Services/Domain/Payment/Stripe/StripeRefundExpiredOrderService.php
+++ b/backend/app/Services/Domain/Payment/Stripe/StripeRefundExpiredOrderService.php
@@ -25,14 +25,12 @@
{
public function __construct(
private StripePaymentIntentRefundService $refundService,
- private Mailer $mailer,
- private LoggerInterface $logger,
- private EventRepositoryInterface $eventRepository,
- private StripeClientFactory $stripeClientFactory,
+ private Mailer $mailer,
+ private LoggerInterface $logger,
+ private EventRepositoryInterface $eventRepository,
+ private StripeClientFactory $stripeClientFactory,
- )
- {
- }
+ ) {}
/**
* @throws ApiErrorException
@@ -43,11 +41,10 @@ public function __construct(
* @throws StripeClientConfigurationException
*/
public function refundExpiredOrder(
- PaymentIntent $paymentIntent,
+ PaymentIntent $paymentIntent,
StripePaymentDomainObject $stripePayment,
- OrderDomainObject $order,
- ): void
- {
+ OrderDomainObject $order,
+ ): void {
$event = $this->eventRepository
->loadRelation(new Relationship(EventSettingDomainObject::class))
->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer'))
@@ -69,12 +66,12 @@ public function refundExpiredOrder(
$this->mailer
->to($order->getEmail())
->locale($order->getLocale())
- ->send(new PaymentSuccessButOrderExpiredMail(
+ ->send((new PaymentSuccessButOrderExpiredMail(
order: $order,
event: $event,
eventSettings: $event->getEventSettings(),
organizer: $event->getOrganizer(),
- ));
+ ))->beforeCommit());
$this->logger->info('Refunded expired order', [
'order_id' => $order->getId(),
diff --git a/backend/app/Services/Domain/Product/AvailableProductQuantitiesFetchService.php b/backend/app/Services/Domain/Product/AvailableProductQuantitiesFetchService.php
index d9c8869ea3..eb723fd492 100644
--- a/backend/app/Services/Domain/Product/AvailableProductQuantitiesFetchService.php
+++ b/backend/app/Services/Domain/Product/AvailableProductQuantitiesFetchService.php
@@ -5,10 +5,15 @@
use HiEvents\Constants;
use HiEvents\DomainObjects\CapacityAssignmentDomainObject;
use HiEvents\DomainObjects\Enums\CapacityAssignmentAppliesTo;
+use HiEvents\DomainObjects\Enums\ProductType;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
+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\Repository\Interfaces\OrderItemRepositoryInterface;
use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesDTO;
use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesResponseDTO;
use Illuminate\Config\Repository as Config;
@@ -19,34 +24,45 @@
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,
+ private readonly OrderItemRepositoryInterface $orderItemRepository,
+ ) {}
+
+ 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,
- ]);
+ $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 +73,75 @@ public function getAvailableProductQuantities(int $eventId, bool $ignoreCache =
return $dto;
});
+ $occurrence = null;
+ $occurrenceReserved = null;
+ if ($eventOccurrenceId !== null) {
+ $occurrence = $this->occurrenceRepository->findById($eventOccurrenceId);
+ if ($this->occurrenceLimitsCapacity($occurrence)) {
+ $occurrenceReserved = $this->orderItemRepository->getReservedQuantityForOccurrence($eventOccurrenceId);
+ }
+ $quantities = $this->applyOccurrenceCapacity($quantities, $occurrence, $occurrenceReserved);
+ }
+
$finalData = new AvailableProductQuantitiesResponseDTO(
productQuantities: $quantities,
- capacities: $capacities
+ capacities: $capacities,
+ occurrence: $occurrence,
+ occurrenceReservedQuantity: $occurrenceReserved,
);
- 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 occurrenceLimitsCapacity(?EventOccurrenceDomainObject $occurrence): bool
+ {
+ return $occurrence !== null
+ && ! $occurrence->isCancelled()
+ && ! $occurrence->isPast()
+ && $occurrence->getCapacity() !== null;
+ }
+
+ private function applyOccurrenceCapacity(
+ Collection $quantities,
+ ?EventOccurrenceDomainObject $occurrence,
+ ?int $reservedForOccurrence,
+ ): Collection {
+ 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;
+ }
+
+ $occurrenceAvailable = max(0, $occurrence->getCapacity() - $occurrence->getUsedCapacity() - $reservedForOccurrence);
+
+ return $quantities->map(function (AvailableProductQuantitiesDTO $dto) use ($occurrenceAvailable) {
+ if ($dto->product_type !== ProductType::TICKET->name) {
+ return $dto;
+ }
+
+ 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,
@@ -102,6 +172,7 @@ private function fetchReservedProductQuantities(int $eventId): Collection
products.id AS product_id,
product_prices.id AS product_price_id,
products.title AS product_title,
+ products.product_type AS product_type,
product_prices.label AS price_label,
product_prices.initial_quantity_available,
product_prices.quantity_sold,
@@ -128,23 +199,24 @@ 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,
+ 'product_type' => $row->product_type,
'price_label' => $row->price_label,
'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 +224,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/CreateProductService.php b/backend/app/Services/Domain/Product/CreateProductService.php
index ee7bbb8727..0d91eeada9 100644
--- a/backend/app/Services/Domain/Product/CreateProductService.php
+++ b/backend/app/Services/Domain/Product/CreateProductService.php
@@ -20,27 +20,24 @@
class CreateProductService
{
public function __construct(
- private readonly ProductRepositoryInterface $productRepository,
- private readonly DatabaseManager $databaseManager,
+ private readonly ProductRepositoryInterface $productRepository,
+ private readonly DatabaseManager $databaseManager,
private readonly TaxAndProductAssociationService $taxAndProductAssociationService,
- private readonly ProductPriceCreateService $priceCreateService,
- private readonly HtmlPurifierService $purifier,
- private readonly EventRepositoryInterface $eventRepository,
- private readonly ProductOrderingService $productOrderingService,
- private readonly DomainEventDispatcherService $domainEventDispatcherService,
- )
- {
- }
+ private readonly ProductPriceCreateService $priceCreateService,
+ private readonly HtmlPurifierService $purifier,
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly ProductOrderingService $productOrderingService,
+ private readonly DomainEventDispatcherService $domainEventDispatcherService,
+ ) {}
/**
* @throws Throwable
*/
public function createProduct(
ProductDomainObject $product,
- int $accountId,
- ?array $taxAndFeeIds = null,
- ): ProductDomainObject
- {
+ int $accountId,
+ ?array $taxAndFeeIds = null,
+ ): ProductDomainObject {
return $this->databaseManager->transaction(function () use ($accountId, $taxAndFeeIds, $product) {
$persistedProduct = $this->persistProduct($product);
@@ -102,10 +99,9 @@ private function persistProduct(ProductDomainObject $productsData): ProductDomai
*/
private function createProductTaxesAndFees(
ProductDomainObject $product,
- array $taxAndFeeIds,
- int $accountId,
- ): Collection
- {
+ array $taxAndFeeIds,
+ int $accountId,
+ ): Collection {
return $this->taxAndProductAssociationService->addTaxesToProduct(
new TaxAndProductAssociateParams(
productId: $product->getId(),
diff --git a/backend/app/Services/Domain/Product/DTO/AvailableProductQuantitiesDTO.php b/backend/app/Services/Domain/Product/DTO/AvailableProductQuantitiesDTO.php
index 01c68affaf..3cdd198b53 100644
--- a/backend/app/Services/Domain/Product/DTO/AvailableProductQuantitiesDTO.php
+++ b/backend/app/Services/Domain/Product/DTO/AvailableProductQuantitiesDTO.php
@@ -9,16 +9,15 @@
class AvailableProductQuantitiesDTO extends BaseDTO
{
public function __construct(
- public int $product_id,
- public int $price_id,
- public string $product_title,
- public ?string $price_label,
- public int $quantity_available,
- public int $quantity_reserved,
- public ?int $initial_quantity_available,
+ public int $product_id,
+ public int $price_id,
+ public string $product_title,
+ public ?string $price_label,
+ public int $quantity_available,
+ public int $quantity_reserved,
+ public ?int $initial_quantity_available,
+ public string $product_type,
/** @var Collection */
public ?Collection $capacities = null,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Domain/Product/DTO/AvailableProductQuantitiesResponseDTO.php b/backend/app/Services/Domain/Product/DTO/AvailableProductQuantitiesResponseDTO.php
index bc1842b23e..4913e2c0bc 100644
--- a/backend/app/Services/Domain/Product/DTO/AvailableProductQuantitiesResponseDTO.php
+++ b/backend/app/Services/Domain/Product/DTO/AvailableProductQuantitiesResponseDTO.php
@@ -4,16 +4,17 @@
use HiEvents\DataTransferObjects\BaseDTO;
use HiEvents\DomainObjects\CapacityAssignmentDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use Illuminate\Support\Collection;
class AvailableProductQuantitiesResponseDTO extends BaseDTO
{
public function __construct(
/** @var Collection */
- public Collection $productQuantities,
+ public Collection $productQuantities,
/** @var Collection */
public ?Collection $capacities = null,
- )
- {
- }
+ public ?EventOccurrenceDomainObject $occurrence = null,
+ public ?int $occurrenceReservedQuantity = null,
+ ) {}
}
diff --git a/backend/app/Services/Domain/Product/DTO/CreateProductDTO.php b/backend/app/Services/Domain/Product/DTO/CreateProductDTO.php
index aca5aab20e..6faadd15a6 100644
--- a/backend/app/Services/Domain/Product/DTO/CreateProductDTO.php
+++ b/backend/app/Services/Domain/Product/DTO/CreateProductDTO.php
@@ -4,7 +4,4 @@
use HiEvents\Services\Application\Handlers\Product\DTO\UpsertProductDTO;
-class CreateProductDTO extends UpsertProductDTO
-{
-
-}
+class CreateProductDTO extends UpsertProductDTO {}
diff --git a/backend/app/Services/Domain/Product/DTO/OrderProductPriceDTO.php b/backend/app/Services/Domain/Product/DTO/OrderProductPriceDTO.php
index 6e2bb4434d..322dff3792 100644
--- a/backend/app/Services/Domain/Product/DTO/OrderProductPriceDTO.php
+++ b/backend/app/Services/Domain/Product/DTO/OrderProductPriceDTO.php
@@ -7,10 +7,8 @@
class OrderProductPriceDTO extends BaseDTO
{
public function __construct(
- public readonly int $quantity,
- public readonly int $price_id,
+ public readonly int $quantity,
+ public readonly int $price_id,
public readonly ?float $price = null // used for donation products
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Domain/Product/DTO/PriceDTO.php b/backend/app/Services/Domain/Product/DTO/PriceDTO.php
index b5adf655ea..bd5761810b 100644
--- a/backend/app/Services/Domain/Product/DTO/PriceDTO.php
+++ b/backend/app/Services/Domain/Product/DTO/PriceDTO.php
@@ -9,7 +9,5 @@ class PriceDTO extends BaseDTO
public function __construct(
public float $price,
public ?float $price_before_discount = null,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Domain/Product/DTO/ProductPriceDTO.php b/backend/app/Services/Domain/Product/DTO/ProductPriceDTO.php
index a61636d00a..b2cd175a24 100644
--- a/backend/app/Services/Domain/Product/DTO/ProductPriceDTO.php
+++ b/backend/app/Services/Domain/Product/DTO/ProductPriceDTO.php
@@ -8,15 +8,13 @@
class ProductPriceDTO extends BaseDTO
{
public function __construct(
- public readonly float $price,
- public readonly ?string $label = null,
- public readonly ?string $sale_start_date = null,
- public readonly ?string $sale_end_date = null,
- public readonly ?int $initial_quantity_available = null,
- public readonly ?bool $is_hidden = false,
- public readonly ?int $id = null,
+ public readonly float $price,
+ public readonly ?string $label = null,
+ public readonly ?string $sale_start_date = null,
+ public readonly ?string $sale_end_date = null,
+ public readonly ?int $initial_quantity_available = null,
+ public readonly ?bool $is_hidden = false,
+ public readonly ?int $id = null,
public readonly ProductStatus $status = ProductStatus::ACTIVE,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Domain/Product/DeleteProductService.php b/backend/app/Services/Domain/Product/DeleteProductService.php
index 091b941180..b06c1f27bc 100644
--- a/backend/app/Services/Domain/Product/DeleteProductService.php
+++ b/backend/app/Services/Domain/Product/DeleteProductService.php
@@ -17,14 +17,12 @@
class DeleteProductService
{
public function __construct(
- private readonly ProductRepositoryInterface $productRepository,
+ private readonly ProductRepositoryInterface $productRepository,
private readonly ProductPriceRepositoryInterface $productPriceRepository,
- private readonly LoggerInterface $logger,
- private readonly DatabaseManager $databaseManager,
- private readonly DomainEventDispatcherService $domainEventDispatcherService,
- )
- {
- }
+ private readonly LoggerInterface $logger,
+ private readonly DatabaseManager $databaseManager,
+ private readonly DomainEventDispatcherService $domainEventDispatcherService,
+ ) {}
/**
* @throws CannotDeleteEntityException
@@ -35,7 +33,7 @@ public function deleteProduct(int $productId, int $eventId): void
$this->databaseManager->transaction(function () use ($productId, $eventId) {
if ($this->productRepository->hasAssociatedOrders($productId)) {
throw new CannotDeleteEntityException(
- __('You cannot delete this product because it has orders associated with it. You can hide it instead.')
+ __('You cannot delete this product because it has orders associated with it or is reserved by a checkout in progress. You can hide it instead.')
);
}
diff --git a/backend/app/Services/Domain/Product/EventProductValidationService.php b/backend/app/Services/Domain/Product/EventProductValidationService.php
index ae46396bda..d3c3d0de22 100644
--- a/backend/app/Services/Domain/Product/EventProductValidationService.php
+++ b/backend/app/Services/Domain/Product/EventProductValidationService.php
@@ -9,10 +9,8 @@
class EventProductValidationService
{
public function __construct(
- readonly private ProductRepositoryInterface $productRepository,
- )
- {
- }
+ private readonly ProductRepositoryInterface $productRepository,
+ ) {}
/**
* @throws UnrecognizedProductIdException
@@ -21,12 +19,12 @@ public function validateProductIds(array $productIds, int $eventId): void
{
$validProductIds = $this->productRepository->findWhere([
'event_id' => $eventId,
- ])->map(fn(ProductDomainObject $product) => $product->getId())
+ ])->map(fn (ProductDomainObject $product) => $product->getId())
->toArray();
$invalidProductIds = array_diff($productIds, $validProductIds);
- if (!empty($invalidProductIds)) {
+ if (! empty($invalidProductIds)) {
throw new UnrecognizedProductIdException(
__('Invalid product ids: :ids', ['ids' => implode(', ', $invalidProductIds)])
);
diff --git a/backend/app/Services/Domain/Product/Exception/UnrecognizedProductIdException.php b/backend/app/Services/Domain/Product/Exception/UnrecognizedProductIdException.php
index f788e3fe82..2f94e8930e 100644
--- a/backend/app/Services/Domain/Product/Exception/UnrecognizedProductIdException.php
+++ b/backend/app/Services/Domain/Product/Exception/UnrecognizedProductIdException.php
@@ -4,7 +4,4 @@
use Exception;
-class UnrecognizedProductIdException extends Exception
-{
-
-}
+class UnrecognizedProductIdException extends Exception {}
diff --git a/backend/app/Services/Domain/Product/ProductFilterService.php b/backend/app/Services/Domain/Product/ProductFilterService.php
index 36af1acc19..fe029663c0 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,8 +14,8 @@
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\ProductOccurrenceVisibilityRepositoryInterface;
use HiEvents\Services\Domain\Order\OrderPlatformFeePassThroughService;
use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesDTO;
use HiEvents\Services\Domain\Tax\TaxAndFeeCalculationService;
@@ -22,46 +23,69 @@
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 TaxAndFeeCalculationService $taxCalculationService,
+ private readonly ProductPriceService $productPriceService,
private readonly AvailableProductQuantitiesFetchService $fetchAvailableProductQuantitiesService,
- private readonly OrderPlatformFeePassThroughService $platformFeeService,
- private readonly AccountRepositoryInterface $accountRepository,
- private readonly EventRepositoryInterface $eventRepository,
- )
- {
- }
+ private readonly OrderPlatformFeePassThroughService $platformFeeService,
+ private readonly EventRepositoryInterface $eventRepository,
+ private readonly ProductOccurrenceVisibilityRepositoryInterface $productOccurrenceVisibilityRepository,
+ ) {}
/**
- * @param Collection $productsCategories
- * @param PromoCodeDomainObject|null $promoCode
- * @param bool $hideSoldOutProducts
- * @param bool $hideHiddenCategories
+ * @param Collection $productsCategories
* @return Collection
*/
public function filter(
- Collection $productsCategories,
+ Collection $productsCategories,
?PromoCodeDomainObject $promoCode = null,
- bool $hideSoldOutProducts = true,
- bool $hideHiddenCategories = true,
- ): Collection
- {
+ bool $hideSoldOutProducts = true,
+ ?int $eventOccurrenceId = null,
+ bool $hideHiddenCategories = true,
+ ): Collection {
if ($productsCategories->isEmpty()) {
return $productsCategories;
}
$products = $productsCategories
- ->flatMap(fn(ProductCategoryDomainObject $category) => $category->getProducts());
+ ->flatMap(fn (ProductCategoryDomainObject $category) => $category->getProducts());
+
+ $filteredCategories = $hideHiddenCategories
+ ? $productsCategories->reject(fn (ProductCategoryDomainObject $category) => $category->getIsHidden())
+ : $productsCategories;
if ($products->isEmpty()) {
- return $hideHiddenCategories
- ? $productsCategories->reject(fn(ProductCategoryDomainObject $category) => $category->getIsHidden())
- : $productsCategories;
+ return $filteredCategories;
+ }
+
+ $filteredProducts = $this->filterProducts($products, $promoCode, $hideSoldOutProducts, $eventOccurrenceId);
+
+ return $filteredCategories
+ ->each(fn (ProductCategoryDomainObject $category) => $category->setProducts(
+ $filteredProducts->where(
+ static fn (ProductDomainObject $product) => $product->getProductCategoryId() === $category->getId()
+ )
+ ));
+ }
+
+ /**
+ * @param Collection $products
+ * @return Collection
+ */
+ public function filterProducts(
+ Collection $products,
+ ?PromoCodeDomainObject $promoCode = null,
+ bool $hideSoldOutProducts = true,
+ ?int $eventOccurrenceId = null,
+ ): Collection {
+ if ($products->isEmpty()) {
+ return $products;
}
$eventId = $products->first()->getEventId();
@@ -69,50 +93,47 @@ public function filter(
$productQuantities = $this
->fetchAvailableProductQuantitiesService
- ->getAvailableProductQuantities($eventId);
+ ->getAvailableProductQuantities($eventId, eventOccurrenceId: $eventOccurrenceId);
$filteredProducts = $products
- ->map(fn(ProductDomainObject $product) => $this->processProduct($product, $productQuantities->productQuantities, $promoCode))
- ->reject(fn(ProductDomainObject $product) => $this->filterProduct($product, $promoCode, $hideSoldOutProducts))
- ->each(fn(ProductDomainObject $product) => $this->processProductPrices($product, $hideSoldOutProducts));
+ ->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));
- $filteredCategories = $hideHiddenCategories
- ? $productsCategories->reject(fn(ProductCategoryDomainObject $category) => $category->getIsHidden())
- : $productsCategories;
+ if ($eventOccurrenceId !== null) {
+ $filteredProducts = $this->filterByOccurrenceVisibility($filteredProducts, $eventOccurrenceId);
+ }
- return $filteredCategories
- ->each(fn(ProductCategoryDomainObject $category) => $category->setProducts(
- $filteredProducts->where(
- static fn(ProductDomainObject $product) => $product->getProductCategoryId() === $category->getId()
- )
- ));
+ return $filteredProducts->values();
}
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
{
- return $product->getIsHiddenWithoutPromoCode() && !(
- $promoCode
- && $promoCode->appliesToProduct($product)
- );
+ return $product->getIsHiddenWithoutPromoCode() && ! (
+ $promoCode
+ && $promoCode->appliesToProduct($product)
+ );
}
private function shouldProductBeDiscounted(?PromoCodeDomainObject $promoCode, ProductDomainObject $product): bool
@@ -121,27 +142,37 @@ private function shouldProductBeDiscounted(?PromoCodeDomainObject $promoCode, Pr
return false;
}
+ if ($promoCode?->isOrderLevelDiscount()) {
+ return false;
+ }
+
return $promoCode
&& $promoCode->isDiscountCode()
&& $promoCode->appliesToProduct($product);
}
/**
- * @param PromoCodeDomainObject|null $promoCode
- * @param ProductDomainObject $product
- * @param Collection $productQuantities
- * @return ProductDomainObject
+ * @param Collection $productQuantities
*/
private function processProduct(
- ProductDomainObject $product,
- Collection $productQuantities,
+ ProductDomainObject $product,
+ Collection $productQuantities,
?PromoCodeDomainObject $promoCode = null,
- ): ProductDomainObject
- {
+ ?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);
+ }
});
}
@@ -156,7 +187,7 @@ private function processProduct(
$productQuantities->each(function (AvailableProductQuantitiesDTO $quantity) use ($product) {
if ($quantity->capacities !== null && $quantity->capacities->isNotEmpty() && $quantity->product_id === $product->getId()) {
$product->setQuantityAvailable(
- $quantity->capacities->min(fn(CapacityAssignmentDomainObject $capacity) => $capacity->getAvailableCapacity())
+ $quantity->capacities->min(fn (CapacityAssignmentDomainObject $capacity) => $capacity->getAvailableCapacity())
);
}
});
@@ -165,11 +196,10 @@ private function processProduct(
}
private function filterProduct(
- ProductDomainObject $product,
+ ProductDomainObject $product,
?PromoCodeDomainObject $promoCode = null,
- bool $hideSoldOutProducts = true,
- ): bool
- {
+ bool $hideSoldOutProducts = true,
+ ): bool {
$hidden = false;
if ($this->isHiddenByPromoCode($product, $promoCode)) {
@@ -202,7 +232,7 @@ private function filterProduct(
private function processProductPrice(ProductDomainObject $product, ProductPriceDomainObject $price): void
{
- if (!$price->isFree()) {
+ if (! $price->isFree()) {
$taxAndFees = $this->taxCalculationService
->calculateTaxAndFeesForProductPrice($product, $price);
@@ -226,12 +256,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,
@@ -244,11 +274,11 @@ private function addPlatformFeeToProduct(ProductDomainObject $product): void
$existingTaxesAndFees = $product->getTaxAndFees() ?? collect();
$hasPlatformFee = $existingTaxesAndFees->contains(
- fn(TaxAndFeesDomainObject $fee) => $fee->getId() === OrderPlatformFeePassThroughService::PLATFORM_FEE_ID
+ fn (TaxAndFeesDomainObject $fee) => $fee->getId() === OrderPlatformFeePassThroughService::PLATFORM_FEE_ID
);
- if (!$hasPlatformFee) {
- $platformFeeDomainObject = (new TaxAndFeesDomainObject())
+ if (! $hasPlatformFee) {
+ $platformFeeDomainObject = (new TaxAndFeesDomainObject)
->setId(OrderPlatformFeePassThroughService::PLATFORM_FEE_ID)
->setAccountId(0)
->setName(OrderPlatformFeePassThroughService::getPlatformFeeName())
@@ -261,14 +291,13 @@ private function addPlatformFeeToProduct(ProductDomainObject $product): void
}
private function filterProductPrice(
- ProductDomainObject $product,
+ ProductDomainObject $product,
ProductPriceDomainObject $price,
- bool $hideSoldOutProducts = true
- ): bool
- {
+ bool $hideSoldOutProducts = true
+ ): bool {
$hidden = false;
- if (!$product->isTieredType()) {
+ if (! $product->isTieredType()) {
return false;
}
@@ -299,23 +328,40 @@ private function processProductPrices(ProductDomainObject $product, bool $hideSo
{
$product->setProductPrices(
$product->getProductPrices()
- ?->each(fn(ProductPriceDomainObject $price) => $this->processProductPrice($product, $price))
- ->reject(fn(ProductPriceDomainObject $price) => $this->filterProductPrice($product, $price, $hideSoldOutProducts))
+ ?->each(fn (ProductPriceDomainObject $price) => $this->processProductPrice($product, $price))
+ ->reject(fn (ProductPriceDomainObject $price) => $this->filterProductPrice($product, $price, $hideSoldOutProducts))
+ );
+ }
+
+ 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()) {
- return !$price->isSoldOut()
- && !$price->isBeforeSaleStartDate()
- && !$price->isAfterSaleEndDate()
- && !$price->getIsHidden();
+ return ! $price->isSoldOut()
+ && ! $price->isBeforeSaleStartDate()
+ && ! $price->isAfterSaleEndDate()
+ && ! $price->getIsHidden();
}
- return !$product->isSoldOut()
- && !$product->isBeforeSaleStartDate()
- && !$product->isAfterSaleEndDate()
- && !$product->getIsHidden();
+ return ! $product->isSoldOut()
+ && ! $product->isBeforeSaleStartDate()
+ && ! $product->isAfterSaleEndDate()
+ && ! $product->getIsHidden();
}
}
diff --git a/backend/app/Services/Domain/Product/ProductOrderingService.php b/backend/app/Services/Domain/Product/ProductOrderingService.php
index 11e9498ae5..7a0aaaff5e 100644
--- a/backend/app/Services/Domain/Product/ProductOrderingService.php
+++ b/backend/app/Services/Domain/Product/ProductOrderingService.php
@@ -9,16 +9,14 @@ class ProductOrderingService
{
public function __construct(
private readonly ProductRepositoryInterface $productRepository
- )
- {
- }
+ ) {}
public function getOrderForNewProduct(int $eventId, int $productCategoryId): int
{
return ($this->productRepository->findWhere([
- 'event_id' => $eventId,
- 'product_category_id' => $productCategoryId,
- ])
- ->max((static fn(ProductDomainObject $product) => $product->getOrder())) ?? 0) + 1;
+ 'event_id' => $eventId,
+ 'product_category_id' => $productCategoryId,
+ ])
+ ->max((static fn (ProductDomainObject $product) => $product->getOrder())) ?? 0) + 1;
}
}
diff --git a/backend/app/Services/Domain/Product/ProductPriceCreateService.php b/backend/app/Services/Domain/Product/ProductPriceCreateService.php
index 57fae4a659..8c8759e882 100644
--- a/backend/app/Services/Domain/Product/ProductPriceCreateService.php
+++ b/backend/app/Services/Domain/Product/ProductPriceCreateService.php
@@ -12,17 +12,14 @@ class ProductPriceCreateService
{
public function __construct(
private readonly ProductPriceRepository $productPriceRepository,
- )
- {
- }
+ ) {}
public function createPrices(
- int $productId,
- Collection $prices,
+ int $productId,
+ Collection $prices,
EventDomainObject $event,
- ): Collection
- {
- return (new Collection($prices->map(fn(ProductPriceDomainObject $price, int $index) => $this->productPriceRepository->create([
+ ): Collection {
+ return new Collection($prices->map(fn (ProductPriceDomainObject $price, int $index) => $this->productPriceRepository->create([
'product_id' => $productId,
'price' => $price->getPrice(),
'label' => $price->getLabel(),
@@ -35,6 +32,6 @@ public function createPrices(
'initial_quantity_available' => $price->getInitialQuantityAvailable(),
'is_hidden' => $price->getIsHidden(),
'order' => $index + 1,
- ]))));
+ ])));
}
}
diff --git a/backend/app/Services/Domain/Product/ProductPriceService.php b/backend/app/Services/Domain/Product/ProductPriceService.php
index aa29d65503..6b8a8f84dc 100644
--- a/backend/app/Services/Domain/Product/ProductPriceService.php
+++ b/backend/app/Services/Domain/Product/ProductPriceService.php
@@ -2,36 +2,41 @@
namespace HiEvents\Services\Domain\Product;
-use HiEvents\DomainObjects\Enums\PromoCodeDiscountTypeEnum;
use HiEvents\DomainObjects\Enums\ProductPriceType;
-use HiEvents\DomainObjects\PromoCodeDomainObject;
+use HiEvents\DomainObjects\Enums\PromoCodeDiscountTypeEnum;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
+use HiEvents\DomainObjects\PromoCodeDomainObject;
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,
+ ProductDomainObject $product,
ProductPriceDomainObject $price,
- ?PromoCodeDomainObject $promoCode
- ): float
- {
+ ?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
- ): PriceDTO
- {
- $price = $this->determineProductPrice($product, $productOrderDetail);
+ ProductDomainObject $product,
+ OrderProductPriceDTO $productOrderDetail,
+ ?PromoCodeDomainObject $promoCode,
+ ?int $eventOccurrenceId = null,
+ ): PriceDTO {
+ $price = $this->determineProductPrice($product, $productOrderDetail, $eventOccurrenceId);
if ($product->getType() === ProductPriceType::FREE->name) {
return new PriceDTO(0.00);
@@ -41,7 +46,7 @@ public function getPrice(
return new PriceDTO($price);
}
- if (!$promoCode || !$promoCode->appliesToProduct($product)) {
+ if (! $promoCode || ! $promoCode->appliesToProduct($product)) {
return new PriceDTO($price);
}
@@ -49,6 +54,10 @@ public function getPrice(
return new PriceDTO($price);
}
+ if ($promoCode->isOrderLevelDiscount()) {
+ return new PriceDTO($price);
+ }
+
if ($promoCode->isFixedDiscount()) {
$discountPrice = Currency::round($price - $promoCode->getDiscount());
} elseif ($promoCode->isPercentageDiscount()) {
@@ -65,13 +74,44 @@ public function getPrice(
);
}
- private function determineProductPrice(ProductDomainObject $product, OrderProductPriceDTO $productOrderDetails): float
+ public function getDonationMinimumPrice(ProductDomainObject $product, int $priceId, ?int $eventOccurrenceId): float
+ {
+ return $this->getOverridePrice($priceId, $eventOccurrenceId) ?? $product->getPrice();
+ }
+
+ private function determineProductPrice(ProductDomainObject $product, OrderProductPriceDTO $productOrderDetails, ?int $eventOccurrenceId = null): float
{
+ if ($product->getType() === ProductPriceType::DONATION->name) {
+ return max(
+ $this->getDonationMinimumPrice($product, $productOrderDetails->price_id, $eventOccurrenceId),
+ $productOrderDetails->price,
+ );
+ }
+
+ $overridePrice = $this->getOverridePrice($productOrderDetails->price_id, $eventOccurrenceId);
+
+ if ($overridePrice !== null) {
+ return $overridePrice;
+ }
+
return match ($product->getType()) {
- ProductPriceType::DONATION->name => max($product->getPrice(), $productOrderDetails->price),
ProductPriceType::PAID->name => $product->getPrice(),
ProductPriceType::FREE->name => 0.00,
ProductPriceType::TIERED->name => $product->getPriceById($productOrderDetails->price_id)?->getPrice()
};
}
+
+ private function getOverridePrice(int $priceId, ?int $eventOccurrenceId): ?float
+ {
+ if ($eventOccurrenceId === null) {
+ return null;
+ }
+
+ $override = $this->priceOverrideRepository->findFirstWhere([
+ 'event_occurrence_id' => $eventOccurrenceId,
+ 'product_price_id' => $priceId,
+ ]);
+
+ return $override === null ? null : (float) $override->getPrice();
+ }
}
diff --git a/backend/app/Services/Domain/Product/ProductPriceUpdateService.php b/backend/app/Services/Domain/Product/ProductPriceUpdateService.php
index 45dc5d0aef..bb7db749ee 100644
--- a/backend/app/Services/Domain/Product/ProductPriceUpdateService.php
+++ b/backend/app/Services/Domain/Product/ProductPriceUpdateService.php
@@ -18,9 +18,7 @@ class ProductPriceUpdateService
{
public function __construct(
private readonly ProductPriceRepository $productPriceRepository,
- )
- {
- }
+ ) {}
/**
* @throws CannotDeleteEntityException
@@ -28,12 +26,11 @@ public function __construct(
*/
public function updatePrices(
ProductDomainObject $product,
- UpsertProductDTO $productsData,
+ UpsertProductDTO $productsData,
/** @var Collection $existingPrices */
- Collection $existingPrices,
- EventDomainObject $event,
- ): void
- {
+ Collection $existingPrices,
+ EventDomainObject $event,
+ ): void {
$this->validateQuantityAvailable($productsData->prices, $existingPrices);
if ($productsData->type !== ProductPriceType::TIERED) {
@@ -105,7 +102,7 @@ private function validateQuantityAvailable(?Collection $prices, Collection $exis
}
/** @var ProductPriceDomainObject|null $existingPrice */
- $existingPrice = $existingPrices->first(fn(ProductPriceDomainObject $p) => $p->getId() === $price->id);
+ $existingPrice = $existingPrices->first(fn (ProductPriceDomainObject $p) => $p->getId() === $price->id);
if ($existingPrice === null) {
continue;
@@ -130,7 +127,7 @@ private function validateQuantityAvailable(?Collection $prices, Collection $exis
*/
private function deletePrices(?Collection $prices, Collection $existingPrices): void
{
- $pricesIds = $prices?->map(fn($price) => $price->id)->toArray();
+ $pricesIds = $prices?->map(fn ($price) => $price->id)->toArray();
$existingPrices->each(function (ProductPriceDomainObject $price) use ($pricesIds) {
if (in_array($price->getId(), $pricesIds, true)) {
diff --git a/backend/app/Services/Domain/Product/ProductQuantityUpdateService.php b/backend/app/Services/Domain/Product/ProductQuantityUpdateService.php
index 44fe678a22..64f4e50f33 100644
--- a/backend/app/Services/Domain/Product/ProductQuantityUpdateService.php
+++ b/backend/app/Services/Domain/Product/ProductQuantityUpdateService.php
@@ -3,31 +3,32 @@
namespace HiEvents\Services\Domain\Product;
use HiEvents\DomainObjects\CapacityAssignmentDomainObject;
+use HiEvents\DomainObjects\Enums\ProductType;
use HiEvents\DomainObjects\Generated\CapacityAssignmentDomainObjectAbstract;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
+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
{
public function __construct(
- private readonly ProductPriceRepositoryInterface $productPriceRepository,
- private readonly ProductRepositoryInterface $productRepository,
+ private readonly ProductPriceRepositoryInterface $productPriceRepository,
+ private readonly ProductRepositoryInterface $productRepository,
private readonly CapacityAssignmentRepositoryInterface $capacityAssignmentRepository,
- private readonly DatabaseManager $databaseManager,
- )
- {
- }
+ 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) {
@@ -35,16 +36,20 @@ public function increaseQuantitySold(int $priceId, int $adjustment = 1): void
});
$this->productPriceRepository->updateWhere([
- 'quantity_sold' => DB::raw('quantity_sold + ' . $adjustment),
+ 'quantity_sold' => DB::raw('quantity_sold + '.$adjustment),
], [
'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) {
@@ -52,10 +57,14 @@ public function decreaseQuantitySold(int $priceId, int $adjustment = 1): void
});
$this->productPriceRepository->updateWhere([
- 'quantity_sold' => DB::raw('GREATEST(0, quantity_sold - ' . $adjustment . ')'),
+ 'quantity_sold' => DB::raw('GREATEST(0, quantity_sold - '.$adjustment.')'),
], [
'id' => $priceId,
]);
+
+ if ($eventOccurrenceId !== null) {
+ $this->decreaseOccurrenceUsedCapacity($eventOccurrenceId, $adjustment);
+ }
});
}
@@ -66,29 +75,31 @@ 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);
});
}
- /**
- * @param OrderDomainObject $order
- * @return void
- */
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->getProductType() === ProductType::TICKET->name
+ ? $orderItem->getEventOccurrenceId()
+ : null,
+ );
}
}
private function increaseCapacityAssignmentUsedCapacity(int $capacityAssignmentId, int $adjustment = 1): void
{
$this->capacityAssignmentRepository->updateWhere([
- CapacityAssignmentDomainObjectAbstract::USED_CAPACITY => DB::raw(CapacityAssignmentDomainObjectAbstract::USED_CAPACITY . ' + ' . $adjustment),
+ CapacityAssignmentDomainObjectAbstract::USED_CAPACITY => DB::raw(CapacityAssignmentDomainObjectAbstract::USED_CAPACITY.' + '.$adjustment),
], [
'id' => $capacityAssignmentId,
]);
@@ -97,14 +108,31 @@ private function increaseCapacityAssignmentUsedCapacity(int $capacityAssignmentI
private function decreaseCapacityAssignmentUsedCapacity(int $capacityAssignmentId, int $adjustment = 1): void
{
$this->capacityAssignmentRepository->updateWhere([
- CapacityAssignmentDomainObjectAbstract::USED_CAPACITY => DB::raw('GREATEST(0, ' . CapacityAssignmentDomainObjectAbstract::USED_CAPACITY . ' - ' . $adjustment . ')'),
+ CapacityAssignmentDomainObjectAbstract::USED_CAPACITY => DB::raw('GREATEST(0, '.CapacityAssignmentDomainObjectAbstract::USED_CAPACITY.' - '.$adjustment.')'),
], [
'id' => $capacityAssignmentId,
]);
}
+ private function increaseOccurrenceUsedCapacity(int $occurrenceId, int $adjustment): void
+ {
+ $this->occurrenceRepository->updateWhere([
+ 'used_capacity' => DB::raw('used_capacity + '.$adjustment),
+ ], [
+ 'id' => $occurrenceId,
+ ]);
+ }
+
+ private function decreaseOccurrenceUsedCapacity(int $occurrenceId, int $adjustment): void
+ {
+ $this->occurrenceRepository->updateWhere([
+ 'used_capacity' => DB::raw('GREATEST(0, used_capacity - '.$adjustment.')'),
+ ], [
+ 'id' => $occurrenceId,
+ ]);
+ }
+
/**
- * @param int $priceId
* @return Collection
*/
private function getCapacityAssignments(int $priceId): Collection
diff --git a/backend/app/Services/Domain/ProductCategory/CreateProductCategoryService.php b/backend/app/Services/Domain/ProductCategory/CreateProductCategoryService.php
index 9ca7b8bc41..d186e63a40 100644
--- a/backend/app/Services/Domain/ProductCategory/CreateProductCategoryService.php
+++ b/backend/app/Services/Domain/ProductCategory/CreateProductCategoryService.php
@@ -10,9 +10,7 @@ class CreateProductCategoryService
{
public function __construct(
private readonly ProductCategoryRepositoryInterface $productCategoryRepository,
- )
- {
- }
+ ) {}
public function createCategory(ProductCategoryDomainObject $productCategoryDomainObject): ProductCategoryDomainObject
{
@@ -21,7 +19,7 @@ public function createCategory(ProductCategoryDomainObject $productCategoryDomai
public function createDefaultProductCategory(EventDomainObject $event): void
{
- $this->createCategory((new ProductCategoryDomainObject())
+ $this->createCategory((new ProductCategoryDomainObject)
->setEventId($event->getId())
->setName(__('Tickets'))
->setIsHidden(false)
diff --git a/backend/app/Services/Domain/ProductCategory/DeleteProductCategoryService.php b/backend/app/Services/Domain/ProductCategory/DeleteProductCategoryService.php
index f48765cfb6..91f7612ddb 100644
--- a/backend/app/Services/Domain/ProductCategory/DeleteProductCategoryService.php
+++ b/backend/app/Services/Domain/ProductCategory/DeleteProductCategoryService.php
@@ -17,13 +17,11 @@ class DeleteProductCategoryService
{
public function __construct(
private readonly ProductCategoryRepositoryInterface $productCategoryRepository,
- private readonly ProductRepositoryInterface $productRepository,
- private readonly DeleteProductService $deleteProductService,
- private readonly LoggerInterface $logger,
- private readonly DatabaseManager $databaseManager,
- )
- {
- }
+ private readonly ProductRepositoryInterface $productRepository,
+ private readonly DeleteProductService $deleteProductService,
+ private readonly LoggerInterface $logger,
+ private readonly DatabaseManager $databaseManager,
+ ) {}
/**
* @throws Throwable
@@ -62,7 +60,7 @@ private function deleteCategoryProducts(int $productCategoryId, int $eventId): v
]
);
- $productsWhichCanNotBeDeleted = new Collection();
+ $productsWhichCanNotBeDeleted = new Collection;
foreach ($productsToDelete as $product) {
try {
@@ -75,7 +73,7 @@ private function deleteCategoryProducts(int $productCategoryId, int $eventId): v
if ($productsWhichCanNotBeDeleted->isNotEmpty()) {
throw new CannotDeleteEntityException(
__('You cannot delete this product category because it contains the following products: :products. These products are linked to existing orders. Please move the :product_name to another category before attempting to delete this one.', [
- 'products' => $productsWhichCanNotBeDeleted->map(fn($product) => $product->getTitle())->implode(', '),
+ 'products' => $productsWhichCanNotBeDeleted->map(fn ($product) => $product->getTitle())->implode(', '),
'product_name' => $productsWhichCanNotBeDeleted->count() > 1 ? __('products') : __('product'),
])
);
diff --git a/backend/app/Services/Domain/ProductCategory/GetProductCategoryService.php b/backend/app/Services/Domain/ProductCategory/GetProductCategoryService.php
index ae03e4dd81..a1cbe9dba4 100644
--- a/backend/app/Services/Domain/ProductCategory/GetProductCategoryService.php
+++ b/backend/app/Services/Domain/ProductCategory/GetProductCategoryService.php
@@ -14,9 +14,7 @@ class GetProductCategoryService
{
public function __construct(
private readonly ProductCategoryRepositoryInterface $productCategoryRepository,
- )
- {
- }
+ ) {}
public function getCategory(int $categoryId, int $eventId): ProductCategoryDomainObject
{
@@ -36,7 +34,7 @@ public function getCategory(int $categoryId, int $eventId): ProductCategoryDomai
]
);
- if (!$category) {
+ if (! $category) {
throw new ResourceNotFoundException(
__('The product category with ID :id was not found.', ['id' => $categoryId])
);
diff --git a/backend/app/Services/Domain/PromoCode/CreatePromoCodeService.php b/backend/app/Services/Domain/PromoCode/CreatePromoCodeService.php
index d8cdaa5ec5..c1a24f5c97 100644
--- a/backend/app/Services/Domain/PromoCode/CreatePromoCodeService.php
+++ b/backend/app/Services/Domain/PromoCode/CreatePromoCodeService.php
@@ -15,12 +15,10 @@
class CreatePromoCodeService
{
public function __construct(
- private readonly PromoCodeRepositoryInterface $promoCodeRepository,
+ private readonly PromoCodeRepositoryInterface $promoCodeRepository,
private readonly EventProductValidationService $eventProductValidationService,
- private readonly EventRepositoryInterface $eventRepository,
- )
- {
- }
+ private readonly EventRepositoryInterface $eventRepository,
+ ) {}
/**
* @throws ResourceConflictException
@@ -30,7 +28,7 @@ public function createPromoCode(PromoCodeDomainObject $promoCode): PromoCodeDoma
{
$this->checkForDuplicateCode($promoCode);
- if (!empty($promoCode->getApplicableProductIds())) {
+ if (! empty($promoCode->getApplicableProductIds())) {
$this->eventProductValidationService->validateProductIds(
productIds: $promoCode->getApplicableProductIds(),
eventId: $promoCode->getEventId()
@@ -46,6 +44,7 @@ public function createPromoCode(PromoCodeDomainObject $promoCode): PromoCodeDoma
? 0.00
: $promoCode->getDiscount(),
PromoCodeDomainObjectAbstract::DISCOUNT_TYPE => $promoCode->getDiscountType(),
+ PromoCodeDomainObjectAbstract::DISCOUNT_APPLIES_TO => $promoCode->getDiscountAppliesTo(),
PromoCodeDomainObjectAbstract::EXPIRY_DATE => $promoCode->getExpiryDate()
? DateHelper::convertToUTC($promoCode->getExpiryDate(), $event->getTimezone())
: null,
diff --git a/backend/app/Services/Domain/PromoCode/PromoCodeUsageValidationService.php b/backend/app/Services/Domain/PromoCode/PromoCodeUsageValidationService.php
index f62ac2776e..9da6af91f1 100644
--- a/backend/app/Services/Domain/PromoCode/PromoCodeUsageValidationService.php
+++ b/backend/app/Services/Domain/PromoCode/PromoCodeUsageValidationService.php
@@ -9,9 +9,7 @@ class PromoCodeUsageValidationService
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
- )
- {
- }
+ ) {}
/**
* Usage is derived from a live count of orders currently holding the code
@@ -20,7 +18,7 @@ public function __construct(
*/
public function isPromoCodeUsable(?PromoCodeDomainObject $promoCode): bool
{
- if (!$promoCode?->isValid()) {
+ if (! $promoCode?->isValid()) {
return false;
}
diff --git a/backend/app/Services/Domain/Question/CreateQuestionService.php b/backend/app/Services/Domain/Question/CreateQuestionService.php
index c5b2c79310..ba35fb90d5 100644
--- a/backend/app/Services/Domain/Question/CreateQuestionService.php
+++ b/backend/app/Services/Domain/Question/CreateQuestionService.php
@@ -13,21 +13,18 @@ class CreateQuestionService
{
public function __construct(
private readonly QuestionRepositoryInterface $questionRepository,
- private readonly DatabaseManager $databaseManager,
- private readonly HtmlPurifierService $purifier,
- )
- {
- }
+ private readonly DatabaseManager $databaseManager,
+ private readonly HtmlPurifierService $purifier,
+ ) {}
/**
* @throws Throwable
*/
public function createQuestion(
QuestionDomainObject $question,
- array $productIds,
- ): QuestionDomainObject
- {
- return $this->databaseManager->transaction(fn() => $this->questionRepository->create([
+ array $productIds,
+ ): QuestionDomainObject {
+ return $this->databaseManager->transaction(fn () => $this->questionRepository->create([
QuestionDomainObjectAbstract::TITLE => $question->getTitle(),
QuestionDomainObjectAbstract::EVENT_ID => $question->getEventId(),
QuestionDomainObjectAbstract::BELONGS_TO => $question->getBelongsTo(),
diff --git a/backend/app/Services/Domain/Question/EditQuestionAnswerService.php b/backend/app/Services/Domain/Question/EditQuestionAnswerService.php
index 899f97928a..4083ad2f49 100644
--- a/backend/app/Services/Domain/Question/EditQuestionAnswerService.php
+++ b/backend/app/Services/Domain/Question/EditQuestionAnswerService.php
@@ -14,10 +14,8 @@ class EditQuestionAnswerService
{
public function __construct(
private readonly QuestionAnswerRepositoryInterface $questionAnswerRepository,
- private readonly LoggerInterface $logger,
- )
- {
- }
+ private readonly LoggerInterface $logger,
+ ) {}
/**
* @throws InvalidAnswerException
@@ -44,7 +42,7 @@ public function editQuestionAnswer(int $eventId, int $questionAnswerId, null|str
throw new ResourceNotFoundException('Question answer does not belong to the event');
}
- if (!$question->isAnswerValid($answer)) {
+ if (! $question->isAnswerValid($answer)) {
$this->logger->error('Invalid answer', [
'question_id' => $question->getId(),
'answer' => $answer,
diff --git a/backend/app/Services/Domain/Question/EditQuestionService.php b/backend/app/Services/Domain/Question/EditQuestionService.php
index 11a393a093..e072804666 100644
--- a/backend/app/Services/Domain/Question/EditQuestionService.php
+++ b/backend/app/Services/Domain/Question/EditQuestionService.php
@@ -14,20 +14,17 @@ class EditQuestionService
{
public function __construct(
private readonly QuestionRepositoryInterface $questionRepository,
- private readonly DatabaseManager $databaseManager,
- private readonly HtmlPurifierService $purifier,
- )
- {
- }
+ private readonly DatabaseManager $databaseManager,
+ private readonly HtmlPurifierService $purifier,
+ ) {}
/**
* @throws Throwable
*/
public function editQuestion(
QuestionDomainObject $question,
- array $productIds,
- ): QuestionDomainObject
- {
+ array $productIds,
+ ): QuestionDomainObject {
return $this->databaseManager->transaction(function () use ($question, $productIds) {
$this->questionRepository->updateQuestion(
questionId: $question->getId(),
diff --git a/backend/app/Services/Domain/Question/Exception/InvalidAnswerException.php b/backend/app/Services/Domain/Question/Exception/InvalidAnswerException.php
index 5233e65b2f..c9ac03cc63 100644
--- a/backend/app/Services/Domain/Question/Exception/InvalidAnswerException.php
+++ b/backend/app/Services/Domain/Question/Exception/InvalidAnswerException.php
@@ -4,7 +4,4 @@
use Exception;
-class InvalidAnswerException extends Exception
-{
-
-}
+class InvalidAnswerException extends Exception {}
diff --git a/backend/app/Services/Domain/Question/QuestionAnswerFormatter.php b/backend/app/Services/Domain/Question/QuestionAnswerFormatter.php
index e1986414e7..5a05666d48 100644
--- a/backend/app/Services/Domain/Question/QuestionAnswerFormatter.php
+++ b/backend/app/Services/Domain/Question/QuestionAnswerFormatter.php
@@ -19,7 +19,7 @@ public function getAnswerAsText(string|array $answer, QuestionTypeEnum $question
];
return implode(', ', array_filter($addressLines, static function ($line) {
- return !empty($line);
+ return ! empty($line);
}));
}
diff --git a/backend/app/Services/Domain/Report/AbstractOrganizerReportService.php b/backend/app/Services/Domain/Report/AbstractOrganizerReportService.php
index 102dd3c57f..1370137fd5 100644
--- a/backend/app/Services/Domain/Report/AbstractOrganizerReportService.php
+++ b/backend/app/Services/Domain/Report/AbstractOrganizerReportService.php
@@ -13,20 +13,17 @@ abstract class AbstractOrganizerReportService
private const CACHE_TTL_SECONDS = 30;
public function __construct(
- private readonly Repository $cache,
- private readonly DatabaseManager $queryBuilder,
- private readonly OrganizerRepositoryInterface $organizerRepository,
- )
- {
- }
+ private readonly Repository $cache,
+ private readonly DatabaseManager $queryBuilder,
+ private readonly OrganizerRepositoryInterface $organizerRepository,
+ ) {}
public function generateReport(
- int $organizerId,
+ int $organizerId,
?string $currency = null,
?Carbon $startDate = null,
?Carbon $endDate = null,
- ): Collection
- {
+ ): Collection {
$organizer = $this->organizerRepository->findById($organizerId);
$timezone = $organizer->getTimezone();
@@ -40,7 +37,7 @@ public function generateReport(
$reportResults = $this->cache->remember(
key: $this->getCacheKey($organizerId, $currency, $startDate, $endDate),
ttl: Carbon::now()->addSeconds(self::CACHE_TTL_SECONDS),
- callback: fn() => $this->queryBuilder->select(
+ callback: fn () => $this->queryBuilder->select(
$this->getSqlQuery($startDate, $endDate, $currency),
[
'organizer_id' => $organizerId,
@@ -59,11 +56,12 @@ protected function buildCurrencyFilter(string $column, ?string $currency): strin
return '';
}
$escapedCurrency = addslashes($currency);
+
return "AND $column = '$escapedCurrency'";
}
protected function getCacheKey(int $organizerId, ?string $currency, ?Carbon $startDate, ?Carbon $endDate): string
{
- return static::class . "$organizerId.$currency.{$startDate?->toDateString()}.{$endDate?->toDateString()}";
+ return static::class."$organizerId.$currency.{$startDate?->toDateString()}.{$endDate?->toDateString()}";
}
}
diff --git a/backend/app/Services/Domain/Report/AbstractReportService.php b/backend/app/Services/Domain/Report/AbstractReportService.php
index 5ff76bfdb9..ffbceb58ed 100644
--- a/backend/app/Services/Domain/Report/AbstractReportService.php
+++ b/backend/app/Services/Domain/Report/AbstractReportService.php
@@ -11,14 +11,12 @@
abstract class AbstractReportService
{
public function __construct(
- private readonly Repository $cache,
- private readonly DatabaseManager $queryBuilder,
+ private readonly Repository $cache,
+ private readonly DatabaseManager $queryBuilder,
private readonly EventRepositoryInterface $eventRepository,
- )
- {
- }
+ ) {}
- 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 +29,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,
- ]
+ callback: fn () => $this->queryBuilder->select(
+ $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/DTO/PaginatedReportDTO.php b/backend/app/Services/Domain/Report/DTO/PaginatedReportDTO.php
index dde8f813af..664799137b 100644
--- a/backend/app/Services/Domain/Report/DTO/PaginatedReportDTO.php
+++ b/backend/app/Services/Domain/Report/DTO/PaginatedReportDTO.php
@@ -9,13 +9,11 @@ class PaginatedReportDTO extends BaseDataObject
{
public function __construct(
public readonly Collection $data,
- public readonly int $total,
- public readonly int $page,
- public readonly int $perPage,
- public readonly int $lastPage,
- )
- {
- }
+ public readonly int $total,
+ public readonly int $page,
+ public readonly int $perPage,
+ public readonly int $lastPage,
+ ) {}
public function toArray(): array
{
diff --git a/backend/app/Services/Domain/Report/Exception/InvalidDateRange.php b/backend/app/Services/Domain/Report/Exception/InvalidDateRange.php
index 56f5e6baa3..9c60d3c7f3 100644
--- a/backend/app/Services/Domain/Report/Exception/InvalidDateRange.php
+++ b/backend/app/Services/Domain/Report/Exception/InvalidDateRange.php
@@ -4,7 +4,4 @@
use Exception;
-class InvalidDateRange extends Exception
-{
-
-}
+class InvalidDateRange extends Exception {}
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/OrganizerReports/PlatformFeesReport.php b/backend/app/Services/Domain/Report/OrganizerReports/PlatformFeesReport.php
index 117a7ea481..ca70e61f2c 100644
--- a/backend/app/Services/Domain/Report/OrganizerReports/PlatformFeesReport.php
+++ b/backend/app/Services/Domain/Report/OrganizerReports/PlatformFeesReport.php
@@ -16,20 +16,19 @@ class PlatformFeesReport
private const CACHE_TTL_SECONDS = 30;
public function __construct(
- private readonly Repository $cache,
- private readonly DatabaseManager $queryBuilder,
+ private readonly Repository $cache,
+ private readonly DatabaseManager $queryBuilder,
private readonly OrganizerRepositoryInterface $organizerRepository,
- ) {
- }
+ ) {}
public function generateReport(
- int $organizerId,
+ int $organizerId,
?string $currency = null,
?Carbon $startDate = null,
?Carbon $endDate = null,
- ?int $eventId = null,
- int $page = 1,
- int $perPage = 1000,
+ ?int $eventId = null,
+ int $page = 1,
+ int $perPage = 1000,
): PaginatedReportDTO {
$organizer = $this->organizerRepository->findById($organizerId);
$timezone = $organizer->getTimezone();
@@ -44,15 +43,15 @@ public function generateReport(
$cacheKey = $this->getCacheKeyWithEvent($organizerId, $currency, $startDate, $endDate, $eventId, $page, $perPage);
$total = $this->cache->remember(
- key: $cacheKey . '.count',
+ key: $cacheKey.'.count',
ttl: Carbon::now()->addSeconds(self::CACHE_TTL_SECONDS),
- callback: fn() => $this->getCount($organizerId, $startDate, $endDate, $currency, $eventId)
+ callback: fn () => $this->getCount($organizerId, $startDate, $endDate, $currency, $eventId)
);
$results = $this->cache->remember(
key: $cacheKey,
ttl: Carbon::now()->addSeconds(self::CACHE_TTL_SECONDS),
- callback: fn() => $this->queryBuilder->select(
+ callback: fn () => $this->queryBuilder->select(
$this->buildSqlQuery($startDate, $endDate, $currency, $eventId, $page, $perPage),
[
'organizer_id' => $organizerId,
@@ -179,12 +178,13 @@ private function buildEventFilter(?int $eventId): string
if ($eventId === null) {
return '';
}
+
return "AND e.id = $eventId";
}
private function getCacheKeyWithEvent(int $organizerId, ?string $currency, ?Carbon $startDate, ?Carbon $endDate, ?int $eventId, int $page, int $perPage): string
{
- return static::class . "$organizerId.$currency.{$startDate?->toDateString()}.{$endDate?->toDateString()}.$eventId.$page.$perPage";
+ return static::class."$organizerId.$currency.{$startDate?->toDateString()}.{$endDate?->toDateString()}.$eventId.$page.$perPage";
}
private function buildCurrencyFilter(string $column, ?string $currency): string
@@ -193,6 +193,7 @@ private function buildCurrencyFilter(string $column, ?string $currency): string
return '';
}
$escapedCurrency = addslashes($currency);
+
return "AND $column = '$escapedCurrency'";
}
}
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 <<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..6a3441f81a 100644
--- a/backend/app/Services/Domain/SelfService/OrderAuditLogService.php
+++ b/backend/app/Services/Domain/SelfService/OrderAuditLogService.php
@@ -77,4 +77,25 @@ public function logEmailResent(
'user_agent' => $userAgent,
]);
}
+
+ 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..b38e9758ec 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;
@@ -62,7 +63,7 @@ public function editAttendee(
$emailChanged = true;
}
- if (!empty($updateData)) {
+ if (! empty($updateData)) {
$oldEmail = $attendee->getEmail();
if ($emailChanged) {
@@ -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/Sitemap/SitemapGeneratorService.php b/backend/app/Services/Domain/Sitemap/SitemapGeneratorService.php
index a702ba3b5d..38f187fdaa 100644
--- a/backend/app/Services/Domain/Sitemap/SitemapGeneratorService.php
+++ b/backend/app/Services/Domain/Sitemap/SitemapGeneratorService.php
@@ -14,21 +14,33 @@
class SitemapGeneratorService
{
private const SITEMAP_NAMESPACE = 'http://www.sitemaps.org/schemas/sitemap/0.9';
+
private const XML_VERSION = '1.0';
+
private const XML_ENCODING = 'UTF-8';
+
private const INDENT_STRING = ' ';
private const CHANGEFREQ_DAILY = 'daily';
+
private const CHANGEFREQ_WEEKLY = 'weekly';
+
private const PRIORITY_HIGH = '0.8';
+
private const PRIORITY_MEDIUM = '0.6';
+
private const PRIORITY_LOW = '0.5';
private const DEFAULT_EVENT_SLUG = 'event';
+
private const DEFAULT_ORGANIZER_SLUG = 'organizer';
+
private const EVENT_URL_PATTERN = '/event/%d/%s';
+
private const ORGANIZER_URL_PATTERN = '/events/%d/%s';
+
private const SITEMAP_EVENTS_URL_PATTERN = '/sitemap-events-%d.xml';
+
private const SITEMAP_ORGANIZERS_URL_PATTERN = '/sitemap-organizers-%d.xml';
public function generateSitemapIndex(
@@ -44,11 +56,11 @@ public function generateSitemapIndex(
$writer->writeAttribute('xmlns', self::SITEMAP_NAMESPACE);
for ($page = 1; $page <= $totalEventPages; $page++) {
- $this->writeSitemapEntry($writer, $baseUrl . sprintf(self::SITEMAP_EVENTS_URL_PATTERN, $page), $lastMod);
+ $this->writeSitemapEntry($writer, $baseUrl.sprintf(self::SITEMAP_EVENTS_URL_PATTERN, $page), $lastMod);
}
for ($page = 1; $page <= $totalOrganizerPages; $page++) {
- $this->writeSitemapEntry($writer, $baseUrl . sprintf(self::SITEMAP_ORGANIZERS_URL_PATTERN, $page), $lastMod);
+ $this->writeSitemapEntry($writer, $baseUrl.sprintf(self::SITEMAP_ORGANIZERS_URL_PATTERN, $page), $lastMod);
}
$writer->endElement();
@@ -66,7 +78,7 @@ private function writeSitemapEntry(XMLWriter $writer, string $loc, string $lastM
}
/**
- * @param Collection $events
+ * @param Collection $events
*/
public function generateEventsSitemap(Collection $events, string $baseUrl): string
{
@@ -90,7 +102,7 @@ public function generateEventsSitemap(Collection $events, string $baseUrl): stri
private function createXmlWriter(): XMLWriter
{
- $writer = new XMLWriter();
+ $writer = new XMLWriter;
$writer->openMemory();
$writer->setIndent(true);
$writer->setIndentString(self::INDENT_STRING);
@@ -101,7 +113,7 @@ private function createXmlWriter(): XMLWriter
private function writeEventUrl(XMLWriter $writer, EventDomainObject $event, string $baseUrl, Carbon $now): void
{
$slug = Str::slug($event->getTitle()) ?: self::DEFAULT_EVENT_SLUG;
- $eventUrl = $baseUrl . sprintf(self::EVENT_URL_PATTERN, $event->getId(), $slug);
+ $eventUrl = $baseUrl.sprintf(self::EVENT_URL_PATTERN, $event->getId(), $slug);
$isUpcoming = $this->isEventUpcoming($event, $now);
$lastMod = Carbon::parse($event->getUpdatedAt())->toAtomString();
@@ -122,7 +134,7 @@ private function isEventUpcoming(EventDomainObject $event, Carbon $now): bool
}
/**
- * @param Collection $organizers
+ * @param Collection $organizers
*/
public function generateOrganizersSitemap(Collection $organizers, string $baseUrl): string
{
@@ -145,7 +157,7 @@ public function generateOrganizersSitemap(Collection $organizers, string $baseUr
private function writeOrganizerUrl(XMLWriter $writer, OrganizerDomainObject $organizer, string $baseUrl): void
{
$slug = Str::slug($organizer->getName()) ?: self::DEFAULT_ORGANIZER_SLUG;
- $organizerUrl = $baseUrl . sprintf(self::ORGANIZER_URL_PATTERN, $organizer->getId(), $slug);
+ $organizerUrl = $baseUrl.sprintf(self::ORGANIZER_URL_PATTERN, $organizer->getId(), $slug);
$lastMod = Carbon::parse($organizer->getUpdatedAt())->toAtomString();
$writer->startElement('url');
diff --git a/backend/app/Services/Domain/Tax/DTO/TaxAndProductAssociateParams.php b/backend/app/Services/Domain/Tax/DTO/TaxAndProductAssociateParams.php
index f3b2b5fa70..59e9987189 100644
--- a/backend/app/Services/Domain/Tax/DTO/TaxAndProductAssociateParams.php
+++ b/backend/app/Services/Domain/Tax/DTO/TaxAndProductAssociateParams.php
@@ -8,7 +8,5 @@ public function __construct(
public readonly int $productId,
public readonly int $accountId,
public readonly array $taxAndFeeIds,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Domain/Tax/DTO/TaxCalculationResponse.php b/backend/app/Services/Domain/Tax/DTO/TaxCalculationResponse.php
index 08b9018c29..2baf98bf81 100644
--- a/backend/app/Services/Domain/Tax/DTO/TaxCalculationResponse.php
+++ b/backend/app/Services/Domain/Tax/DTO/TaxCalculationResponse.php
@@ -10,7 +10,5 @@ public function __construct(
public readonly float $feeTotal,
public readonly float $taxTotal,
public readonly array $rollUp,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Domain/Tax/TaxAndFeeCalculationService.php b/backend/app/Services/Domain/Tax/TaxAndFeeCalculationService.php
index ac9c11da4f..a268fd1779 100644
--- a/backend/app/Services/Domain/Tax/TaxAndFeeCalculationService.php
+++ b/backend/app/Services/Domain/Tax/TaxAndFeeCalculationService.php
@@ -19,26 +19,24 @@ public function __construct(TaxAndFeeRollupService $taxRollupService)
}
public function calculateTaxAndFeesForProductPrice(
- ProductDomainObject $product,
+ ProductDomainObject $product,
ProductPriceDomainObject $price,
- ): TaxCalculationResponse
- {
+ ): TaxCalculationResponse {
return $this->calculateTaxAndFeesForProduct($product, $price->getPrice());
}
public function calculateTaxAndFeesForProduct(
ProductDomainObject $product,
- float $price,
- int $quantity = 1
- ): TaxCalculationResponse
- {
+ float $price,
+ int $quantity = 1
+ ): TaxCalculationResponse {
$this->taxRollupService->resetRollUp();
$fees = $product->getFees()
- ?->sum(fn($taxOrFee) => $this->calculateFee($taxOrFee, $price, $quantity)) ?: 0.00;
+ ?->sum(fn ($taxOrFee) => $this->calculateFee($taxOrFee, $price, $quantity)) ?: 0.00;
$taxFees = $product->getTaxRates()
- ?->sum(fn($taxOrFee) => $this->calculateFee($taxOrFee, $price + $fees, $quantity));
+ ?->sum(fn ($taxOrFee) => $this->calculateFee($taxOrFee, $price + $fees, $quantity));
return new TaxCalculationResponse(
feeTotal: $fees ? ($fees * $quantity) : 0.00,
diff --git a/backend/app/Services/Domain/Tax/TaxAndFeeRollupService.php b/backend/app/Services/Domain/Tax/TaxAndFeeRollupService.php
index be52cef406..ae992e1ef8 100644
--- a/backend/app/Services/Domain/Tax/TaxAndFeeRollupService.php
+++ b/backend/app/Services/Domain/Tax/TaxAndFeeRollupService.php
@@ -49,7 +49,7 @@ public function addToRollUp(TaxAndFeesDomainObject $taxOrFee, float $amount): vo
'name' => $name,
'rate' => $taxOrFee->getRate(),
'type' => $taxOrFee->getCalculationType(),
- 'value' => $amount
+ 'value' => $amount,
];
} else {
$this->rollUp[$type][$foundIndex]['value'] += $amount;
diff --git a/backend/app/Services/Domain/Tax/TaxAndProductAssociationService.php b/backend/app/Services/Domain/Tax/TaxAndProductAssociationService.php
index 05527137ee..c357318cbc 100644
--- a/backend/app/Services/Domain/Tax/TaxAndProductAssociationService.php
+++ b/backend/app/Services/Domain/Tax/TaxAndProductAssociationService.php
@@ -13,10 +13,8 @@
{
public function __construct(
private TaxAndFeeRepositoryInterface $taxAndFeeRepository,
- private ProductRepositoryInterface $ticketRepository,
- )
- {
- }
+ private ProductRepositoryInterface $ticketRepository,
+ ) {}
/**
* @throws Exception
diff --git a/backend/app/Services/Domain/User/EmailConfirmationService.php b/backend/app/Services/Domain/User/EmailConfirmationService.php
index 045310bc83..3ab6ab9fe3 100644
--- a/backend/app/Services/Domain/User/EmailConfirmationService.php
+++ b/backend/app/Services/Domain/User/EmailConfirmationService.php
@@ -19,16 +19,14 @@
class EmailConfirmationService
{
public function __construct(
- private readonly Mailer $mailer,
- private readonly EncryptedPayloadService $encryptedPayloadService,
- private readonly UserRepositoryInterface $userRepository,
- private readonly DatabaseManager $databaseManager,
+ private readonly Mailer $mailer,
+ private readonly EncryptedPayloadService $encryptedPayloadService,
+ private readonly UserRepositoryInterface $userRepository,
+ private readonly DatabaseManager $databaseManager,
private readonly EmailVerificationCodeService $emailVerificationCodeService,
- private readonly VerifyUserEmailService $verifyUserEmailService,
- private readonly EventRepositoryInterface $eventRepository,
- )
- {
- }
+ private readonly VerifyUserEmailService $verifyUserEmailService,
+ private readonly EventRepositoryInterface $eventRepository,
+ ) {}
/**
* @throws DecryptionFailedException
diff --git a/backend/app/Services/Domain/User/SendUserInvitationService.php b/backend/app/Services/Domain/User/SendUserInvitationService.php
index b213292e49..803bb1ffc2 100644
--- a/backend/app/Services/Domain/User/SendUserInvitationService.php
+++ b/backend/app/Services/Domain/User/SendUserInvitationService.php
@@ -19,10 +19,9 @@ class SendUserInvitationService
public function __construct(
EncryptedPayloadService $encryptedPayloadService,
- Mailer $mailer,
- Repository $config,
- )
- {
+ Mailer $mailer,
+ Repository $config,
+ ) {
$this->encryptedPayloadService = $encryptedPayloadService;
$this->mailer = $mailer;
$this->config = $config;
diff --git a/backend/app/Services/Domain/User/VerifyUserEmailService.php b/backend/app/Services/Domain/User/VerifyUserEmailService.php
index 3a75936ce4..ee1bcfef61 100644
--- a/backend/app/Services/Domain/User/VerifyUserEmailService.php
+++ b/backend/app/Services/Domain/User/VerifyUserEmailService.php
@@ -11,12 +11,10 @@
class VerifyUserEmailService
{
public function __construct(
- private readonly UserRepositoryInterface $userRepository,
- private readonly AccountRepositoryInterface $accountRepository,
+ private readonly UserRepositoryInterface $userRepository,
+ private readonly AccountRepositoryInterface $accountRepository,
private readonly AccountUserRepositoryInterface $accountUserRepository,
- )
- {
- }
+ ) {}
public function markEmailAsVerified(UserDomainObject $user, int $accountId): void
{
@@ -37,7 +35,7 @@ public function markEmailAsVerified(UserDomainObject $user, int $accountId): voi
);
if ($accountUser === null) {
- throw new ResourceNotFoundException();
+ throw new ResourceNotFoundException;
}
// If this is the account owner, mark the account as verified
diff --git a/backend/app/Services/Domain/Waitlist/CancelWaitlistEntryService.php b/backend/app/Services/Domain/Waitlist/CancelWaitlistEntryService.php
index fa8aa6d541..0318eb1cca 100644
--- a/backend/app/Services/Domain/Waitlist/CancelWaitlistEntryService.php
+++ b/backend/app/Services/Domain/Waitlist/CancelWaitlistEntryService.php
@@ -11,6 +11,7 @@
use HiEvents\Exceptions\ResourceNotFoundException;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface;
+use HiEvents\Repository\Interfaces\StripePaymentsRepositoryInterface;
use HiEvents\Repository\Interfaces\WaitlistEntryRepositoryInterface;
use Illuminate\Database\DatabaseManager;
@@ -18,12 +19,11 @@ class CancelWaitlistEntryService
{
public function __construct(
private readonly WaitlistEntryRepositoryInterface $waitlistEntryRepository,
- private readonly OrderRepositoryInterface $orderRepository,
- private readonly DatabaseManager $databaseManager,
- private readonly ProductPriceRepositoryInterface $productPriceRepository,
- )
- {
- }
+ private readonly OrderRepositoryInterface $orderRepository,
+ private readonly DatabaseManager $databaseManager,
+ private readonly ProductPriceRepositoryInterface $productPriceRepository,
+ private readonly StripePaymentsRepositoryInterface $stripePaymentsRepository,
+ ) {}
/**
* @throws ResourceConflictException
@@ -67,9 +67,9 @@ public function cancelById(int $entryId, int $eventId): WaitlistEntryDomainObjec
/**
* @throws ResourceConflictException
*/
- private function cancelEntry(WaitlistEntryDomainObject $entry): WaitlistEntryDomainObject
+ public function cancelEntry(WaitlistEntryDomainObject $entry): WaitlistEntryDomainObject
{
- if (!in_array($entry->getStatus(), [
+ if (! in_array($entry->getStatus(), [
WaitlistEntryStatus::WAITING->name,
WaitlistEntryStatus::OFFERED->name,
], true)) {
@@ -77,13 +77,36 @@ private function cancelEntry(WaitlistEntryDomainObject $entry): WaitlistEntryDom
}
return $this->databaseManager->transaction(function () use ($entry) {
- $wasOffered = $entry->getStatus() === WaitlistEntryStatus::OFFERED->name;
+ $lockedEntry = $this->waitlistEntryRepository->findByIdLocked($entry->getId());
+
+ if ($lockedEntry === null || ! in_array($lockedEntry->getStatus(), [
+ WaitlistEntryStatus::WAITING->name,
+ WaitlistEntryStatus::OFFERED->name,
+ ], true)) {
+ throw new ResourceConflictException(__('This waitlist entry cannot be cancelled'));
+ }
- if ($entry->getOrderId() !== null) {
- $this->orderRepository->deleteWhere([
- 'id' => $entry->getOrderId(),
- 'status' => OrderStatus::RESERVED->name,
- ]);
+ $wasOffered = $lockedEntry->getStatus() === WaitlistEntryStatus::OFFERED->name;
+
+ if ($lockedEntry->getOrderId() !== null) {
+ $orderHasStripePayment = $this->stripePaymentsRepository->countWhere([
+ 'order_id' => $lockedEntry->getOrderId(),
+ ]) > 0;
+
+ if ($orderHasStripePayment) {
+ $this->orderRepository->updateWhere(
+ attributes: ['status' => OrderStatus::ABANDONED->name],
+ where: [
+ 'id' => $lockedEntry->getOrderId(),
+ 'status' => OrderStatus::RESERVED->name,
+ ],
+ );
+ } else {
+ $this->orderRepository->deleteWhere([
+ 'id' => $lockedEntry->getOrderId(),
+ 'status' => OrderStatus::RESERVED->name,
+ ]);
+ }
}
$this->waitlistEntryRepository->updateWhere(
@@ -92,21 +115,22 @@ private function cancelEntry(WaitlistEntryDomainObject $entry): WaitlistEntryDom
'cancelled_at' => now(),
'order_id' => null,
],
- where: ['id' => $entry->getId()],
+ where: ['id' => $lockedEntry->getId()],
);
if ($wasOffered) {
- $productPrice = $this->productPriceRepository->findById($entry->getProductPriceId());
+ $productPrice = $this->productPriceRepository->findById($lockedEntry->getProductPriceId());
event(new CapacityChangedEvent(
- eventId: $entry->getEventId(),
+ eventId: $lockedEntry->getEventId(),
direction: CapacityChangeDirection::INCREASED,
productId: $productPrice->getProductId(),
- productPriceId: $entry->getProductPriceId(),
+ productPriceId: $lockedEntry->getProductPriceId(),
+ eventOccurrenceId: $lockedEntry->getEventOccurrenceId(),
));
}
- return $this->waitlistEntryRepository->findById($entry->getId());
+ return $this->waitlistEntryRepository->findById($lockedEntry->getId());
});
}
}
diff --git a/backend/app/Services/Domain/Waitlist/CreateWaitlistEntryService.php b/backend/app/Services/Domain/Waitlist/CreateWaitlistEntryService.php
index c934377cdb..728310691e 100644
--- a/backend/app/Services/Domain/Waitlist/CreateWaitlistEntryService.php
+++ b/backend/app/Services/Domain/Waitlist/CreateWaitlistEntryService.php
@@ -18,31 +18,29 @@ class CreateWaitlistEntryService
{
public function __construct(
private readonly WaitlistEntryRepositoryInterface $waitlistEntryRepository,
- private readonly DatabaseManager $databaseManager,
- )
- {
- }
+ private readonly DatabaseManager $databaseManager,
+ ) {}
/**
* @throws ResourceConflictException
*/
public function createEntry(
- CreateWaitlistEntryDTO $dto,
- EventSettingDomainObject $eventSettings,
- ProductDomainObject $product,
- ): WaitlistEntryDomainObject
- {
+ CreateWaitlistEntryDTO $dto,
+ EventSettingDomainObject $eventSettings,
+ ProductDomainObject $product,
+ ): WaitlistEntryDomainObject {
$this->validateWaitlistEnabled($product);
/** @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,
@@ -63,7 +61,7 @@ public function createEntry(
*/
private function validateWaitlistEnabled(ProductDomainObject $product): void
{
- if ($product->getWaitlistEnabled() === false) {
+ if ($product->getWaitlistEnabled() !== true) {
throw new ResourceConflictException(__('Waitlist is not enabled for this product'));
}
}
@@ -78,6 +76,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 +90,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..fced3cbae2 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,50 @@ public function offerToNext(
}
$offeredEntries = collect();
+ $remainingByOccurrenceAndPrice = [];
foreach ($entries as $entry) {
+ try {
+ $occurrenceId = $this->resolveEventOccurrenceId($event, $entry);
+ } catch (ResourceConflictException|ResourceNotFoundException) {
+ continue;
+ }
+
+ 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 +132,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 +151,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 +189,42 @@ public function offerSpecificEntry(
});
}
+ 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 +248,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 +270,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 +281,7 @@ private function createReservedOrder(
price_id: $productPrice->getId(),
),
]),
+ event_occurrence_id: $eventOccurrenceId,
),
]);
@@ -220,6 +295,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 +331,7 @@ private function getAvailableCountForPrice(object $quantities, int $priceId): in
if ($available === Constants::INFINITE) {
return Constants::INFINITE;
}
+
return $available;
}
}
@@ -235,6 +339,23 @@ private function getAvailableCountForPrice(object $quantities, int $priceId): in
return 0;
}
+ private function hasCapacityForEntry(WaitlistEntryDomainObject $entry, EventDomainObject $event): bool
+ {
+ 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/Domain/Waitlist/RevertWaitlistOffersForCancelledOrderService.php b/backend/app/Services/Domain/Waitlist/RevertWaitlistOffersForCancelledOrderService.php
new file mode 100644
index 0000000000..fef12f880f
--- /dev/null
+++ b/backend/app/Services/Domain/Waitlist/RevertWaitlistOffersForCancelledOrderService.php
@@ -0,0 +1,57 @@
+
+ */
+ public function revertOffersForOrder(int $orderId): array
+ {
+ $capacityEvents = [];
+
+ $entries = $this->waitlistEntryRepository->findWhere([
+ 'order_id' => $orderId,
+ ['status', 'in', [WaitlistEntryStatus::OFFERED->name]],
+ ]);
+
+ foreach ($entries as $entry) {
+ $this->waitlistEntryRepository->updateWhere(
+ attributes: [
+ 'status' => WaitlistEntryStatus::WAITING->name,
+ 'order_id' => null,
+ 'offered_at' => null,
+ 'offer_expires_at' => null,
+ 'offer_token' => null,
+ ],
+ where: [
+ 'id' => $entry->getId(),
+ 'status' => WaitlistEntryStatus::OFFERED->name,
+ ],
+ );
+
+ $productPrice = $this->productPriceRepository->findById($entry->getProductPriceId());
+ $capacityEvents[] = new CapacityChangedEvent(
+ eventId: $entry->getEventId(),
+ direction: CapacityChangeDirection::INCREASED,
+ productId: $productPrice->getProductId(),
+ productPriceId: $entry->getProductPriceId(),
+ eventOccurrenceId: $entry->getEventOccurrenceId(),
+ );
+ }
+
+ return $capacityEvents;
+ }
+}
diff --git a/backend/app/Services/Infrastructure/Authorization/IsAuthorizedService.php b/backend/app/Services/Infrastructure/Authorization/IsAuthorizedService.php
index 2efbd9e238..7ade5b767f 100644
--- a/backend/app/Services/Infrastructure/Authorization/IsAuthorizedService.php
+++ b/backend/app/Services/Infrastructure/Authorization/IsAuthorizedService.php
@@ -24,12 +24,10 @@
readonly class IsAuthorizedService
{
public function __construct(
- private Application $app,
+ private Application $app,
private AccountUserRepositoryInterface $accountUserRepository,
- private AuthManager $auth,
- )
- {
- }
+ private AuthManager $auth,
+ ) {}
/**
* @todo This is a very simplistic way of handling roles. Currently we have an ADMIN and ORGANIZER role, but we
@@ -49,13 +47,12 @@ public function validateUserRole(Role $minimumRole, UserDomainObject $authUser):
}
public function isActionAuthorized(
- int $entityId,
- string $entityType,
+ int $entityId,
+ string $entityType,
UserDomainObject $authUser,
- int $authAccountId,
- Role $minimumRole
- ): void
- {
+ int $authAccountId,
+ Role $minimumRole
+ ): void {
$this->validateUserStatus($authUser);
$this->validateUserRole($minimumRole, $authUser);
@@ -79,8 +76,8 @@ public function isActionAuthorized(
TaxAndFeesDomainObject::class => $this->validateTax($entity, $authAccountId),
};
- if (!$result) {
- throw new UnauthorizedException();
+ if (! $result) {
+ throw new UnauthorizedException;
}
}
diff --git a/backend/app/Services/Infrastructure/CurrencyConversion/Exception/CurrencyConversionErrorException.php b/backend/app/Services/Infrastructure/CurrencyConversion/Exception/CurrencyConversionErrorException.php
index c0e558989c..de32c5eb06 100644
--- a/backend/app/Services/Infrastructure/CurrencyConversion/Exception/CurrencyConversionErrorException.php
+++ b/backend/app/Services/Infrastructure/CurrencyConversion/Exception/CurrencyConversionErrorException.php
@@ -4,7 +4,4 @@
use Exception;
-class CurrencyConversionErrorException extends Exception
-{
-
-}
+class CurrencyConversionErrorException extends Exception {}
diff --git a/backend/app/Services/Infrastructure/CurrencyConversion/OpenExchangeRatesCurrencyConversionClient.php b/backend/app/Services/Infrastructure/CurrencyConversion/OpenExchangeRatesCurrencyConversionClient.php
index 1819b67795..e67bec7c7c 100644
--- a/backend/app/Services/Infrastructure/CurrencyConversion/OpenExchangeRatesCurrencyConversionClient.php
+++ b/backend/app/Services/Infrastructure/CurrencyConversion/OpenExchangeRatesCurrencyConversionClient.php
@@ -18,22 +18,18 @@ class OpenExchangeRatesCurrencyConversionClient implements CurrencyConversionCli
private string $apiKey;
private const CACHE_TTL = 43200; // 12 hours in seconds
+
private const API_URL = 'https://openexchangerates.org/api/latest.json';
public function __construct(
- string $apiKey,
- private readonly CacheInterface $cache,
+ string $apiKey,
+ private readonly CacheInterface $cache,
private readonly LoggerInterface $logger,
- )
- {
+ ) {
$this->apiKey = $apiKey;
}
/**
- * @param Currency $fromCurrency
- * @param Currency $toCurrency
- * @param float $amount
- * @return MoneyValue
* @throws CurrencyConversionErrorException
* @throws InvalidArgumentException
* @throws JsonException
@@ -52,7 +48,7 @@ public function convert(Currency $fromCurrency, Currency $toCurrency, float $amo
$fromCurrencyCode = $fromCurrency->getCurrencyCode();
$toCurrencyCode = $toCurrency->getCurrencyCode();
- if (!isset($rates[$fromCurrencyCode], $rates[$toCurrencyCode])) {
+ if (! isset($rates[$fromCurrencyCode], $rates[$toCurrencyCode])) {
throw new CurrencyConversionErrorException("Invalid currency conversion: $fromCurrencyCode to $toCurrencyCode");
}
@@ -87,7 +83,7 @@ private function getRates(): array
$data = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
- if (!isset($data['rates']) || !is_array($data['rates'])) {
+ if (! isset($data['rates']) || ! is_array($data['rates'])) {
throw new CurrencyConversionErrorException('Invalid response from Open Exchange Rates API.');
}
diff --git a/backend/app/Services/Infrastructure/DomainEvents/DomainEventDispatcherService.php b/backend/app/Services/Infrastructure/DomainEvents/DomainEventDispatcherService.php
index e40847cd3c..5978d12fa2 100644
--- a/backend/app/Services/Infrastructure/DomainEvents/DomainEventDispatcherService.php
+++ b/backend/app/Services/Infrastructure/DomainEvents/DomainEventDispatcherService.php
@@ -12,9 +12,7 @@ class DomainEventDispatcherService
public function __construct(
private readonly EventDispatcher $dispatcher,
private readonly LoggerInterface $logger,
- )
- {
- }
+ ) {}
/**
* @throws Throwable
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/AttendeeEvent.php b/backend/app/Services/Infrastructure/DomainEvents/Events/AttendeeEvent.php
index abea0146e1..955943f55c 100644
--- a/backend/app/Services/Infrastructure/DomainEvents/Events/AttendeeEvent.php
+++ b/backend/app/Services/Infrastructure/DomainEvents/Events/AttendeeEvent.php
@@ -8,8 +8,6 @@ class AttendeeEvent extends BaseDomainEvent
{
public function __construct(
public DomainEventType $type,
- public int $attendeeId,
- )
- {
- }
+ public int $attendeeId,
+ ) {}
}
diff --git a/backend/app/Services/Infrastructure/DomainEvents/Events/CheckinEvent.php b/backend/app/Services/Infrastructure/DomainEvents/Events/CheckinEvent.php
index 3e30542e20..bdc2b6ba42 100644
--- a/backend/app/Services/Infrastructure/DomainEvents/Events/CheckinEvent.php
+++ b/backend/app/Services/Infrastructure/DomainEvents/Events/CheckinEvent.php
@@ -8,8 +8,6 @@ class CheckinEvent extends BaseDomainEvent
{
public function __construct(
public DomainEventType $type,
- public int $attendeeCheckinId,
- )
- {
- }
+ public int $attendeeCheckinId,
+ ) {}
}
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..659fee71e7
--- /dev/null
+++ b/backend/app/Services/Infrastructure/DomainEvents/Events/OccurrenceEvent.php
@@ -0,0 +1,13 @@
+defaultAbstractNamespace);
- $className = ucfirst(Str::camel(Pluralizer::singular($table->getName()))) . 'DomainObjectAbstract';
+ $className = ucfirst(Str::camel(Pluralizer::singular($table->getName()))).'DomainObjectAbstract';
$this->currentClass = $namespace->addClass($className);
$this->currentTable = $table;
@@ -82,7 +81,7 @@ public function run(): void
$this->currentClass
->setAbstract()
->setExtends($this->defaultExtends)
- ->addComment("THIS FILE IS AUTOGENERATED - DO NOT EDIT IT DIRECTLY.")
+ ->addComment('THIS FILE IS AUTOGENERATED - DO NOT EDIT IT DIRECTLY.')
->addComment("@package $this->defaultAbstractNamespace");
$this->buildConstants();
@@ -92,7 +91,7 @@ public function run(): void
$namespace->add($this->currentClass);
- $file = new PhpFile();
+ $file = new PhpFile;
$file->addNamespace($namespace);
$this->writeFile($table, (new PsrPrinter)->printFile($file));
}
@@ -140,7 +139,7 @@ private function buildProperties(): void
->addProperty($column->getName())
->setProtected()
->setType($this->getType($column->getType()))
- ->setNullable(!$column->getNotnull());
+ ->setNullable(! $column->getNotnull());
$defaultValue = $this->getDefaultValue($column);
if ($defaultValue !== self::NO_TYPE) {
@@ -168,10 +167,10 @@ private function getDefaultValue(Column $column): mixed
return match ($column->getType()->getName()) {
Types::STRING, Types::TEXT, Types::DATE_MUTABLE, Types::TIME_MUTABLE, Types::DATETIMETZ_MUTABLE, Types::DATETIME_MUTABLE => $column->getDefault(),
- Types::INTEGER, Types::BIGINT, Types::SMALLINT => (int)$column->getDefault(),
- Types::FLOAT, Types::DECIMAL => (float)$column->getDefault(),
+ Types::INTEGER, Types::BIGINT, Types::SMALLINT => (int) $column->getDefault(),
+ Types::FLOAT, Types::DECIMAL => (float) $column->getDefault(),
Types::GUID => self::NO_TYPE,
- Types::BOOLEAN => (bool)$column->getDefault(),
+ Types::BOOLEAN => (bool) $column->getDefault(),
default => throw new NoDefaultValueAvailableForGeneratedDoProperty(
sprintf('Unable to handle %s', $column->getType()->getName())
),
@@ -180,7 +179,7 @@ private function getDefaultValue(Column $column): mixed
private function buildPublicMethods(): void
{
- $response[] = "return [";
+ $response[] = 'return [';
foreach ($this->currentColumns as $column) {
if ($this->shouldIgnoreProperty($column->getName())) {
continue;
@@ -189,9 +188,9 @@ private function buildPublicMethods(): void
$response[] = "
'{$column->getName()}' => \$this->{$column->getName()} ?? null,";
}
- $response[] = "
+ $response[] = '
];
- ";
+ ';
$this->currentClass
->addMethod('toArray')
->setReturnType('array')
@@ -207,8 +206,8 @@ private function buildGettersAndSetters(): void
}
$model = ucfirst(Str::camel($column->getName()));
- $setterName = "set" . $model;
- $getterName = "get" . $model;
+ $setterName = 'set'.$model;
+ $getterName = 'get'.$model;
$this->currentClass->addMethod($setterName)
->setPublic()
@@ -216,13 +215,13 @@ private function buildGettersAndSetters(): void
->setReturnType('self')
->addParameter($column->getName())
->setType($this->getType($column->getType()))
- ->setNullable(!$column->getNotnull());
+ ->setNullable(! $column->getNotnull());
$this->currentClass->addMethod($getterName)
->setPublic()
->addBody("return \$this->{$column->getName()};")
->setReturnType($this->getType($column->getType()))
- ->setReturnNullable(!$column->getNotnull());
+ ->setReturnNullable(! $column->getNotnull());
}
}
@@ -231,15 +230,15 @@ private function writeFile(Table $table, string $modelContent): void
$abstractPath = $this->app->path($this->defaultAbstractModelPath);
$concretePath = $this->app->path($this->defaultConcreteModelPath);
- $model = Pluralizer::singular(ucfirst(Str::camel($table->getName()))) . 'DomainObject';
- $abstractFile = $abstractPath . '/' . $model . 'Abstract' . '.php';
- $concreteFile = $concretePath . '/' . $model . '.php';
+ $model = Pluralizer::singular(ucfirst(Str::camel($table->getName()))).'DomainObject';
+ $abstractFile = $abstractPath.'/'.$model.'Abstract'.'.php';
+ $concreteFile = $concretePath.'/'.$model.'.php';
if (file_exists($abstractFile)) {
unlink($abstractFile);
}
- if (!file_exists($concreteFile)) {
+ if (! file_exists($concreteFile)) {
$this->writeConcreteFile($table, $concreteFile);
}
@@ -249,14 +248,14 @@ private function writeFile(Table $table, string $modelContent): void
private function writeConcreteFile(Table $table, string $concreteFile): void
{
$namespace = new PhpNamespace($this->defaultConcreteNamespace);
- $className = ucfirst(Str::camel(Pluralizer::singular($table->getName()))) . 'DomainObject';
+ $className = ucfirst(Str::camel(Pluralizer::singular($table->getName()))).'DomainObject';
$class = $namespace->addClass($className);
- $class->setExtends($this->defaultConcreteNamespace. '\Generated' . '\\' . $className . 'Abstract');
+ $class->setExtends($this->defaultConcreteNamespace.'\Generated'.'\\'.$className.'Abstract');
- $file = new PhpFile();
+ $file = new PhpFile;
$file->addNamespace($namespace);
- file_put_contents($concreteFile, (new PsrPrinter())->printFile($file));
+ file_put_contents($concreteFile, (new PsrPrinter)->printFile($file));
}
}
diff --git a/backend/app/Services/Infrastructure/Email/LiquidTemplateRenderer.php b/backend/app/Services/Infrastructure/Email/LiquidTemplateRenderer.php
index 813f702845..3b18c1cdd7 100644
--- a/backend/app/Services/Infrastructure/Email/LiquidTemplateRenderer.php
+++ b/backend/app/Services/Infrastructure/Email/LiquidTemplateRenderer.php
@@ -14,7 +14,7 @@ class LiquidTemplateRenderer
public function __construct()
{
- $this->liquid = new Template();
+ $this->liquid = new Template;
$this->liquid->parse(''); // Initialize
}
@@ -22,9 +22,10 @@ public function render(string $template, array $context): string
{
try {
$this->liquid->parse($template);
+
return $this->liquid->render($context);
} catch (Exception $e) {
- throw new RuntimeException('Failed to render template: ' . $e->getMessage(), 0, $e);
+ throw new RuntimeException('Failed to render template: '.$e->getMessage(), 0, $e);
}
}
@@ -32,6 +33,7 @@ public function validate(string $template): bool
{
try {
$this->liquid->parse($template);
+
return true;
} catch (ParseException $e) {
return false;
@@ -42,6 +44,7 @@ public function getValidationErrors(string $template): ?string
{
try {
$this->liquid->parse($template);
+
return null;
} catch (ParseException $e) {
return $e->getMessage();
@@ -146,6 +149,31 @@ public function getAvailableTokens(EmailTemplateType $type): array
'description' => __('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 +242,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/Encryption/EncryptedPayloadService.php b/backend/app/Services/Infrastructure/Encryption/EncryptedPayloadService.php
index 0656e93c2c..d2ce44b2c2 100644
--- a/backend/app/Services/Infrastructure/Encryption/EncryptedPayloadService.php
+++ b/backend/app/Services/Infrastructure/Encryption/EncryptedPayloadService.php
@@ -34,7 +34,7 @@ public function decryptPayload(string $encryptedPayload): array
try {
$decrypted = $this->encrypter->decrypt($encryptedPayload);
- if (!isset($decrypted['exp']) || (new Carbon($decrypted['exp']))->isPast()) {
+ if (! isset($decrypted['exp']) || (new Carbon($decrypted['exp']))->isPast()) {
throw new EncryptedPayloadExpiredException(__('Payload has expired or is invalid.'));
}
diff --git a/backend/app/Services/Infrastructure/Encryption/Exception/DecryptionFailedException.php b/backend/app/Services/Infrastructure/Encryption/Exception/DecryptionFailedException.php
index 6c8c552aac..bbf768fee7 100644
--- a/backend/app/Services/Infrastructure/Encryption/Exception/DecryptionFailedException.php
+++ b/backend/app/Services/Infrastructure/Encryption/Exception/DecryptionFailedException.php
@@ -4,7 +4,4 @@
use Exception;
-class DecryptionFailedException extends Exception
-{
-
-}
+class DecryptionFailedException extends Exception {}
diff --git a/backend/app/Services/Infrastructure/Encryption/Exception/EncryptedPayloadExpiredException.php b/backend/app/Services/Infrastructure/Encryption/Exception/EncryptedPayloadExpiredException.php
index 6c150c4a72..c2a891e145 100644
--- a/backend/app/Services/Infrastructure/Encryption/Exception/EncryptedPayloadExpiredException.php
+++ b/backend/app/Services/Infrastructure/Encryption/Exception/EncryptedPayloadExpiredException.php
@@ -4,7 +4,4 @@
use Exception;
-class EncryptedPayloadExpiredException extends Exception
-{
-
-}
+class EncryptedPayloadExpiredException extends Exception {}
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;
+
+ public function getCachedRawPlaceDetails(?string $providerPlaceId): ?array;
+
+ public function isAvailable(): bool;
+}
diff --git a/backend/app/Services/Infrastructure/Geo/GooglePlacesGeoProvider.php b/backend/app/Services/Infrastructure/Geo/GooglePlacesGeoProvider.php
new file mode 100644
index 0000000000..40a3210064
--- /dev/null
+++ b/backend/app/Services/Infrastructure/Geo/GooglePlacesGeoProvider.php
@@ -0,0 +1,279 @@
+ $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;
+ }
+
+ $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);
+ }
+
+ $this->cache->put($this->rawPlaceDetailsCacheKey($providerPlaceId), $payload, self::PLACE_DETAILS_CACHE_TTL_SECONDS);
+
+ return $this->mapToGeoPlaceDTO($payload, $providerPlaceId);
+ }
+
+ public function getCachedRawPlaceDetails(?string $providerPlaceId): ?array
+ {
+ if ($providerPlaceId === null || $providerPlaceId === '') {
+ return null;
+ }
+
+ $payload = $this->cache->get($this->rawPlaceDetailsCacheKey($providerPlaceId));
+
+ return is_array($payload) ? $payload : null;
+ }
+
+ public function isAvailable(): bool
+ {
+ return true;
+ }
+
+ 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 rawPlaceDetailsCacheKey(string $providerPlaceId): string
+ {
+ return 'geo:place_details_raw:'.self::PROVIDER_NAME.':'.$providerPlaceId;
+ }
+
+ 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..b7a62784ee
--- /dev/null
+++ b/backend/app/Services/Infrastructure/Geo/NoOpGeoProvider.php
@@ -0,0 +1,37 @@
+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;
+ }
+
+ public function getCachedRawPlaceDetails(?string $providerPlaceId): ?array
+ {
+ return null;
+ }
+
+ public function isAvailable(): bool
+ {
+ return false;
+ }
+}
diff --git a/backend/app/Services/Infrastructure/Image/DTO/ImageMetadataDTO.php b/backend/app/Services/Infrastructure/Image/DTO/ImageMetadataDTO.php
index 17bc7982fb..1ff8a57ee6 100644
--- a/backend/app/Services/Infrastructure/Image/DTO/ImageMetadataDTO.php
+++ b/backend/app/Services/Infrastructure/Image/DTO/ImageMetadataDTO.php
@@ -5,10 +5,9 @@
readonly class ImageMetadataDTO
{
public function __construct(
- public int $width,
- public int $height,
- public string $avg_colour,
- public string $lqip_base64,
- ) {
- }
+ public int $width,
+ public int $height,
+ public string $avg_colour,
+ public string $lqip_base64,
+ ) {}
}
diff --git a/backend/app/Services/Infrastructure/Image/DTO/ImageStorageResponseDTO.php b/backend/app/Services/Infrastructure/Image/DTO/ImageStorageResponseDTO.php
index 4249c408cd..e4f7d37538 100644
--- a/backend/app/Services/Infrastructure/Image/DTO/ImageStorageResponseDTO.php
+++ b/backend/app/Services/Infrastructure/Image/DTO/ImageStorageResponseDTO.php
@@ -8,9 +8,7 @@ public function __construct(
public string $filename,
public string $disk,
public string $path,
- public int $size,
+ public int $size,
public string $mime_type,
- )
- {
- }
+ ) {}
}
diff --git a/backend/app/Services/Infrastructure/Image/Exception/CouldNotUploadImageException.php b/backend/app/Services/Infrastructure/Image/Exception/CouldNotUploadImageException.php
index d79a90e12d..f158a34ae7 100644
--- a/backend/app/Services/Infrastructure/Image/Exception/CouldNotUploadImageException.php
+++ b/backend/app/Services/Infrastructure/Image/Exception/CouldNotUploadImageException.php
@@ -4,7 +4,4 @@
use Exception;
-class CouldNotUploadImageException extends Exception
-{
-
-}
+class CouldNotUploadImageException extends Exception {}
diff --git a/backend/app/Services/Infrastructure/Image/ImageMetadataService.php b/backend/app/Services/Infrastructure/Image/ImageMetadataService.php
index ca6cbc67cf..47ec56bcba 100644
--- a/backend/app/Services/Infrastructure/Image/ImageMetadataService.php
+++ b/backend/app/Services/Infrastructure/Image/ImageMetadataService.php
@@ -10,16 +10,16 @@
class ImageMetadataService
{
private const LQIP_MAX_DIMENSION = 16;
+
private const LQIP_QUALITY = 60;
public function __construct(
private readonly LoggerInterface $logger,
- ) {
- }
+ ) {}
public function extractMetadata(UploadedFile $image): ?ImageMetadataDTO
{
- if (!$this->isImagickAvailable()) {
+ if (! $this->isImagickAvailable()) {
return null;
}
@@ -41,7 +41,7 @@ public function extractMetadata(UploadedFile $image): ?ImageMetadataDTO
lqip_base64: $lqipBase64,
);
} catch (\Exception $e) {
- $this->logger->warning('Failed to extract image metadata: ' . $e->getMessage());
+ $this->logger->warning('Failed to extract image metadata: '.$e->getMessage());
return null;
}
@@ -91,6 +91,6 @@ private function generateLqip(Imagick $imagick): string
$clone->clear();
$clone->destroy();
- return 'data:image/webp;base64,' . base64_encode($blob);
+ return 'data:image/webp;base64,'.base64_encode($blob);
}
}
diff --git a/backend/app/Services/Infrastructure/Image/ImageStorageService.php b/backend/app/Services/Infrastructure/Image/ImageStorageService.php
index c2d962c01b..9a5236d5e1 100644
--- a/backend/app/Services/Infrastructure/Image/ImageStorageService.php
+++ b/backend/app/Services/Infrastructure/Image/ImageStorageService.php
@@ -14,11 +14,9 @@ class ImageStorageService
{
public function __construct(
private readonly FilesystemManager $filesystemManager,
- private readonly Repository $config,
- private readonly LoggerInterface $logger,
- )
- {
- }
+ private readonly Repository $config,
+ private readonly LoggerInterface $logger,
+ ) {}
/**
* @throws CouldNotUploadImageException
@@ -26,12 +24,12 @@ public function __construct(
public function store(UploadedFile $image, string $imageType): ImageStorageResponseDTO
{
$filename = Str::slug(
- title: str_ireplace(
- search: '.' . $image->getClientOriginalExtension(),
- replace: '',
- subject: $image->getClientOriginalName()
- )
- ) . '-' . Str::random(5) . '.' . $image->getClientOriginalExtension();
+ title: str_ireplace(
+ search: '.'.$image->getClientOriginalExtension(),
+ replace: '',
+ subject: $image->getClientOriginalName()
+ )
+ ).'-'.Str::random(5).'.'.$image->getClientOriginalExtension();
$disk = $this->config->get('filesystems.public');
@@ -45,16 +43,16 @@ public function store(UploadedFile $image, string $imageType): ImageStorageRespo
);
if ($path === false) {
- $this->logger->error(__('Could not upload image to :disk. Check :disk is configured correctly', ['disk' => $disk,]), [
- 'filename' => $filename,
- 'original_filename' => $image->getClientOriginalName()
- ]
+ $this->logger->error(__('Could not upload image to :disk. Check :disk is configured correctly', ['disk' => $disk]), [
+ 'filename' => $filename,
+ 'original_filename' => $image->getClientOriginalName(),
+ ]
);
throw new CouldNotUploadImageException(__('Could not upload image'));
}
- return new ImageStorageResponseDTO (
+ return new ImageStorageResponseDTO(
filename: $filename,
disk: $disk,
path: $path,
diff --git a/backend/app/Services/Infrastructure/Jobs/DTO/JobPollingResultDTO.php b/backend/app/Services/Infrastructure/Jobs/DTO/JobPollingResultDTO.php
index 16a75c891c..1e2981eb58 100644
--- a/backend/app/Services/Infrastructure/Jobs/DTO/JobPollingResultDTO.php
+++ b/backend/app/Services/Infrastructure/Jobs/DTO/JobPollingResultDTO.php
@@ -8,10 +8,8 @@ class JobPollingResultDTO
{
public function __construct(
public JobStatusEnum $status,
- public string $message,
- public ?string $jobUuid = null,
- public ?string $downloadUrl = null,
- )
- {
- }
+ public string $message,
+ public ?string $jobUuid = null,
+ public ?string $downloadUrl = null,
+ ) {}
}
diff --git a/backend/app/Services/Infrastructure/Jobs/JobPollingService.php b/backend/app/Services/Infrastructure/Jobs/JobPollingService.php
index 9d4abcbe12..f0076bfdcd 100644
--- a/backend/app/Services/Infrastructure/Jobs/JobPollingService.php
+++ b/backend/app/Services/Infrastructure/Jobs/JobPollingService.php
@@ -28,7 +28,7 @@ public function checkJobStatus(string $jobUuid, ?string $filePath = null): JobPo
{
$batch = Bus::findBatch($jobUuid);
- if (!$batch) {
+ if (! $batch) {
return new JobPollingResultDTO(
status: JobStatusEnum::NOT_FOUND,
message: __('Job not found'),
@@ -37,7 +37,7 @@ public function checkJobStatus(string $jobUuid, ?string $filePath = null): JobPo
}
if ($batch->finished()) {
- if ($filePath && !Storage::disk(self::STORAGE_DISK)->exists($filePath)) {
+ if ($filePath && ! Storage::disk(self::STORAGE_DISK)->exists($filePath)) {
return new JobPollingResultDTO(
status: JobStatusEnum::NOT_FOUND,
message: __('Export file not found'),
diff --git a/backend/app/Services/Infrastructure/Session/CheckoutSessionManagementService.php b/backend/app/Services/Infrastructure/Session/CheckoutSessionManagementService.php
index d7552ac842..1f03198f72 100644
--- a/backend/app/Services/Infrastructure/Session/CheckoutSessionManagementService.php
+++ b/backend/app/Services/Infrastructure/Session/CheckoutSessionManagementService.php
@@ -15,11 +15,9 @@ class CheckoutSessionManagementService
private ?string $sessionId = null;
public function __construct(
- private readonly Request $request,
+ private readonly Request $request,
private readonly Repository $config,
- )
- {
- }
+ ) {}
/**
* Get the session ID from query param, cookie, or generate a new one.
@@ -47,14 +45,14 @@ public function getSessionCookie(): SymfonyCookie
return Cookie::make(
name: self::SESSION_IDENTIFIER,
value: $this->getSessionId(),
- domain: $this->config->get('session.domain') ?? '.' . $this->request->getHost(),
+ domain: $this->config->get('session.domain') ?? '.'.$this->request->getHost(),
secure: true,
sameSite: 'None',
- );
+ )->withPartitioned(true);
}
private function createSessionId(): string
{
- return sha1(Str::uuid() . Str::random(40));
+ return sha1(Str::uuid().Str::random(40));
}
}
diff --git a/backend/app/Services/Infrastructure/Stripe/StripeClientFactory.php b/backend/app/Services/Infrastructure/Stripe/StripeClientFactory.php
index 5883b91af1..ba2f8adbba 100644
--- a/backend/app/Services/Infrastructure/Stripe/StripeClientFactory.php
+++ b/backend/app/Services/Infrastructure/Stripe/StripeClientFactory.php
@@ -10,8 +10,7 @@ class StripeClientFactory
{
public function __construct(
private readonly StripeConfigurationService $configurationService
- ) {
- }
+ ) {}
/**
* @throws StripeClientConfigurationException
diff --git a/backend/app/Services/Infrastructure/Stripe/StripeConfigurationService.php b/backend/app/Services/Infrastructure/Stripe/StripeConfigurationService.php
index 5ac01b36a5..6eaef0aab6 100644
--- a/backend/app/Services/Infrastructure/Stripe/StripeConfigurationService.php
+++ b/backend/app/Services/Infrastructure/Stripe/StripeConfigurationService.php
@@ -27,12 +27,13 @@ public function getPublicKey(?StripePlatform $platform = null): ?string
public function getPrimaryPlatform(): ?StripePlatform
{
$platformString = config('services.stripe.primary_platform');
+
return StripePlatform::fromString($platformString);
}
public function getAllWebhookSecrets(): array
{
- $secrets = array_filter([
+ $secrets = array_filter([
'default' => config('services.stripe.webhook_secret'),
StripePlatform::CANADA->value => config('services.stripe.ca_webhook_secret'),
StripePlatform::IRELAND->value => config('services.stripe.ie_webhook_secret'),
@@ -44,6 +45,7 @@ public function getAllWebhookSecrets(): array
if ($primary && isset($secrets[$primary])) {
$primarySecret = [$primary => $secrets[$primary]];
unset($secrets[$primary]);
+
return $primarySecret + $secrets;
}
diff --git a/backend/app/Services/Infrastructure/TokenGenerator/TokenGeneratorService.php b/backend/app/Services/Infrastructure/TokenGenerator/TokenGeneratorService.php
index 9a19c6c306..795660d277 100644
--- a/backend/app/Services/Infrastructure/TokenGenerator/TokenGeneratorService.php
+++ b/backend/app/Services/Infrastructure/TokenGenerator/TokenGeneratorService.php
@@ -7,19 +7,16 @@
class TokenGeneratorService
{
- public function __construct(private readonly Randomizer $randomizer)
- {
- }
+ public function __construct(private readonly Randomizer $randomizer) {}
/**
* Generates a random token string.
*
- * @param int $length Desired length of the random part of the token.
- * @param string $prefix A prefix to be added to the token.
+ * @param int $length Desired length of the random part of the token.
+ * @param string $prefix A prefix to be added to the token.
+ * @return string Generated token string with prefix.
*
* @throws InvalidArgumentException if the length is not positive.
- *
- * @return string Generated token string with prefix.
*/
public function generateToken(int $length = 32, string $prefix = ''): string
{
@@ -36,6 +33,6 @@ public function generateToken(int $length = 32, string $prefix = ''): string
$randomBytes = $this->randomizer->getBytes($adjustedLength);
$token = bin2hex($randomBytes);
- return $prefix . $token;
+ return $prefix.$token;
}
}
diff --git a/backend/app/Services/Infrastructure/User/EmailVerificationCodeService.php b/backend/app/Services/Infrastructure/User/EmailVerificationCodeService.php
index 466e16e875..30e960e8f3 100644
--- a/backend/app/Services/Infrastructure/User/EmailVerificationCodeService.php
+++ b/backend/app/Services/Infrastructure/User/EmailVerificationCodeService.php
@@ -8,9 +8,7 @@ class EmailVerificationCodeService
{
public function __construct(
private readonly Repository $cacheRepository,
- )
- {
- }
+ ) {}
public function storeAndReturnCode(string $email): int
{
@@ -49,6 +47,6 @@ private function generateNumericCode(): int
private function getCacheKey(string $email): string
{
- return 'email_verification_code:' . $email;
+ return 'email_verification_code:'.$email;
}
}
diff --git a/backend/app/Services/Infrastructure/Utlitiy/Retry/Retrier.php b/backend/app/Services/Infrastructure/Utlitiy/Retry/Retrier.php
index fd24aeaae0..85e63c6485 100644
--- a/backend/app/Services/Infrastructure/Utlitiy/Retry/Retrier.php
+++ b/backend/app/Services/Infrastructure/Utlitiy/Retry/Retrier.php
@@ -9,24 +9,20 @@
class Retrier
{
/**
- * @param callable(int $attempt):mixed $callableAction Receives 1-based attempt #
- * @param int $maxAttempts
- * @param int $baseDelayMs
- * @param int $maxDelayMs
- * @param null|callable(int $attempt, Throwable $e):void $onFailure Called before final throw
- * @param class-string|array> $retryOn Exceptions to retry
- * @return mixed
+ * @param callable(int $attempt):mixed $callableAction Receives 1-based attempt #
+ * @param null|callable(int $attempt, Throwable $e):void $onFailure Called before final throw
+ * @param class-string|array> $retryOn Exceptions to retry
+ *
* @throws Throwable
*/
public function retry(
- callable $callableAction,
- int $maxAttempts = 3,
- int $baseDelayMs = 25,
- int $maxDelayMs = 250,
+ callable $callableAction,
+ int $maxAttempts = 3,
+ int $baseDelayMs = 25,
+ int $maxDelayMs = 250,
?callable $onFailure = null,
- array $retryOn = [Throwable::class],
- ): mixed
- {
+ array $retryOn = [Throwable::class],
+ ): mixed {
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
try {
return $callableAction($attempt);
@@ -41,7 +37,7 @@ public function retry(
$isLast = ($attempt === $maxAttempts);
- if (!$isRetryable || $isLast) {
+ if (! $isRetryable || $isLast) {
if ($onFailure !== null) {
$onFailure($attempt, $e);
}
diff --git a/backend/app/Services/Application/Handlers/Account/Vat/DTO/ViesValidationResponseDTO.php b/backend/app/Services/Infrastructure/Vat/DTO/ViesValidationResponseDTO.php
similarity index 86%
rename from backend/app/Services/Application/Handlers/Account/Vat/DTO/ViesValidationResponseDTO.php
rename to backend/app/Services/Infrastructure/Vat/DTO/ViesValidationResponseDTO.php
index aedbb1a0d9..7a9459af16 100644
--- a/backend/app/Services/Application/Handlers/Account/Vat/DTO/ViesValidationResponseDTO.php
+++ b/backend/app/Services/Infrastructure/Vat/DTO/ViesValidationResponseDTO.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace HiEvents\Services\Application\Handlers\Account\Vat\DTO;
+namespace HiEvents\Services\Infrastructure\Vat\DTO;
use HiEvents\DataTransferObjects\BaseDataObject;
@@ -16,6 +16,5 @@ public function __construct(
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..6dd267e362 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;
@@ -13,6 +13,7 @@
class ViesValidationService
{
private const VIES_API_URL = 'https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number';
+
private const TIMEOUT_SECONDS = 15;
private const TRANSIENT_ERRORS = [
@@ -25,7 +26,7 @@ class ViesValidationService
];
public function __construct(
- private readonly HttpClient $httpClient,
+ private readonly HttpClient $httpClient,
private readonly LoggerInterface $logger,
) {}
@@ -54,10 +55,10 @@ public function validateVatNumber(string $vatNumber): ViesValidationResponseDTO
'status_code' => $response->status(),
'action_succeed' => $data['actionSucceed'] ?? null,
'valid' => $data['valid'] ?? null,
- 'has_errors' => !empty($data['errorWrappers']),
+ 'has_errors' => ! empty($data['errorWrappers']),
]);
- if (!$response->successful()) {
+ if (! $response->successful()) {
$this->logger->warning('VIES HTTP error response', [
'vat_number' => $this->maskVatNumber($vatNumber),
'status_code' => $response->status(),
@@ -73,7 +74,7 @@ public function validateVatNumber(string $vatNumber): ViesValidationResponseDTO
);
}
- if (($data['actionSucceed'] ?? true) === false || !empty($data['errorWrappers'])) {
+ if (($data['actionSucceed'] ?? true) === false || ! empty($data['errorWrappers'])) {
$errorCode = $this->extractErrorCode($data);
$this->logger->warning('VIES API returned error', [
@@ -182,6 +183,6 @@ private function maskVatNumber(string $vatNumber): string
return $vatNumber;
}
- return substr($vatNumber, 0, 2) . str_repeat('*', $length - 4) . substr($vatNumber, -2);
+ return substr($vatNumber, 0, 2).str_repeat('*', $length - 4).substr($vatNumber, -2);
}
}
diff --git a/backend/app/Services/Infrastructure/Webhook/WebhookDispatchService.php b/backend/app/Services/Infrastructure/Webhook/WebhookDispatchService.php
index 25fe3369ed..ea4a4ac438 100644
--- a/backend/app/Services/Infrastructure/Webhook/WebhookDispatchService.php
+++ b/backend/app/Services/Infrastructure/Webhook/WebhookDispatchService.php
@@ -3,6 +3,9 @@
namespace HiEvents\Services\Infrastructure\Webhook;
use HiEvents\DomainObjects\AttendeeDomainObject;
+use HiEvents\DomainObjects\EventLocationDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
+use HiEvents\DomainObjects\LocationDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\DomainObjects\QuestionAndAnswerViewDomainObject;
@@ -11,13 +14,15 @@
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\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
use HiEvents\Repository\Interfaces\WebhookRepositoryInterface;
-use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Resources\Attendee\AttendeeResource;
-use HiEvents\Resources\Event\EventResource;
use HiEvents\Resources\CheckInList\AttendeeCheckInResource;
+use HiEvents\Resources\Event\EventResource;
+use HiEvents\Resources\EventOccurrence\EventOccurrenceResource;
use HiEvents\Resources\Order\OrderResource;
use HiEvents\Resources\Product\ProductResource;
use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType;
@@ -28,20 +33,28 @@
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 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 EventRepositoryInterface $eventRepository,
+ private readonly EventOccurrenceRepositoryInterface $eventOccurrenceRepository,
+ ) {}
public function dispatchEventWebhook(DomainEventType $eventType, int $eventId): void
{
- $event = $this->eventRepository->findById($eventId);
+ $event = $this->eventRepository
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ], name: 'event_location'))
+ ->loadRelation(new Relationship(domainObject: EventOccurrenceDomainObject::class, nested: [
+ new Relationship(domainObject: EventLocationDomainObject::class, nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ], name: 'event_location'),
+ ]))
+ ->findById($eventId);
$this->dispatchWebhook(
eventType: $eventType,
@@ -57,6 +70,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 +100,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,16 +133,28 @@ 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: AttendeeDomainObject::class,
- nested: [
- new Relationship(
- domainObject: QuestionAndAnswerViewDomainObject::class,
- name: 'question_and_answer_views',
- ),
- ],
- name: 'attendees')
+ domainObject: OrderItemDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ name: 'event_occurrence',
+ ),
+ ],
+ ))
+ ->loadRelation(new Relationship(
+ domainObject: AttendeeDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: QuestionAndAnswerViewDomainObject::class,
+ name: 'question_and_answer_views',
+ ),
+ new Relationship(
+ domainObject: EventOccurrenceDomainObject::class,
+ name: 'event_occurrence',
+ ),
+ ],
+ name: 'attendees')
)
->loadRelation(QuestionAndAnswerViewDomainObject::class)
->findById($orderId);
@@ -145,7 +189,7 @@ public function dispatchOrderWebhook(DomainEventType $eventType, int $orderId):
private function dispatchWebhook(DomainEventType $eventType, JsonResource $payload, int $eventId): void
{
$webhooks = $this->webhookRepository->findEnabledByEventId($eventId)
- ->filter(fn(WebhookDomainObject $webhook) => in_array($eventType->value, $webhook->getEventTypes(), true));
+ ->filter(fn (WebhookDomainObject $webhook) => in_array($eventType->value, $webhook->getEventTypes(), true));
foreach ($webhooks as $webhook) {
$this->logger->info("Dispatching webhook for event ID: $eventId and webhook ID: {$webhook->getId()}");
@@ -155,7 +199,7 @@ private function dispatchWebhook(DomainEventType $eventType, JsonResource $paylo
->payload([
'event_type' => $eventType->value,
'event_sent_at' => now()->toIso8601String(),
- 'payload' => $payload->resolve()
+ 'payload' => $payload->resolve(),
])
->useSecret($webhook->getSecret())
->meta([
diff --git a/backend/app/Services/Infrastructure/Webhook/WebhookResponseHandlerService.php b/backend/app/Services/Infrastructure/Webhook/WebhookResponseHandlerService.php
index 5c2ccfd560..7de31f2c24 100644
--- a/backend/app/Services/Infrastructure/Webhook/WebhookResponseHandlerService.php
+++ b/backend/app/Services/Infrastructure/Webhook/WebhookResponseHandlerService.php
@@ -11,17 +11,17 @@
class WebhookResponseHandlerService
{
public function __construct(
- private readonly WebhookRepositoryInterface $webhookRepository,
- private readonly LoggerInterface $logger,
+ private readonly WebhookRepositoryInterface $webhookRepository,
+ private readonly LoggerInterface $logger,
private readonly WebhookLogRepositoryInterface $webhookLogRepository,
- private readonly DatabaseManager $databaseManager,
+ private readonly DatabaseManager $databaseManager,
) {}
public function handleResponse(
- int $eventId,
- int $webhookId,
- string $eventType,
- array $payload,
+ int $eventId,
+ int $webhookId,
+ string $eventType,
+ array $payload,
?Response $response
): void {
$this->databaseManager->transaction(function () use ($payload, $eventType, $eventId, $webhookId, $response) {
@@ -29,8 +29,9 @@ public function handleResponse(
'id' => $webhookId,
]);
- if (!$webhook) {
+ if (! $webhook) {
$this->logger->error("Webhook not found for ID: $webhookId and event ID: $eventId");
+
return;
}
diff --git a/backend/app/Validators/CompleteOrderValidator.php b/backend/app/Validators/CompleteOrderValidator.php
index 860ce4e296..8e20fb782c 100644
--- a/backend/app/Validators/CompleteOrderValidator.php
+++ b/backend/app/Validators/CompleteOrderValidator.php
@@ -22,20 +22,18 @@
class CompleteOrderValidator extends BaseValidator
{
public function __construct(
- private readonly QuestionRepositoryInterface $questionRepository,
- private readonly ProductRepositoryInterface $productRepository,
+ private readonly QuestionRepositoryInterface $questionRepository,
+ private readonly ProductRepositoryInterface $productRepository,
private readonly EventSettingsRepositoryInterface $eventSettingsRepository,
- private readonly Route $route
- )
- {
- }
+ private readonly Route $route
+ ) {}
public function rules(): array
{
$questions = $this->questionRepository
->loadRelation(
new Relationship(ProductDomainObject::class, [
- new Relationship(ProductPriceDomainObject::class)
+ new Relationship(ProductPriceDomainObject::class),
])
)
->findWhere(
@@ -43,11 +41,11 @@ public function rules(): array
);
$orderQuestions = $questions->filter(
- fn(QuestionDomainObject $question) => $question->getBelongsTo() === QuestionBelongsTo::ORDER->name
+ fn (QuestionDomainObject $question) => $question->getBelongsTo() === QuestionBelongsTo::ORDER->name
);
$productQuestions = $questions->filter(
- fn(QuestionDomainObject $question) => $question->getBelongsTo() === QuestionBelongsTo::PRODUCT->name
+ fn (QuestionDomainObject $question) => $question->getBelongsTo() === QuestionBelongsTo::PRODUCT->name
);
$products = $this->productRepository
@@ -82,7 +80,7 @@ public function rules(): array
$products,
$eventSettings->getAttendeeDetailsCollectionMethod(),
),
- ...$addressRules
+ ...$addressRules,
];
}
diff --git a/backend/app/Validators/DTO/CompleteOrderResponseDTO.php b/backend/app/Validators/DTO/CompleteOrderResponseDTO.php
index 6e70630da3..592183c6a8 100644
--- a/backend/app/Validators/DTO/CompleteOrderResponseDTO.php
+++ b/backend/app/Validators/DTO/CompleteOrderResponseDTO.php
@@ -2,7 +2,4 @@
namespace HiEvents\Validators\DTO;
-class CompleteOrderResponseDTO
-{
-
-}
+class CompleteOrderResponseDTO {}
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/app/Validators/Rules/AffiliateRules.php b/backend/app/Validators/Rules/AffiliateRules.php
index 7842cc2b10..eedcca30ff 100644
--- a/backend/app/Validators/Rules/AffiliateRules.php
+++ b/backend/app/Validators/Rules/AffiliateRules.php
@@ -27,4 +27,4 @@ public static function updateRules(): array
'status' => ['nullable', Rule::in(AffiliateStatus::valuesArray())],
];
}
-}
\ No newline at end of file
+}
diff --git a/backend/app/Validators/Rules/BaseQuestionRule.php b/backend/app/Validators/Rules/BaseQuestionRule.php
index 333d07d6ef..9a6a4fa3a9 100644
--- a/backend/app/Validators/Rules/BaseQuestionRule.php
+++ b/backend/app/Validators/Rules/BaseQuestionRule.php
@@ -4,13 +4,14 @@
use Closure;
use HiEvents\DomainObjects\Enums\QuestionTypeEnum;
-use HiEvents\DomainObjects\QuestionDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
+use HiEvents\DomainObjects\QuestionDomainObject;
use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Contracts\Validation\ValidatorAwareRule;
use Illuminate\Support\Collection;
+use Illuminate\Validation\ValidationException;
use Illuminate\Validation\Validator;
abstract class BaseQuestionRule implements ValidationRule, DataAwareRule, ValidatorAwareRule
@@ -75,12 +76,18 @@ public function setData(array $data): void
protected function getProductIdFromProductPriceId(int $productPriceId): int
{
- $productPrices = new Collection();
- $this->products->each(fn(ProductDomainObject $product) => $productPrices->push(...$product->getProductPrices()));
+ $productPrices = new Collection;
+ $this->products->each(fn (ProductDomainObject $product) => $productPrices->push(...$product->getProductPrices()));
- /** @var ProductPriceDomainObject $productPrice */
+ /** @var ProductPriceDomainObject|null $productPrice */
$productPrice = $productPrices
- ->first(fn(ProductPriceDomainObject $productPrice) => $productPrice->getId() === $productPriceId);
+ ->first(fn (ProductPriceDomainObject $productPrice) => $productPrice->getId() === $productPriceId);
+
+ if ($productPrice === null) {
+ throw ValidationException::withMessages([
+ __('This product is outdated. Please reload the page.'),
+ ]);
+ }
return $productPrice->getProductId();
}
@@ -91,20 +98,19 @@ protected function getQuestionDomainObject(?int $questionId): ?QuestionDomainObj
return null;
}
- return $this->questions->filter(fn($question) => $question->getId() === $questionId)?->first();
+ return $this->questions->filter(fn ($question) => $question->getId() === $questionId)?->first();
}
protected function validateRequiredFields(
QuestionDomainObject $questionDomainObject,
- mixed $response,
- string $key,
- array $validationMessages
- ): array
- {
+ mixed $response,
+ string $key,
+ array $validationMessages
+ ): array {
if ($questionDomainObject->getType() === QuestionTypeEnum::ADDRESS->name) {
foreach (self::ADDRESS_REQUIRED_FIELDS as $field) {
if (empty($response[$field])) {
- $validationMessages[$key . '.' . $field][] = __('This field is required.');
+ $validationMessages[$key.'.'.$field][] = __('This field is required.');
}
}
@@ -112,7 +118,7 @@ protected function validateRequiredFields(
}
if (empty($response) || (is_array($response) && empty($response['answer']))) {
- $validationMessages[$key . '.answer'][] = 'This field is required.';
+ $validationMessages[$key.'.answer'][] = 'This field is required.';
}
return $validationMessages;
@@ -120,25 +126,24 @@ protected function validateRequiredFields(
protected function validateResponseLength(
QuestionDomainObject $questionDomainObject,
- mixed $response,
- string $key,
- array $validationMessages
- ): array
- {
+ mixed $response,
+ string $key,
+ array $validationMessages
+ ): array {
if ($questionDomainObject->getType() === QuestionTypeEnum::ADDRESS->name) {
foreach (self::ADDRESS_FIELDS as $field) {
if (isset($response[$field]) && strlen($response[$field]) > 255) {
- $validationMessages[$key . '.' . $field][] = __('This field must be less than 255 characters.');
+ $validationMessages[$key.'.'.$field][] = __('This field must be less than 255 characters.');
} elseif (isset($response[$field]) && strlen($response[$field]) < 2) {
- $validationMessages[$key . '.' . $field][] = __('This field must be at least 2 characters.');
+ $validationMessages[$key.'.'.$field][] = __('This field must be at least 2 characters.');
}
}
return $validationMessages;
}
- if (isset($response['answer']) && !is_array($response['answer']) && strlen($response['answer']) > 255) {
- $validationMessages[$key . '.answer'][] = __('This field must be less than 255 characters.');
+ if (isset($response['answer']) && ! is_array($response['answer']) && strlen($response['answer']) > 255) {
+ $validationMessages[$key.'.answer'][] = __('This field must be less than 255 characters.');
}
return $validationMessages;
@@ -146,6 +151,6 @@ protected function validateResponseLength(
protected function getProductDomainObject(int $id): ?ProductDomainObject
{
- return $this->products->filter(fn($product) => $product->getId() === $id)?->first();
+ return $this->products->filter(fn ($product) => $product->getId() === $id)?->first();
}
}
diff --git a/backend/app/Validators/Rules/InsensitiveIn.php b/backend/app/Validators/Rules/InsensitiveIn.php
index b57b61ad4a..ea6e5add6e 100644
--- a/backend/app/Validators/Rules/InsensitiveIn.php
+++ b/backend/app/Validators/Rules/InsensitiveIn.php
@@ -16,8 +16,8 @@ public function __construct(array $values)
public function validate(string $attribute, mixed $value, Closure $fail): void
{
- if (!in_array(strtoupper($value), $this->values, true)) {
- $fail(':attribute must be one of ' . implode(', ', $this->values));
+ if (! in_array(strtoupper($value), $this->values, true)) {
+ $fail(':attribute must be one of '.implode(', ', $this->values));
}
}
}
diff --git a/backend/app/Validators/Rules/NoInternalUrlRule.php b/backend/app/Validators/Rules/NoInternalUrlRule.php
index a24c1df239..4e156e109e 100644
--- a/backend/app/Validators/Rules/NoInternalUrlRule.php
+++ b/backend/app/Validators/Rules/NoInternalUrlRule.php
@@ -29,20 +29,23 @@ class NoInternalUrlRule implements ValidationRule
public function validate(string $attribute, mixed $value, Closure $fail): void
{
- if (!is_string($value)) {
+ if (! is_string($value)) {
$fail(__('The :attribute must be a valid URL.'));
+
return;
}
$parsedUrl = parse_url($value);
- if ($parsedUrl === false || !isset($parsedUrl['host'])) {
+ if ($parsedUrl === false || ! isset($parsedUrl['host'])) {
$fail(__('The :attribute must be a valid URL.'));
+
return;
}
$scheme = strtolower($parsedUrl['scheme'] ?? '');
- if (!in_array($scheme, self::ALLOWED_SCHEMES, true)) {
+ if (! in_array($scheme, self::ALLOWED_SCHEMES, true)) {
$fail(__('The :attribute must use http or https protocol.'));
+
return;
}
@@ -60,21 +63,25 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
if ($this->isBlockedHost($host)) {
$fail(__('The :attribute cannot point to localhost or internal addresses.'));
+
return;
}
if ($this->isBlockedTld($host)) {
$fail(__('The :attribute cannot use reserved domain names.'));
+
return;
}
if ($this->isCloudMetadataHost($host)) {
$fail(__('The :attribute cannot point to cloud metadata endpoints.'));
+
return;
}
if ($this->isPrivateIpAddress($host)) {
$fail(__('The :attribute cannot point to private or internal IP addresses.'));
+
return;
}
}
@@ -91,16 +98,18 @@ private function isBlockedTld(string $host): bool
return true;
}
}
+
return false;
}
private function isCloudMetadataHost(string $host): bool
{
foreach (self::CLOUD_METADATA_HOSTS as $metadataHost) {
- if ($host === $metadataHost || str_ends_with($host, '.' . $metadataHost)) {
+ if ($host === $metadataHost || str_ends_with($host, '.'.$metadataHost)) {
return true;
}
}
+
return false;
}
@@ -112,7 +121,7 @@ private function isPrivateIpAddress(string $host): bool
return true;
}
- if (!filter_var($ip, FILTER_VALIDATE_IP)) {
+ if (! filter_var($ip, FILTER_VALIDATE_IP)) {
return true;
}
@@ -137,6 +146,7 @@ private function resolveAndNormalize(string $host): string|false
return inet_ntop(substr($binary, 12));
}
}
+
return $host;
}
@@ -155,12 +165,13 @@ private function resolveAndNormalize(string $host): string|false
private function isWhitelistedHost(string $host): bool
{
$whitelistedHosts = Config::string('app.allowed_internal_webhook_hosts');
- if (!empty($whitelistedHosts)) {
+ if (! empty($whitelistedHosts)) {
$allowedList = array_filter(array_map('trim', explode(',', $whitelistedHosts)));
if (in_array($host, $allowedList) || in_array(gethostbyname($host), $allowedList)) {
return true;
}
}
+
return false;
}
-}
\ No newline at end of file
+}
diff --git a/backend/app/Validators/Rules/OrderQuestionRule.php b/backend/app/Validators/Rules/OrderQuestionRule.php
index 303247849f..4f3c8603f6 100644
--- a/backend/app/Validators/Rules/OrderQuestionRule.php
+++ b/backend/app/Validators/Rules/OrderQuestionRule.php
@@ -14,13 +14,13 @@ class OrderQuestionRule extends BaseQuestionRule
protected function validateRequiredQuestionArePresent(Collection $orderQuestions): void
{
$requiredQuestionIds = $this->questions
- ->filter(fn(QuestionDomainObject $question) => $question->getRequired())
- ->filter(fn(QuestionDomainObject $question) => !$question->getIsHidden())
- ->map(fn(QuestionDomainObject $question) => $question->getId());
+ ->filter(fn (QuestionDomainObject $question) => $question->getRequired())
+ ->filter(fn (QuestionDomainObject $question) => ! $question->getIsHidden())
+ ->map(fn (QuestionDomainObject $question) => $question->getId());
if (array_diff($requiredQuestionIds->toArray(), $orderQuestions->pluck('question_id')->toArray())) {
throw ValidationException::withMessages([
- 'Required questions have not been answered. You may need to reload the page.'
+ 'Required questions have not been answered. You may need to reload the page.',
]);
}
}
@@ -30,16 +30,17 @@ protected function validateQuestions(mixed $questions): array
$validationMessages = [];
foreach ($questions as $index => $orderQuestion) {
$questionDomainObject = $this->getQuestionDomainObject($orderQuestion['question_id']);
- $key = 'order.questions.' . $index . '.response';
+ $key = 'order.questions.'.$index.'.response';
$response = $orderQuestion['response'] ?? null;
$answer = $response['answer'] ?? $response;
- if (!$questionDomainObject) {
- $validationMessages[$key . '.answer'][] = 'This question is outdated. Please reload the page.';
+ if (! $questionDomainObject) {
+ $validationMessages[$key.'.answer'][] = 'This question is outdated. Please reload the page.';
+
continue;
}
- if (is_null($response) && !$questionDomainObject->getRequired()) {
+ if (is_null($response) && ! $questionDomainObject->getRequired()) {
continue;
}
@@ -47,8 +48,8 @@ protected function validateQuestions(mixed $questions): array
$validationMessages = $this->validateRequiredFields($questionDomainObject, $response, $key, $validationMessages);
}
- if (!$questionDomainObject->isAnswerValid($answer)) {
- $validationMessages[$key . '.answer'][] = 'Please select an option';
+ if (! $questionDomainObject->isAnswerValid($answer)) {
+ $validationMessages[$key.'.answer'][] = 'Please select an option';
}
$validationMessages = $this->validateResponseLength($questionDomainObject, $response, $key, $validationMessages);
diff --git a/backend/app/Validators/Rules/ProductQuestionRule.php b/backend/app/Validators/Rules/ProductQuestionRule.php
index 2722c7a5e3..8f81abc907 100644
--- a/backend/app/Validators/Rules/ProductQuestionRule.php
+++ b/backend/app/Validators/Rules/ProductQuestionRule.php
@@ -22,26 +22,33 @@ public function __construct(
$this->skipBasicAttendeeValidation = $attendeeDetailsCollectionMethod === AttendeeDetailsCollectionMethod::PER_ORDER->name;
}
+
/**
* @throws ValidationException
*/
protected function validateRequiredQuestionArePresent(Collection $orderProducts): void
{
foreach ($orderProducts as $productData) {
- $productId = $this->getProductIdFromProductPriceId($productData['product_price_id']);
+ if (! isset($productData['product_price_id']) || ! is_numeric($productData['product_price_id'])) {
+ throw ValidationException::withMessages([
+ __('This product is outdated. Please reload the page.'),
+ ]);
+ }
+
+ $productId = $this->getProductIdFromProductPriceId((int) $productData['product_price_id']);
$questions = $productData['questions'] ?? [];
$requiredQuestionIds = $this->questions
->filter(function (QuestionDomainObject $question) use ($productId) {
return $question->getRequired()
- && !$question->getIsHidden()
- && $question->getProducts()?->map(fn($product) => $product->getId())->contains($productId);
+ && ! $question->getIsHidden()
+ && $question->getProducts()?->map(fn ($product) => $product->getId())->contains($productId);
})
- ->map(fn(QuestionDomainObject $question) => $question->getId());
+ ->map(fn (QuestionDomainObject $question) => $question->getId());
if (array_diff($requiredQuestionIds->toArray(), collect($questions)->pluck('question_id')->toArray())) {
throw ValidationException::withMessages([
- __('Required questions have not been answered. You may need to reload the page.')
+ __('Required questions have not been answered. You may need to reload the page.'),
]);
}
}
@@ -52,14 +59,21 @@ protected function validateQuestions(mixed $products): array
$validationMessages = [];
foreach ($products as $productIndex => $productRequestData) {
- $productDomainObject = $this->getProductDomainObject($productRequestData['product_id']);
+ if (! isset($productRequestData['product_id']) || ! is_numeric($productRequestData['product_id'])) {
+ throw ValidationException::withMessages([
+ __('This product is outdated. Please reload the page.'),
+ ]);
+ }
+
+ $productDomainObject = $this->getProductDomainObject((int) $productRequestData['product_id']);
+
+ if (! $productDomainObject) {
+ $validationMessages['products.'.$productIndex][] = __('This product is outdated. Please reload the page.');
- if (!$productDomainObject) {
- $validationMessages['products.' . $productIndex][] = __('This product is outdated. Please reload the page.');
continue;
}
- if ($productDomainObject->getProductType() === ProductType::TICKET->name && !$this->skipBasicAttendeeValidation) {
+ if ($productDomainObject->getProductType() === ProductType::TICKET->name && ! $this->skipBasicAttendeeValidation) {
$validationMessages = [
...$validationMessages,
...$this->validateBasicTicketFields($productRequestData, $productIndex),
@@ -69,16 +83,17 @@ protected function validateQuestions(mixed $products): array
$questions = $productRequestData['questions'] ?? [];
foreach ($questions as $questionIndex => $question) {
$questionDomainObject = $this->getQuestionDomainObject($question['question_id'] ?? null);
- $key = 'products.' . $productIndex . '.questions.' . $questionIndex . '.response';
+ $key = 'products.'.$productIndex.'.questions.'.$questionIndex.'.response';
$response = empty($question['response']) ? null : $question['response'];
$answer = $response['answer'] ?? $response;
- if (!$questionDomainObject) {
- $validationMessages[$key . '.answer'][] = __('This question is outdated. Please reload the page.');
+ if (! $questionDomainObject) {
+ $validationMessages[$key.'.answer'][] = __('This question is outdated. Please reload the page.');
+
continue;
}
- if (is_null($response) && !$questionDomainObject->getRequired()) {
+ if (is_null($response) && ! $questionDomainObject->getRequired()) {
continue;
}
@@ -86,8 +101,8 @@ protected function validateQuestions(mixed $products): array
$validationMessages = $this->validateRequiredFields($questionDomainObject, $response, $key, $validationMessages);
}
- if (!$questionDomainObject->isAnswerValid($answer)) {
- $validationMessages[$key . '.answer'][] = __('Please select an option');
+ if (! $questionDomainObject->isAnswerValid($answer)) {
+ $validationMessages[$key.'.answer'][] = __('Please select an option');
}
$validationMessages = $this->validateResponseLength($questionDomainObject, $response, $key, $validationMessages);
diff --git a/backend/app/Validators/Rules/RequiredIf.php b/backend/app/Validators/Rules/RequiredIf.php
index fabcf7836c..496e944d39 100644
--- a/backend/app/Validators/Rules/RequiredIf.php
+++ b/backend/app/Validators/Rules/RequiredIf.php
@@ -8,7 +8,6 @@
class RequiredIf implements ValidationRule
{
-
public bool $implicit = true;
private Request $request;
diff --git a/backend/app/Validators/Rules/RulesHelper.php b/backend/app/Validators/Rules/RulesHelper.php
index dbc8d8ff53..ba5573d94b 100644
--- a/backend/app/Validators/Rules/RulesHelper.php
+++ b/backend/app/Validators/Rules/RulesHelper.php
@@ -6,22 +6,22 @@ class RulesHelper
{
public const MONEY = ['gte:0', 'numeric', 'decimal:0,2', 'max:999999999999'];
- public const REQUIRED_STRING = ['string' , 'required', 'max:100', 'min:1'];
+ public const REQUIRED_STRING = ['string', 'required', 'max:100', 'min:1'];
- public const REQUIRED_NUMERIC= ['numeric' , 'required'];
+ public const REQUIRED_NUMERIC = ['numeric', 'required'];
public const STRING = ['string', 'max:100', 'min:1', 'nullable'];
public const HEX_COLOR = ['string', 'max:9', 'min:4', 'regex:/\#(?:[0-9a-fA-F]{3}){1,2}$|^\#(?:[0-9a-fA-F]{4}){1,2}$/'];
- public const REQUIRED_EMAIL = ['email' , 'required', 'max:100'];
+ public const REQUIRED_EMAIL = ['email', 'required', 'max:100'];
public const OPTIONAL_TEXT_MEDIUM_LENGTH = ['string', 'max:2000', 'nullable'];
public const IMAGE_RULES = [
'required',
'image',
- 'max:8192', //8mb
+ 'max:8192', // 8mb
'dimensions:min_width=600,min_height=50,max_width=4000,max_height=4000',
'mimes:jpeg,png,jpg,webp',
];
diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php
index 90832018c1..fa2f776310 100644
--- a/backend/bootstrap/app.php
+++ b/backend/bootstrap/app.php
@@ -1,5 +1,10 @@
singleton(
- Illuminate\Contracts\Http\Kernel::class,
- \HiEvents\Http\Kernel::class
+ Kernel::class,
+ HiEvents\Http\Kernel::class
);
$app->singleton(
Illuminate\Contracts\Console\Kernel::class,
- \HiEvents\Console\Kernel::class
+ HiEvents\Console\Kernel::class
);
$app->singleton(
- Illuminate\Contracts\Debug\ExceptionHandler::class,
- \HiEvents\Exceptions\Handler::class
+ ExceptionHandler::class,
+ Handler::class
);
/*
diff --git a/backend/composer.json b/backend/composer.json
index e77240c677..79cbf74ea0 100644
--- a/backend/composer.json
+++ b/backend/composer.json
@@ -4,9 +4,9 @@
"description": "hi.events - Ticket selling and event management.",
"keywords": ["ticketing", "events"],
"license": "AGPL-3.0",
- "version": "1.11.0-beta",
+ "version": "2.0.0-alpha.1",
"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..9360106d4c 100644
--- a/backend/config/app.php
+++ b/backend/config/app.php
@@ -1,6 +1,11 @@
'/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',
@@ -107,7 +112,7 @@
|
*/
- 'debug' => (bool)env('APP_DEBUG', false),
+ 'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
@@ -228,12 +233,12 @@
/*
* Application Service Providers...
*/
- \HiEvents\Providers\AppServiceProvider::class,
- \HiEvents\Providers\AuthServiceProvider::class,
+ AppServiceProvider::class,
+ AuthServiceProvider::class,
// App\Providers\BroadcastServiceProvider::class,
- \HiEvents\Providers\EventServiceProvider::class,
- \HiEvents\Providers\RouteServiceProvider::class,
- \HiEvents\Providers\RepositoryServiceProvider::class
+ EventServiceProvider::class,
+ RouteServiceProvider::class,
+ RepositoryServiceProvider::class,
])->toArray(),
@@ -252,6 +257,5 @@
// 'Example' => App\Facades\Example::class,
])->toArray(),
-
'is_hi_events' => env('APP_IS_HI_EVENTS', false),
];
diff --git a/backend/config/auth.php b/backend/config/auth.php
index 17cb955ce8..35c15d86b3 100644
--- a/backend/config/auth.php
+++ b/backend/config/auth.php
@@ -1,5 +1,7 @@
[
'users' => [
'driver' => 'eloquent',
- 'model' => \HiEvents\Models\User::class,
+ 'model' => User::class,
],
// 'users' => [
diff --git a/backend/config/cors.php b/backend/config/cors.php
index b9018a8500..327ba940aa 100644
--- a/backend/config/cors.php
+++ b/backend/config/cors.php
@@ -31,4 +31,3 @@
'supports_credentials' => true,
];
-
diff --git a/backend/config/data.php b/backend/config/data.php
index 62bf210915..9aed39bfa2 100644
--- a/backend/config/data.php
+++ b/backend/config/data.php
@@ -1,5 +1,23 @@
[
- DateTimeInterface::class => \Spatie\LaravelData\Transformers\DateTimeInterfaceTransformer::class,
- \Illuminate\Contracts\Support\Arrayable::class => \Spatie\LaravelData\Transformers\ArrayableTransformer::class,
- BackedEnum::class => Spatie\LaravelData\Transformers\EnumTransformer::class,
+ DateTimeInterface::class => DateTimeInterfaceTransformer::class,
+ Arrayable::class => ArrayableTransformer::class,
+ BackedEnum::class => EnumTransformer::class,
],
/*
@@ -46,9 +64,9 @@
* object from simple types.
*/
'casts' => [
- DateTimeInterface::class => Spatie\LaravelData\Casts\DateTimeInterfaceCast::class,
- BackedEnum::class => Spatie\LaravelData\Casts\EnumCast::class,
-// Enumerable::class => Spatie\LaravelData\Casts\EnumerableCast::class,
+ DateTimeInterface::class => DateTimeInterfaceCast::class,
+ BackedEnum::class => EnumCast::class,
+ // Enumerable::class => Spatie\LaravelData\Casts\EnumerableCast::class,
],
/*
@@ -57,11 +75,11 @@
* the type of the property.
*/
'rule_inferrers' => [
- Spatie\LaravelData\RuleInferrers\SometimesRuleInferrer::class,
- Spatie\LaravelData\RuleInferrers\NullableRuleInferrer::class,
- Spatie\LaravelData\RuleInferrers\RequiredRuleInferrer::class,
- Spatie\LaravelData\RuleInferrers\BuiltInTypesRuleInferrer::class,
- Spatie\LaravelData\RuleInferrers\AttributesRuleInferrer::class,
+ SometimesRuleInferrer::class,
+ NullableRuleInferrer::class,
+ RequiredRuleInferrer::class,
+ BuiltInTypesRuleInferrer::class,
+ AttributesRuleInferrer::class,
],
/*
@@ -70,12 +88,12 @@
* every data object, unless overridden in a specific data object class.
*/
'normalizers' => [
- Spatie\LaravelData\Normalizers\ModelNormalizer::class,
+ ModelNormalizer::class,
// Spatie\LaravelData\Normalizers\FormRequestNormalizer::class,
- Spatie\LaravelData\Normalizers\ArrayableNormalizer::class,
- Spatie\LaravelData\Normalizers\ObjectNormalizer::class,
- Spatie\LaravelData\Normalizers\ArrayNormalizer::class,
- Spatie\LaravelData\Normalizers\JsonNormalizer::class,
+ ArrayableNormalizer::class,
+ ObjectNormalizer::class,
+ ArrayNormalizer::class,
+ JsonNormalizer::class,
],
/*
@@ -124,7 +142,7 @@
* method. By default, only when a request is passed the data is being validated. This
* behaviour can be changed to always validate or to completely disable validation.
*/
- 'validation_strategy' => \Spatie\LaravelData\Support\Creation\ValidationStrategy::OnlyRequests->value,
+ 'validation_strategy' => ValidationStrategy::OnlyRequests->value,
/*
* A data object can map the names of its properties when transforming (output) or when
diff --git a/backend/config/database.php b/backend/config/database.php
index a3d58d1988..84380d4b1d 100644
--- a/backend/config/database.php
+++ b/backend/config/database.php
@@ -1,10 +1,11 @@
true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
- PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
+ (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
diff --git a/backend/config/excel.php b/backend/config/excel.php
index 987883ea71..18fead2027 100644
--- a/backend/config/excel.php
+++ b/backend/config/excel.php
@@ -1,5 +1,6 @@
1000,
+ 'chunk_size' => 1000,
/*
|--------------------------------------------------------------------------
@@ -41,15 +42,15 @@
| Configure e.g. delimiter, enclosure and line ending for CSV exports.
|
*/
- 'csv' => [
- 'delimiter' => ',',
- 'enclosure' => '"',
- 'line_ending' => PHP_EOL,
- 'use_bom' => false,
+ 'csv' => [
+ 'delimiter' => ',',
+ 'enclosure' => '"',
+ 'line_ending' => PHP_EOL,
+ 'use_bom' => false,
'include_separator_line' => false,
- 'excel_compatibility' => false,
- 'output_encoding' => '',
- 'test_auto_detect' => true,
+ 'excel_compatibility' => false,
+ 'output_encoding' => '',
+ 'test_auto_detect' => true,
],
/*
@@ -60,20 +61,20 @@
| Configure e.g. default title, creator, subject,...
|
*/
- 'properties' => [
- 'creator' => '',
+ 'properties' => [
+ 'creator' => '',
'lastModifiedBy' => '',
- 'title' => '',
- 'description' => '',
- 'subject' => '',
- 'keywords' => '',
- 'category' => '',
- 'manager' => '',
- 'company' => '',
+ 'title' => '',
+ 'description' => '',
+ 'subject' => '',
+ 'keywords' => '',
+ 'category' => '',
+ 'manager' => '',
+ 'company' => '',
],
],
- 'imports' => [
+ 'imports' => [
/*
|--------------------------------------------------------------------------
@@ -122,12 +123,12 @@
| Configure e.g. delimiter, enclosure and line ending for CSV imports.
|
*/
- 'csv' => [
- 'delimiter' => null,
- 'enclosure' => '"',
+ 'csv' => [
+ 'delimiter' => null,
+ 'enclosure' => '"',
'escape_character' => '\\',
- 'contiguous' => false,
- 'input_encoding' => 'UTF-8',
+ 'contiguous' => false,
+ 'input_encoding' => 'UTF-8',
],
/*
@@ -138,16 +139,16 @@
| Configure e.g. default title, creator, subject,...
|
*/
- 'properties' => [
- 'creator' => '',
+ 'properties' => [
+ 'creator' => '',
'lastModifiedBy' => '',
- 'title' => '',
- 'description' => '',
- 'subject' => '',
- 'keywords' => '',
- 'category' => '',
- 'manager' => '',
- 'company' => '',
+ 'title' => '',
+ 'description' => '',
+ 'subject' => '',
+ 'keywords' => '',
+ 'category' => '',
+ 'manager' => '',
+ 'company' => '',
],
],
@@ -162,21 +163,21 @@
|
*/
'extension_detector' => [
- 'xlsx' => Excel::XLSX,
- 'xlsm' => Excel::XLSX,
- 'xltx' => Excel::XLSX,
- 'xltm' => Excel::XLSX,
- 'xls' => Excel::XLS,
- 'xlt' => Excel::XLS,
- 'ods' => Excel::ODS,
- 'ots' => Excel::ODS,
- 'slk' => Excel::SLK,
- 'xml' => Excel::XML,
+ 'xlsx' => Excel::XLSX,
+ 'xlsm' => Excel::XLSX,
+ 'xltx' => Excel::XLSX,
+ 'xltm' => Excel::XLSX,
+ 'xls' => Excel::XLS,
+ 'xlt' => Excel::XLS,
+ 'ods' => Excel::ODS,
+ 'ots' => Excel::ODS,
+ 'slk' => Excel::SLK,
+ 'xml' => Excel::XML,
'gnumeric' => Excel::GNUMERIC,
- 'htm' => Excel::HTML,
- 'html' => Excel::HTML,
- 'csv' => Excel::CSV,
- 'tsv' => Excel::TSV,
+ 'htm' => Excel::HTML,
+ 'html' => Excel::HTML,
+ 'csv' => Excel::CSV,
+ 'tsv' => Excel::TSV,
/*
|--------------------------------------------------------------------------
@@ -187,7 +188,7 @@
| Available options: Excel::MPDF | Excel::TCPDF | Excel::DOMPDF
|
*/
- 'pdf' => Excel::DOMPDF,
+ 'pdf' => Excel::DOMPDF,
],
/*
@@ -208,7 +209,7 @@
|
*/
'value_binder' => [
- 'default' => Maatwebsite\Excel\DefaultValueBinder::class,
+ 'default' => DefaultValueBinder::class,
],
'cache' => [
@@ -228,7 +229,7 @@
| Drivers: memory|illuminate|batch
|
*/
- 'driver' => 'memory',
+ 'driver' => 'memory',
/*
|--------------------------------------------------------------------------
@@ -240,7 +241,7 @@
| Here you can tweak the memory limit to your liking.
|
*/
- 'batch' => [
+ 'batch' => [
'memory_limit' => 60000,
],
@@ -278,7 +279,7 @@
*/
'transactions' => [
'handler' => 'db',
- 'db' => [
+ 'db' => [
'connection' => null,
],
],
@@ -294,7 +295,7 @@
| storing reading or downloading. Here you can customize that path.
|
*/
- 'local_path' => storage_path('framework/cache/laravel-excel'),
+ 'local_path' => storage_path('framework/cache/laravel-excel'),
/*
|--------------------------------------------------------------------------
@@ -310,8 +311,8 @@
| in conjunction with queued imports and exports.
|
*/
- 'remote_disk' => null,
- 'remote_prefix' => null,
+ 'remote_disk' => null,
+ 'remote_prefix' => null,
/*
|--------------------------------------------------------------------------
diff --git a/backend/config/jwt.php b/backend/config/jwt.php
index 6970e79af3..9d4900fb7e 100644
--- a/backend/config/jwt.php
+++ b/backend/config/jwt.php
@@ -1,5 +1,8 @@
PHPOpenSourceSaver\JWTAuth\Providers\JWT\Lcobucci::class,
+ 'jwt' => Lcobucci::class,
/*
|--------------------------------------------------------------------------
@@ -284,7 +287,7 @@
|
*/
- 'auth' => PHPOpenSourceSaver\JWTAuth\Providers\Auth\Illuminate::class,
+ 'auth' => Illuminate::class,
/*
|--------------------------------------------------------------------------
diff --git a/backend/config/queue.php b/backend/config/queue.php
index 52622994f0..fd24f7d4fc 100644
--- a/backend/config/queue.php
+++ b/backend/config/queue.php
@@ -1,13 +1,10 @@
env('WEBHOOK_QUEUE_NAME', env('QUEUE_CONNECTION', 'sync')),
+ 'occurrences_queue_name' => env('OCCURRENCES_QUEUE_NAME'),
+
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
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/config/webhook-server.php b/backend/config/webhook-server.php
index cd54853f1f..ada90c12ec 100644
--- a/backend/config/webhook-server.php
+++ b/backend/config/webhook-server.php
@@ -1,5 +1,9 @@
\Spatie\WebhookServer\Signer\DefaultSigner::class,
+ 'signer' => DefaultSigner::class,
/*
* This is the name of the header where the signature will be added.
@@ -58,12 +62,12 @@
/*
* This class determines how many seconds there should be between attempts.
*/
- 'backoff_strategy' => \Spatie\WebhookServer\BackoffStrategy\ExponentialBackoffStrategy::class,
+ 'backoff_strategy' => ExponentialBackoffStrategy::class,
/*
* This class is used to dispatch webhooks onto the queue.
*/
- 'webhook_job' => \Spatie\WebhookServer\CallWebhookJob::class,
+ 'webhook_job' => CallWebhookJob::class,
/*
* By default we will verify that the ssl certificate of the destination
diff --git a/backend/database/factories/AccountFactory.php b/backend/database/factories/AccountFactory.php
index 2968b43255..51248b7934 100644
--- a/backend/database/factories/AccountFactory.php
+++ b/backend/database/factories/AccountFactory.php
@@ -5,18 +5,14 @@
namespace Database\Factories;
use HiEvents\Helper\IdHelper;
+use HiEvents\Models\Account;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
- * @extends \Illuminate\Database\Eloquent\Factories\Factory<\HiEvents\Models\Account>
+ * @extends Factory
*/
class AccountFactory extends Factory
{
- /**
- * Define the model's default state.
- *
- * @return array
- */
public function definition(): array
{
$currencies = include base_path('data/currencies.php');
@@ -27,46 +23,20 @@ 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) => [
+ return $this->state(fn (array $attributes) => [
'account_verified_at' => now(),
]);
}
- /**
- * Indicate that the model has been manually verified.
- */
public function manuallyVerified(): self
{
- return $this->state(fn(array $attributes) => [
+ return $this->state(fn (array $attributes) => [
'is_manually_verified' => true,
]);
}
diff --git a/backend/database/factories/AccountVatSettingFactory.php b/backend/database/factories/AccountVatSettingFactory.php
index 93f7aa0d00..fa4f97cee6 100644
--- a/backend/database/factories/AccountVatSettingFactory.php
+++ b/backend/database/factories/AccountVatSettingFactory.php
@@ -9,7 +9,7 @@
use Illuminate\Database\Eloquent\Factories\Factory;
/**
- * @extends \Illuminate\Database\Eloquent\Factories\Factory<\HiEvents\Models\AccountVatSetting>
+ * @extends Factory
*/
class AccountVatSettingFactory extends Factory
{
@@ -32,9 +32,9 @@ public function definition(): array
public function registered(): self
{
$countryCode = fake()->randomElement(['IE', 'DE', 'FR', 'ES', 'NL', 'IT']);
- $vatNumber = $countryCode . fake()->numerify('########');
+ $vatNumber = $countryCode.fake()->numerify('########');
- return $this->state(fn(array $attributes) => [
+ return $this->state(fn (array $attributes) => [
'vat_registered' => true,
'vat_number' => $vatNumber,
'vat_country_code' => $countryCode,
@@ -44,9 +44,9 @@ public function registered(): self
public function validated(): self
{
$countryCode = fake()->randomElement(['IE', 'DE', 'FR', 'ES', 'NL', 'IT']);
- $vatNumber = $countryCode . fake()->numerify('########');
+ $vatNumber = $countryCode.fake()->numerify('########');
- return $this->state(fn(array $attributes) => [
+ return $this->state(fn (array $attributes) => [
'vat_registered' => true,
'vat_number' => $vatNumber,
'vat_validated' => true,
@@ -59,7 +59,7 @@ public function validated(): self
public function notRegistered(): self
{
- return $this->state(fn(array $attributes) => [
+ return $this->state(fn (array $attributes) => [
'vat_registered' => false,
'vat_number' => null,
'vat_validated' => false,
diff --git a/backend/database/factories/OrderFactory.php b/backend/database/factories/OrderFactory.php
index f0a6fe8d67..bd119add0c 100644
--- a/backend/database/factories/OrderFactory.php
+++ b/backend/database/factories/OrderFactory.php
@@ -2,10 +2,10 @@
namespace Database\Factories;
-use Illuminate\Database\Eloquent\Factories\Factory;
-use Illuminate\Support\Str;
use HiEvents\Helper\IdHelper;
use HiEvents\Models\Order;
+use Illuminate\Database\Eloquent\Factories\Factory;
+use Illuminate\Support\Str;
class OrderFactory extends Factory
{
diff --git a/backend/database/factories/UserFactory.php b/backend/database/factories/UserFactory.php
index 849bc48d5d..922a88d7e8 100644
--- a/backend/database/factories/UserFactory.php
+++ b/backend/database/factories/UserFactory.php
@@ -13,13 +13,11 @@
use Illuminate\Support\Facades\Hash;
/**
- * @extends \Illuminate\Database\Eloquent\Factories\Factory<\HiEvents\Core\Models\User>
+ * @extends Factory<\HiEvents\Core\Models\User>
*/
class UserFactory extends Factory
{
/**
- * Define the model's default state.
- *
* @return array
*/
public function definition(): array
@@ -37,40 +35,31 @@ public function definition(): array
public function pendingEmail(?string $email = null): self
{
- return $this->state(fn(array $attributes) => [
+ return $this->state(fn (array $attributes) => [
'pending_email' => $email ?? fake()->unique()->safeEmail(),
]);
}
- /**
- * Set the user's password.
- */
public function password(string $password): static
{
- return $this->state(fn(array $attributes) => [
+ return $this->state(fn (array $attributes) => [
'password' => Hash::make($password),
]);
}
- /**
- * Indicate that the model's email address should be unverified.
- */
public function unverified(): static
{
- return $this->state(fn(array $attributes) => [
+ return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
- /**
- * Saves an Account to the database and attaches it to the user.
- */
public function withAccount(): static
{
return $this->afterCreating(function (User $user): void {
$account = Account::factory()->verified()->create();
$account->timezone = $user->timezone;
- $account->name = $user->first_name . ($user->last_name ? ' ' . $user->last_name : '');
+ $account->name = $user->first_name.($user->last_name ? ' '.$user->last_name : '');
$account->email = strtolower($user->email);
$user->accounts()->attach($account, [
diff --git a/backend/database/migrations/2020_01_25_113926_initial_db.php b/backend/database/migrations/2020_01_25_113926_initial_db.php
index a392f3ecbf..3c78486513 100644
--- a/backend/database/migrations/2020_01_25_113926_initial_db.php
+++ b/backend/database/migrations/2020_01_25_113926_initial_db.php
@@ -12,8 +12,8 @@ class InitialDb extends Migration
*/
public function up()
{
- DB::unprepared(file_get_contents(__DIR__ . '/extensions.sql'));
- DB::unprepared(file_get_contents(__DIR__ . '/schema.sql'));
+ DB::unprepared(file_get_contents(__DIR__.'/extensions.sql'));
+ DB::unprepared(file_get_contents(__DIR__.'/schema.sql'));
}
/**
diff --git a/backend/database/migrations/2024_06_16_192150_add_locale_to_attendees.php b/backend/database/migrations/2024_06_16_192150_add_locale_to_attendees.php
index 571584a9d6..2a651ffa16 100644
--- a/backend/database/migrations/2024_06_16_192150_add_locale_to_attendees.php
+++ b/backend/database/migrations/2024_06_16_192150_add_locale_to_attendees.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::table('attendees', static function (Blueprint $table) {
diff --git a/backend/database/migrations/2024_06_16_192151_add_locale_to_orders.php b/backend/database/migrations/2024_06_16_192151_add_locale_to_orders.php
index e13958018e..fdc1aa83e3 100644
--- a/backend/database/migrations/2024_06_16_192151_add_locale_to_orders.php
+++ b/backend/database/migrations/2024_06_16_192151_add_locale_to_orders.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::table('orders', static function (Blueprint $table) {
diff --git a/backend/database/migrations/2024_07_11_223142_add_description_to_questions_table.php b/backend/database/migrations/2024_07_11_223142_add_description_to_questions_table.php
index fe870768fc..2907a4fb49 100644
--- a/backend/database/migrations/2024_07_11_223142_add_description_to_questions_table.php
+++ b/backend/database/migrations/2024_07_11_223142_add_description_to_questions_table.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::table('questions', static function (Blueprint $table) {
diff --git a/backend/database/migrations/2024_07_14_031511_create_capacity_assignments_and_associated_tables.php b/backend/database/migrations/2024_07_14_031511_create_capacity_assignments_and_associated_tables.php
index 77839e5262..5626cd8cac 100644
--- a/backend/database/migrations/2024_07_14_031511_create_capacity_assignments_and_associated_tables.php
+++ b/backend/database/migrations/2024_07_14_031511_create_capacity_assignments_and_associated_tables.php
@@ -6,7 +6,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::create('capacity_assignments', function (Blueprint $table) {
diff --git a/backend/database/migrations/2024_07_19_033929_add_missing_indexes.php b/backend/database/migrations/2024_07_19_033929_add_missing_indexes.php
index 897f64a002..29d25bd33c 100644
--- a/backend/database/migrations/2024_07_19_033929_add_missing_indexes.php
+++ b/backend/database/migrations/2024_07_19_033929_add_missing_indexes.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
diff --git a/backend/database/migrations/2024_08_07_005807_create_stripe_customers_table.php b/backend/database/migrations/2024_08_07_005807_create_stripe_customers_table.php
index 6b62ad4db8..2517ac6271 100644
--- a/backend/database/migrations/2024_08_07_005807_create_stripe_customers_table.php
+++ b/backend/database/migrations/2024_08_07_005807_create_stripe_customers_table.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::create('stripe_customers', function (Blueprint $table) {
diff --git a/backend/database/migrations/2024_08_08_032637_create_check_in_lists_tables.php b/backend/database/migrations/2024_08_08_032637_create_check_in_lists_tables.php
index febfebee70..fa81590db3 100644
--- a/backend/database/migrations/2024_08_08_032637_create_check_in_lists_tables.php
+++ b/backend/database/migrations/2024_08_08_032637_create_check_in_lists_tables.php
@@ -5,7 +5,8 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::create('check_in_lists', static function (Blueprint $table) {
diff --git a/backend/database/migrations/2024_09_20_032323_rename_tickets_to_products.php b/backend/database/migrations/2024_09_20_032323_rename_tickets_to_products.php
index 907f3fabba..b61772522d 100644
--- a/backend/database/migrations/2024_09_20_032323_rename_tickets_to_products.php
+++ b/backend/database/migrations/2024_09_20_032323_rename_tickets_to_products.php
@@ -5,7 +5,8 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::rename('tickets', 'products');
diff --git a/backend/database/migrations/2024_09_20_032838_add_product_type_to_products.php b/backend/database/migrations/2024_09_20_032838_add_product_type_to_products.php
index fd8974b75a..71b8d6621b 100644
--- a/backend/database/migrations/2024_09_20_032838_add_product_type_to_products.php
+++ b/backend/database/migrations/2024_09_20_032838_add_product_type_to_products.php
@@ -5,7 +5,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::table('products', static function (Blueprint $table) {
diff --git a/backend/database/migrations/2024_09_23_032009_add_product_categories_table.php b/backend/database/migrations/2024_09_23_032009_add_product_categories_table.php
index 5a4b2f8824..c4bbdbd319 100644
--- a/backend/database/migrations/2024_09_23_032009_add_product_categories_table.php
+++ b/backend/database/migrations/2024_09_23_032009_add_product_categories_table.php
@@ -5,7 +5,8 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::create('product_categories', static function (Blueprint $table) {
diff --git a/backend/database/migrations/2024_09_29_053757_add_product_type_to_order_items_table.php b/backend/database/migrations/2024_09_29_053757_add_product_type_to_order_items_table.php
index 79f7a26e7f..449f818111 100644
--- a/backend/database/migrations/2024_09_29_053757_add_product_type_to_order_items_table.php
+++ b/backend/database/migrations/2024_09_29_053757_add_product_type_to_order_items_table.php
@@ -5,7 +5,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::table('order_items', function (Blueprint $table) {
diff --git a/backend/database/migrations/2024_10_01_003655_update_question_and_answer_views_view.php b/backend/database/migrations/2024_10_01_003655_update_question_and_answer_views_view.php
index 4f0db80e33..ff66ea9c33 100644
--- a/backend/database/migrations/2024_10_01_003655_update_question_and_answer_views_view.php
+++ b/backend/database/migrations/2024_10_01_003655_update_question_and_answer_views_view.php
@@ -3,12 +3,13 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
DB::statement('DROP VIEW IF EXISTS question_and_answer_views');
- DB::statement("
+ DB::statement('
CREATE VIEW question_and_answer_views AS
SELECT p.id AS product_id,
p.title AS product_title,
@@ -26,14 +27,14 @@ public function up(): void
LEFT JOIN attendees a ON a.id = qa.attendee_id
LEFT JOIN products p ON p.id = qa.product_id
JOIN questions q ON q.id = qa.question_id;
- ");
+ ');
}
public function down(): void
{
DB::statement('DROP VIEW IF EXISTS question_and_answer_views');
- DB::statement("
+ DB::statement('
CREATE VIEW question_and_answer_views AS
SELECT q.id AS question_id,
q.event_id,
@@ -49,6 +50,6 @@ public function down(): void
LEFT JOIN attendees a ON a.id = qa.attendee_id
JOIN orders o ON qa.order_id = o.id
JOIN questions q ON q.id = qa.question_id;
- ");
+ ');
}
};
diff --git a/backend/database/migrations/2024_10_02_031052_add_attendees_registered_to_event_statistics_tables.php b/backend/database/migrations/2024_10_02_031052_add_attendees_registered_to_event_statistics_tables.php
index 86f57e2303..4a6af32d89 100644
--- a/backend/database/migrations/2024_10_02_031052_add_attendees_registered_to_event_statistics_tables.php
+++ b/backend/database/migrations/2024_10_02_031052_add_attendees_registered_to_event_statistics_tables.php
@@ -5,10 +5,11 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
- if (!Schema::hasColumn('event_statistics', 'attendees_registered')) {
+ if (! Schema::hasColumn('event_statistics', 'attendees_registered')) {
Schema::table('event_statistics', static function (Blueprint $table) {
$table->unsignedInteger('attendees_registered')->default(0);
});
@@ -16,7 +17,7 @@ public function up(): void
DB::statement('UPDATE event_statistics SET attendees_registered = products_sold');
}
- if (!Schema::hasColumn('event_daily_statistics', 'attendees_registered')) {
+ if (! Schema::hasColumn('event_daily_statistics', 'attendees_registered')) {
Schema::table('event_daily_statistics', static function (Blueprint $table) {
$table->unsignedInteger('attendees_registered')->default(0);
});
diff --git a/backend/database/migrations/2024_10_14_232118_add_start_collapsed_to_tickets.php b/backend/database/migrations/2024_10_14_232118_add_start_collapsed_to_tickets.php
index 480a9fe31b..ae22e83f4d 100644
--- a/backend/database/migrations/2024_10_14_232118_add_start_collapsed_to_tickets.php
+++ b/backend/database/migrations/2024_10_14_232118_add_start_collapsed_to_tickets.php
@@ -4,12 +4,13 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
$table = Schema::hasTable('tickets') ? 'tickets' : 'products';
- if (!Schema::hasColumn($table, 'start_collapsed')) {
+ if (! Schema::hasColumn($table, 'start_collapsed')) {
Schema::table($table, static function (Blueprint $table) {
$table->boolean('start_collapsed')->default(false);
});
diff --git a/backend/database/migrations/2024_11_22_235559_update_capacity_assignment_applies_to.php b/backend/database/migrations/2024_11_22_235559_update_capacity_assignment_applies_to.php
index 2e1293ea41..6713a098fc 100644
--- a/backend/database/migrations/2024_11_22_235559_update_capacity_assignment_applies_to.php
+++ b/backend/database/migrations/2024_11_22_235559_update_capacity_assignment_applies_to.php
@@ -3,7 +3,8 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
DB::update(
diff --git a/backend/database/migrations/2024_12_09_234323_add_notes_to_attendees_table.php b/backend/database/migrations/2024_12_09_234323_add_notes_to_attendees_table.php
index 4b5042f81a..ef0fe21f83 100644
--- a/backend/database/migrations/2024_12_09_234323_add_notes_to_attendees_table.php
+++ b/backend/database/migrations/2024_12_09_234323_add_notes_to_attendees_table.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::table('attendees', static function (Blueprint $table) {
@@ -18,7 +19,7 @@ public function up(): void
public function down(): void
{
Schema::table('attendees', static function (Blueprint $table) {
- if (!Schema::hasColumn('attendees', 'notes')) {
+ if (! Schema::hasColumn('attendees', 'notes')) {
return;
}
$table->dropColumn('notes');
diff --git a/backend/database/migrations/2024_12_14_000001_create_account_attributions_table.php b/backend/database/migrations/2024_12_14_000001_create_account_attributions_table.php
index 2ee2b5f0cc..d824db6607 100644
--- a/backend/database/migrations/2024_12_14_000001_create_account_attributions_table.php
+++ b/backend/database/migrations/2024_12_14_000001_create_account_attributions_table.php
@@ -6,7 +6,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::create('account_attributions', function (Blueprint $table) {
diff --git a/backend/database/migrations/2025_01_01_232333_add_event_invoicing_settings.php b/backend/database/migrations/2025_01_01_232333_add_event_invoicing_settings.php
index da27fea0f0..a4fa36ad2f 100644
--- a/backend/database/migrations/2025_01_01_232333_add_event_invoicing_settings.php
+++ b/backend/database/migrations/2025_01_01_232333_add_event_invoicing_settings.php
@@ -35,7 +35,7 @@ public function down(): void
'organization_address',
'invoice_tax_details',
'payment_providers',
- 'offline_payment_instructions'
+ 'offline_payment_instructions',
]);
});
}
diff --git a/backend/database/migrations/2025_01_03_010511_create_invoices_table.php b/backend/database/migrations/2025_01_03_010511_create_invoices_table.php
index d8024b6ab3..6fbafff985 100644
--- a/backend/database/migrations/2025_01_03_010511_create_invoices_table.php
+++ b/backend/database/migrations/2025_01_03_010511_create_invoices_table.php
@@ -5,7 +5,8 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::create('invoices', static function (Blueprint $table) {
diff --git a/backend/database/migrations/2025_01_03_013621_add_payment_provider_to_orders_table.php b/backend/database/migrations/2025_01_03_013621_add_payment_provider_to_orders_table.php
index f47d681ffa..7e43e40e38 100644
--- a/backend/database/migrations/2025_01_03_013621_add_payment_provider_to_orders_table.php
+++ b/backend/database/migrations/2025_01_03_013621_add_payment_provider_to_orders_table.php
@@ -6,7 +6,8 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::table('orders', static function (Blueprint $table) {
diff --git a/backend/database/migrations/2025_01_10_144325_add_index_to_orders_status.php b/backend/database/migrations/2025_01_10_144325_add_index_to_orders_status.php
index e2724caf76..477d3f0e46 100644
--- a/backend/database/migrations/2025_01_10_144325_add_index_to_orders_status.php
+++ b/backend/database/migrations/2025_01_10_144325_add_index_to_orders_status.php
@@ -3,7 +3,8 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
DB::commit();
diff --git a/backend/database/migrations/2025_01_16_034331_add_allow_orders_awaiting_offline_payment_to_check_in_to_event_settings.php b/backend/database/migrations/2025_01_16_034331_add_allow_orders_awaiting_offline_payment_to_check_in_to_event_settings.php
index a88c4c5c28..c1abf2ea1b 100644
--- a/backend/database/migrations/2025_01_16_034331_add_allow_orders_awaiting_offline_payment_to_check_in_to_event_settings.php
+++ b/backend/database/migrations/2025_01_16_034331_add_allow_orders_awaiting_offline_payment_to_check_in_to_event_settings.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::table('event_settings', static function (Blueprint $table) {
diff --git a/backend/database/migrations/2025_01_19_181257_add_payment_terms_and_notes_to_invoices_table.php b/backend/database/migrations/2025_01_19_181257_add_payment_terms_and_notes_to_invoices_table.php
index fc74c532fa..e6f7730226 100644
--- a/backend/database/migrations/2025_01_19_181257_add_payment_terms_and_notes_to_invoices_table.php
+++ b/backend/database/migrations/2025_01_19_181257_add_payment_terms_and_notes_to_invoices_table.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::table('event_settings', static function (Blueprint $table) {
diff --git a/backend/database/migrations/2025_01_20_035401_add_default_payment_provider.php b/backend/database/migrations/2025_01_20_035401_add_default_payment_provider.php
index 4a58b8f898..fc1283ab5e 100644
--- a/backend/database/migrations/2025_01_20_035401_add_default_payment_provider.php
+++ b/backend/database/migrations/2025_01_20_035401_add_default_payment_provider.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
DB::table('event_settings')
diff --git a/backend/database/migrations/2025_01_20_045159_add_configuration_to_accounts.php b/backend/database/migrations/2025_01_20_045159_add_configuration_to_accounts.php
index 3d41ccc670..0cde0c961d 100644
--- a/backend/database/migrations/2025_01_20_045159_add_configuration_to_accounts.php
+++ b/backend/database/migrations/2025_01_20_045159_add_configuration_to_accounts.php
@@ -5,7 +5,8 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::table('accounts', static function (Blueprint $table) {
@@ -16,7 +17,7 @@ public function up(): void
'application_fee' => [
'percentage' => config('app.saas_stripe_application_fee_percent'),
'fixed' => config('app.saas_stripe_application_fee_fixed') ?? 0,
- ]
+ ],
]]);
}
diff --git a/backend/database/migrations/2025_02_02_093202_create_order_refunds_table.php b/backend/database/migrations/2025_02_02_093202_create_order_refunds_table.php
index 40e956c135..849eab260f 100644
--- a/backend/database/migrations/2025_02_02_093202_create_order_refunds_table.php
+++ b/backend/database/migrations/2025_02_02_093202_create_order_refunds_table.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::create('order_refunds', static function (Blueprint $table) {
diff --git a/backend/database/migrations/2025_02_03_112136_add_stripe_connect_account_type_to_accounts.php b/backend/database/migrations/2025_02_03_112136_add_stripe_connect_account_type_to_accounts.php
index 551c9b056a..4b820e77ae 100644
--- a/backend/database/migrations/2025_02_03_112136_add_stripe_connect_account_type_to_accounts.php
+++ b/backend/database/migrations/2025_02_03_112136_add_stripe_connect_account_type_to_accounts.php
@@ -6,7 +6,8 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::table('accounts', function (Blueprint $table) {
diff --git a/backend/database/migrations/2025_02_03_121516_add_stripe_account_id_to_stripe_customers_table.php b/backend/database/migrations/2025_02_03_121516_add_stripe_account_id_to_stripe_customers_table.php
index 6bd5b9eadf..78fbbd4d63 100644
--- a/backend/database/migrations/2025_02_03_121516_add_stripe_account_id_to_stripe_customers_table.php
+++ b/backend/database/migrations/2025_02_03_121516_add_stripe_account_id_to_stripe_customers_table.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
/**
* Run the migrations.
*/
diff --git a/backend/database/migrations/2025_02_09_074739_create_webhooks_table.php b/backend/database/migrations/2025_02_09_074739_create_webhooks_table.php
index 236d69e392..8b1965986b 100644
--- a/backend/database/migrations/2025_02_09_074739_create_webhooks_table.php
+++ b/backend/database/migrations/2025_02_09_074739_create_webhooks_table.php
@@ -5,7 +5,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::create('webhooks', static function (Blueprint $table) {
diff --git a/backend/database/migrations/2025_02_16_162629_add_application_fee_to_stripe_payments.php b/backend/database/migrations/2025_02_16_162629_add_application_fee_to_stripe_payments.php
index 84c888b9aa..b058f943a8 100644
--- a/backend/database/migrations/2025_02_16_162629_add_application_fee_to_stripe_payments.php
+++ b/backend/database/migrations/2025_02_16_162629_add_application_fee_to_stripe_payments.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::table('stripe_payments', static function (Blueprint $table) {
diff --git a/backend/database/migrations/2025_02_16_163546_create_account_configuration.php b/backend/database/migrations/2025_02_16_163546_create_account_configuration.php
index e39f336a73..6c641db097 100644
--- a/backend/database/migrations/2025_02_16_163546_create_account_configuration.php
+++ b/backend/database/migrations/2025_02_16_163546_create_account_configuration.php
@@ -5,7 +5,8 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::create('account_configuration', function (Blueprint $table) {
diff --git a/backend/database/migrations/2025_02_17_095041_create_order_application_fees_table.php b/backend/database/migrations/2025_02_17_095041_create_order_application_fees_table.php
index 15b99cea81..c482dc664b 100644
--- a/backend/database/migrations/2025_02_17_095041_create_order_application_fees_table.php
+++ b/backend/database/migrations/2025_02_17_095041_create_order_application_fees_table.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::create('order_application_fees', static function (Blueprint $table) {
@@ -30,4 +31,3 @@ public function down(): void
Schema::dropIfExists('order_application_fees');
}
};
-
diff --git a/backend/database/migrations/2025_02_23_181633_add_is_manually_verified_to_accounts.php b/backend/database/migrations/2025_02_23_181633_add_is_manually_verified_to_accounts.php
index 128450aa16..b83a577471 100644
--- a/backend/database/migrations/2025_02_23_181633_add_is_manually_verified_to_accounts.php
+++ b/backend/database/migrations/2025_02_23_181633_add_is_manually_verified_to_accounts.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::table('accounts', static function (Blueprint $table) {
diff --git a/backend/database/migrations/2025_02_28_112829_update_question_and_answers_view.php b/backend/database/migrations/2025_02_28_112829_update_question_and_answers_view.php
index 42caa13767..3c1d44a586 100644
--- a/backend/database/migrations/2025_02_28_112829_update_question_and_answers_view.php
+++ b/backend/database/migrations/2025_02_28_112829_update_question_and_answers_view.php
@@ -3,12 +3,13 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
DB::statement('DROP VIEW IF EXISTS question_and_answer_views');
- DB::statement("
+ DB::statement('
CREATE VIEW question_and_answer_views AS
SELECT
qa.id AS question_answer_id,
@@ -32,14 +33,14 @@ public function up(): void
LEFT JOIN attendees a ON a.id = qa.attendee_id
LEFT JOIN products p ON p.id = qa.product_id
JOIN questions q ON q.id = qa.question_id;
- ");
+ ');
}
public function down(): void
{
DB::statement('DROP VIEW IF EXISTS question_and_answer_views');
- DB::statement("
+ DB::statement('
CREATE VIEW question_and_answer_views AS
SELECT p.id AS product_id,
p.title AS product_title,
@@ -57,6 +58,6 @@ public function down(): void
LEFT JOIN attendees a ON a.id = qa.attendee_id
LEFT JOIN products p ON p.id = qa.product_id
JOIN questions q ON q.id = qa.question_id;
- ");
+ ');
}
};
diff --git a/backend/database/migrations/2025_03_07_160427_add_missing_indexes.php b/backend/database/migrations/2025_03_07_160427_add_missing_indexes.php
index 325cbb21d9..0243ee4ea7 100644
--- a/backend/database/migrations/2025_03_07_160427_add_missing_indexes.php
+++ b/backend/database/migrations/2025_03_07_160427_add_missing_indexes.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::table('product_check_in_lists', static function (Blueprint $table) {
diff --git a/backend/database/migrations/2025_03_09_085509_update_question_and_answer_view.php b/backend/database/migrations/2025_03_09_085509_update_question_and_answer_view.php
index a0fe86c11f..88c2dd2c6c 100644
--- a/backend/database/migrations/2025_03_09_085509_update_question_and_answer_view.php
+++ b/backend/database/migrations/2025_03_09_085509_update_question_and_answer_view.php
@@ -3,12 +3,13 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
DB::statement('DROP VIEW IF EXISTS question_and_answer_views');
- DB::statement("
+ DB::statement('
CREATE VIEW question_and_answer_views AS
SELECT qa.id AS question_answer_id,
p.id AS product_id,
@@ -37,14 +38,14 @@ public function up(): void
LEFT JOIN products p ON p.id = qa.product_id
LEFT JOIN orders o ON qa.order_id = o.id
JOIN questions q ON q.id = qa.question_id
- ");
+ ');
}
public function down(): void
{
DB::statement('DROP VIEW IF EXISTS question_and_answer_views');
- DB::statement("
+ DB::statement('
CREATE VIEW question_and_answer_views AS
SELECT
qa.id AS question_answer_id,
@@ -68,6 +69,6 @@ public function down(): void
LEFT JOIN attendees a ON a.id = qa.attendee_id
LEFT JOIN products p ON p.id = qa.product_id
JOIN questions q ON q.id = qa.question_id;
- ");
+ ');
}
};
diff --git a/backend/database/migrations/2025_03_16_143442_add_order_id_to_attendee_check_ins.php b/backend/database/migrations/2025_03_16_143442_add_order_id_to_attendee_check_ins.php
index b66c429834..e584566af4 100644
--- a/backend/database/migrations/2025_03_16_143442_add_order_id_to_attendee_check_ins.php
+++ b/backend/database/migrations/2025_03_16_143442_add_order_id_to_attendee_check_ins.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::table('attendee_check_ins', function (Blueprint $table) {
diff --git a/backend/database/migrations/2025_03_24_052900_add_outgoing_messages_table.php b/backend/database/migrations/2025_03_24_052900_add_outgoing_messages_table.php
index 0a3afe5685..570dd1e500 100644
--- a/backend/database/migrations/2025_03_24_052900_add_outgoing_messages_table.php
+++ b/backend/database/migrations/2025_03_24_052900_add_outgoing_messages_table.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
/**
* Run the migrations.
*/
diff --git a/backend/database/migrations/2025_05_22_235244_create_organizer_settings_table.php b/backend/database/migrations/2025_05_22_235244_create_organizer_settings_table.php
index f1e31f3b8e..3c85330e97 100644
--- a/backend/database/migrations/2025_05_22_235244_create_organizer_settings_table.php
+++ b/backend/database/migrations/2025_05_22_235244_create_organizer_settings_table.php
@@ -8,7 +8,8 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::create('organizer_settings', static function (Blueprint $table) {
diff --git a/backend/database/migrations/2025_06_05_132644_add_status_to_organizers.php b/backend/database/migrations/2025_06_05_132644_add_status_to_organizers.php
index ed9ac99349..52f470273f 100644
--- a/backend/database/migrations/2025_06_05_132644_add_status_to_organizers.php
+++ b/backend/database/migrations/2025_06_05_132644_add_status_to_organizers.php
@@ -5,7 +5,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::table('organizers', static function (Blueprint $table) {
diff --git a/backend/database/migrations/2025_06_18_033649_recreate_affiliates_table.php b/backend/database/migrations/2025_06_18_033649_recreate_affiliates_table.php
index 481feb0a90..9c2915bc50 100644
--- a/backend/database/migrations/2025_06_18_033649_recreate_affiliates_table.php
+++ b/backend/database/migrations/2025_06_18_033649_recreate_affiliates_table.php
@@ -5,7 +5,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
/**
* Run the migrations.
*/
diff --git a/backend/database/migrations/2025_06_21_060905_add_category_to_events_table.php b/backend/database/migrations/2025_06_21_060905_add_category_to_events_table.php
index f97573d8d8..ec7bb6ee04 100644
--- a/backend/database/migrations/2025_06_21_060905_add_category_to_events_table.php
+++ b/backend/database/migrations/2025_06_21_060905_add_category_to_events_table.php
@@ -5,7 +5,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
/**
* Run the migrations.
*/
diff --git a/backend/database/migrations/2025_06_24_055432_change_proudct_category_description_type.php b/backend/database/migrations/2025_06_24_055432_change_proudct_category_description_type.php
index 23a348a0f2..01d5c195b5 100644
--- a/backend/database/migrations/2025_06_24_055432_change_proudct_category_description_type.php
+++ b/backend/database/migrations/2025_06_24_055432_change_proudct_category_description_type.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
/**
* Run the migrations.
*/
diff --git a/backend/database/migrations/2025_08_27_193114_add_orders_cancelled_to_event_statistics_tables.php b/backend/database/migrations/2025_08_27_193114_add_orders_cancelled_to_event_statistics_tables.php
index 0530d2613f..1de515012b 100644
--- a/backend/database/migrations/2025_08_27_193114_add_orders_cancelled_to_event_statistics_tables.php
+++ b/backend/database/migrations/2025_08_27_193114_add_orders_cancelled_to_event_statistics_tables.php
@@ -11,13 +11,13 @@
*/
public function up(): void
{
- if (!Schema::hasColumn('event_statistics', 'orders_cancelled')) {
+ if (! Schema::hasColumn('event_statistics', 'orders_cancelled')) {
Schema::table('event_statistics', function (Blueprint $table) {
$table->unsignedInteger('orders_cancelled')->default(0);
});
}
- if (!Schema::hasColumn('event_daily_statistics', 'orders_cancelled')) {
+ if (! Schema::hasColumn('event_daily_statistics', 'orders_cancelled')) {
Schema::table('event_daily_statistics', function (Blueprint $table) {
$table->unsignedInteger('orders_cancelled')->default(0);
});
diff --git a/backend/database/migrations/2025_11_07_134601_add_country_to_accounts.php b/backend/database/migrations/2025_11_07_134601_add_country_to_accounts.php
index dbae11eff6..5bfbffad18 100644
--- a/backend/database/migrations/2025_11_07_134601_add_country_to_accounts.php
+++ b/backend/database/migrations/2025_11_07_134601_add_country_to_accounts.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
/**
* Run the migrations.
*/
diff --git a/backend/database/migrations/2025_11_07_153308_backfill_account_vat_settings.php b/backend/database/migrations/2025_11_07_153308_backfill_account_vat_settings.php
index 359d65b6a5..075509248c 100644
--- a/backend/database/migrations/2025_11_07_153308_backfill_account_vat_settings.php
+++ b/backend/database/migrations/2025_11_07_153308_backfill_account_vat_settings.php
@@ -7,15 +7,13 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Migrations\Migration;
-return new class extends Migration {
- /**
- * Run the migrations.
- */
+return new class extends Migration
+{
public function up(): void
{
Model::preventLazyLoading(false);
- if (!config('app.tax.eu_vat_handling_enabled')) {
+ if (! config('app.tax.eu_vat_handling_enabled')) {
return;
}
@@ -32,7 +30,7 @@ public function up(): void
}
if (CountryCode::isEuCountry(CountryCode::from(strtoupper($stripeCountry)))) {
- $vatSettings = new AccountVatSetting();
+ $vatSettings = new AccountVatSetting;
$vatSettings->account()->associate($accountStripePlatform->account);
$vatSettings->vat_country_code = strtoupper($stripeCountry);
$vatSettings->vat_validated = false;
@@ -41,11 +39,7 @@ public function up(): void
}
}
- /**
- * Reverse the migrations.
- */
public function down(): void
{
- //no-op
}
};
diff --git a/backend/database/migrations/2025_11_11_200000_create_stripe_payouts_table.php b/backend/database/migrations/2025_11_11_200000_create_stripe_payouts_table.php
index 705dcee675..db952465a8 100644
--- a/backend/database/migrations/2025_11_11_200000_create_stripe_payouts_table.php
+++ b/backend/database/migrations/2025_11_11_200000_create_stripe_payouts_table.php
@@ -29,4 +29,3 @@ public function down(): void
Schema::dropIfExists('stripe_payouts');
}
};
-
diff --git a/backend/database/migrations/2025_11_25_120000_add_attendee_details_collection_method.php b/backend/database/migrations/2025_11_25_120000_add_attendee_details_collection_method.php
index bc74168b66..6f93201f44 100644
--- a/backend/database/migrations/2025_11_25_120000_add_attendee_details_collection_method.php
+++ b/backend/database/migrations/2025_11_25_120000_add_attendee_details_collection_method.php
@@ -5,7 +5,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::table('event_settings', static function (Blueprint $table) {
diff --git a/backend/database/migrations/2025_11_28_100001_migrate_event_color_settings_to_json.php b/backend/database/migrations/2025_11_28_100001_migrate_event_color_settings_to_json.php
index ee6d5385a4..d2502e0b45 100644
--- a/backend/database/migrations/2025_11_28_100001_migrate_event_color_settings_to_json.php
+++ b/backend/database/migrations/2025_11_28_100001_migrate_event_color_settings_to_json.php
@@ -31,9 +31,9 @@ private function detectMode(string $backgroundColor): string
$hex = ltrim($backgroundColor, '#');
if (strlen($hex) === 3) {
- $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
+ $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2];
} elseif (strlen($hex) === 4) {
- $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
+ $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2];
} elseif (strlen($hex) === 8) {
$hex = substr($hex, 0, 6);
}
diff --git a/backend/database/migrations/2025_11_28_100002_migrate_organizer_color_settings_to_new_format.php b/backend/database/migrations/2025_11_28_100002_migrate_organizer_color_settings_to_new_format.php
index 522a33b2f6..c986f89707 100644
--- a/backend/database/migrations/2025_11_28_100002_migrate_organizer_color_settings_to_new_format.php
+++ b/backend/database/migrations/2025_11_28_100002_migrate_organizer_color_settings_to_new_format.php
@@ -51,9 +51,9 @@ private function detectMode(string $backgroundColor): string
// Handle short hex formats and colors with alpha
if (strlen($hex) === 3) {
- $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
+ $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2];
} elseif (strlen($hex) === 4) {
- $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
+ $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2];
} elseif (strlen($hex) === 8) {
$hex = substr($hex, 0, 6);
}
diff --git a/backend/database/migrations/2025_11_28_200000_add_highlight_fields_to_products.php b/backend/database/migrations/2025_11_28_200000_add_highlight_fields_to_products.php
index 2339234bab..2ead2bd096 100644
--- a/backend/database/migrations/2025_11_28_200000_add_highlight_fields_to_products.php
+++ b/backend/database/migrations/2025_11_28_200000_add_highlight_fields_to_products.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
diff --git a/backend/database/migrations/2025_12_04_000000_add_vat_validation_status_column.php b/backend/database/migrations/2025_12_04_000000_add_vat_validation_status_column.php
index 6b8f58775c..c61a3d4e27 100644
--- a/backend/database/migrations/2025_12_04_000000_add_vat_validation_status_column.php
+++ b/backend/database/migrations/2025_12_04_000000_add_vat_validation_status_column.php
@@ -2,8 +2,8 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
-use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
diff --git a/backend/database/migrations/2025_12_27_192118_backfill_stripe_payments_currency_from_orders.php b/backend/database/migrations/2025_12_27_192118_backfill_stripe_payments_currency_from_orders.php
index 5babc02f1e..b1a28bec78 100644
--- a/backend/database/migrations/2025_12_27_192118_backfill_stripe_payments_currency_from_orders.php
+++ b/backend/database/migrations/2025_12_27_192118_backfill_stripe_payments_currency_from_orders.php
@@ -7,7 +7,7 @@
{
public function up(): void
{
- DB::statement(<<nullOnDelete();
});
- if (!config('app.is_hi_events')) {
+ if (! config('app.is_hi_events')) {
// Self-hosted: set all accounts to Premium tier
DB::table('accounts')
->whereNull('account_messaging_tier_id')
diff --git a/backend/database/migrations/2026_01_21_000000_add_bypass_application_fees_to_account_configuration.php b/backend/database/migrations/2026_01_21_000000_add_bypass_application_fees_to_account_configuration.php
index c02b07a7b6..56217a2234 100644
--- a/backend/database/migrations/2026_01_21_000000_add_bypass_application_fees_to_account_configuration.php
+++ b/backend/database/migrations/2026_01_21_000000_add_bypass_application_fees_to_account_configuration.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::table('account_configuration', function (Blueprint $table) {
diff --git a/backend/database/migrations/2026_01_30_000000_add_currency_to_application_fees.php b/backend/database/migrations/2026_01_30_000000_add_currency_to_application_fees.php
index 6e9482ef49..c9698d888b 100644
--- a/backend/database/migrations/2026_01_30_000000_add_currency_to_application_fees.php
+++ b/backend/database/migrations/2026_01_30_000000_add_currency_to_application_fees.php
@@ -12,7 +12,7 @@ public function up(): void
->get()
->each(function ($row) {
$fees = json_decode($row->application_fees, true);
- if ($fees && !isset($fees['currency'])) {
+ if ($fees && ! isset($fees['currency'])) {
$fees['currency'] = 'USD';
DB::table('account_configuration')
->where('id', $row->id)
diff --git a/backend/database/migrations/2026_02_15_000001_create_waitlist_entries_table.php b/backend/database/migrations/2026_02_15_000001_create_waitlist_entries_table.php
index 50bc459cb6..8acd85c177 100644
--- a/backend/database/migrations/2026_02_15_000001_create_waitlist_entries_table.php
+++ b/backend/database/migrations/2026_02_15_000001_create_waitlist_entries_table.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::create('waitlist_entries', function (Blueprint $table) {
diff --git a/backend/database/migrations/2026_02_15_000002_add_waitlist_settings_to_event_settings_and_products.php b/backend/database/migrations/2026_02_15_000002_add_waitlist_settings_to_event_settings_and_products.php
index cd41776f8f..3a60646839 100644
--- a/backend/database/migrations/2026_02_15_000002_add_waitlist_settings_to_event_settings_and_products.php
+++ b/backend/database/migrations/2026_02_15_000002_add_waitlist_settings_to_event_settings_and_products.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::table('event_settings', function (Blueprint $table) {
diff --git a/backend/database/migrations/2026_02_18_000001_change_waitlist_product_id_to_product_price_id.php b/backend/database/migrations/2026_02_18_000001_change_waitlist_product_id_to_product_price_id.php
index 36b7f870c7..9ecfb7b04e 100644
--- a/backend/database/migrations/2026_02_18_000001_change_waitlist_product_id_to_product_price_id.php
+++ b/backend/database/migrations/2026_02_18_000001_change_waitlist_product_id_to_product_price_id.php
@@ -5,7 +5,8 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
DB::statement('DROP INDEX IF EXISTS idx_unique_email_product_status');
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..4d4b6d9d4b
--- /dev/null
+++ b/backend/database/migrations/2026_02_22_000001_add_type_and_recurrence_rule_to_events.php
@@ -0,0 +1,26 @@
+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..ed0fb896ba
--- /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..5231371cce
--- /dev/null
+++ b/backend/database/migrations/2026_02_22_000003_create_product_price_occurrence_overrides_table.php
@@ -0,0 +1,30 @@
+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..053ca15f57
--- /dev/null
+++ b/backend/database/migrations/2026_02_22_000004_create_product_occurrence_visibility_table.php
@@ -0,0 +1,29 @@
+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..4eb1aa66ba
--- /dev/null
+++ b/backend/database/migrations/2026_02_22_000005_add_occurrence_id_to_order_items_attendees_checkin_lists.php
@@ -0,0 +1,48 @@
+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..d20f5a77ce
--- /dev/null
+++ b/backend/database/migrations/2026_02_22_000006_backfill_occurrences_and_drop_event_dates.php
@@ -0,0 +1,77 @@
+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' => IdHelper::shortId(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(),
+ ]);
+ }
+ });
+ }
+
+ 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
+ ');
+
+ 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
+ ');
+
+ Schema::table('attendees', function (Blueprint $table) {
+ $table->foreignId('event_occurrence_id')->nullable(false)->change();
+ });
+ });
+ }
+
+ public function down(): void
+ {
+ 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_02_22_115000_add_organizer_id_to_webhooks_table.php b/backend/database/migrations/2026_02_22_115000_add_organizer_id_to_webhooks_table.php
index fba97e757c..318f2c4e48 100644
--- a/backend/database/migrations/2026_02_22_115000_add_organizer_id_to_webhooks_table.php
+++ b/backend/database/migrations/2026_02_22_115000_add_organizer_id_to_webhooks_table.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::table('webhooks', static function (Blueprint $table) {
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..3354590703
--- /dev/null
+++ b/backend/database/migrations/2026_03_22_000001_make_check_in_lists_occurrence_id_nullable.php
@@ -0,0 +1,35 @@
+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..75b078b293
--- /dev/null
+++ b/backend/database/migrations/2026_03_24_000001_add_quantity_to_price_overrides_and_drop_soft_deletes.php
@@ -0,0 +1,35 @@
+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..7af7eeb2f1
--- /dev/null
+++ b/backend/database/migrations/2026_03_26_000001_create_event_occurrence_statistics_table.php
@@ -0,0 +1,85 @@
+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');
+ });
+
+ 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,
+ SUM(es.products_sold),
+ SUM(es.attendees_registered),
+ SUM(es.sales_total_gross),
+ SUM(es.sales_total_before_additions),
+ SUM(es.total_tax),
+ SUM(es.total_fee),
+ SUM(es.orders_created),
+ SUM(es.orders_cancelled),
+ SUM(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
+ GROUP BY es.event_id, eo.id
+ 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..ac0181124e
--- /dev/null
+++ b/backend/database/migrations/2026_03_26_000002_backfill_event_occurrence_statistics.php
@@ -0,0 +1,61 @@
+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']);
+ });
+
+ 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,
+ SUM(eds.products_sold), SUM(eds.attendees_registered),
+ SUM(eds.sales_total_gross), SUM(eds.sales_total_before_additions),
+ SUM(eds.total_tax), SUM(eds.total_fee),
+ SUM(eds.orders_created), SUM(eds.orders_cancelled), SUM(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
+ GROUP BY eds.event_id, eo.id, eds.date
+ 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..0bd277bbf8
--- /dev/null
+++ b/backend/database/migrations/2026_03_31_000001_add_event_occurrence_id_to_attendee_check_ins.php
@@ -0,0 +1,28 @@
+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..4c80e000b2
--- /dev/null
+++ b/backend/database/migrations/2026_04_04_000001_fix_occurrence_fk_on_delete_behavior.php
@@ -0,0 +1,50 @@
+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_14_000000_add_tracking_pixels_to_organizer_settings.php b/backend/database/migrations/2026_04_14_000000_add_tracking_pixels_to_organizer_settings.php
index e036484f26..ab409391a8 100644
--- a/backend/database/migrations/2026_04_14_000000_add_tracking_pixels_to_organizer_settings.php
+++ b/backend/database/migrations/2026_04_14_000000_add_tracking_pixels_to_organizer_settings.php
@@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::table('organizer_settings', function (Blueprint $table) {
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..0b3bd83d11
--- /dev/null
+++ b/backend/database/migrations/2026_04_20_120000_add_public_visibility_to_check_in_lists.php
@@ -0,0 +1,26 @@
+boolean('public_show_attendee_notes')->default(false);
+ $table->boolean('public_show_question_answers')->default(false);
+ $table->boolean('public_show_order_details')->default(false);
+ });
+ }
+
+ 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..5c12539527
--- /dev/null
+++ b/backend/database/migrations/2026_04_23_000001_add_is_system_default_to_check_in_lists.php
@@ -0,0 +1,63 @@
+boolean('is_system_default')->default(false);
+ });
+
+ 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
+ ');
+
+ 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' => false,
+ 'public_show_question_answers' => false,
+ 'public_show_order_details' => false,
+ '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..51f713e5f8
--- /dev/null
+++ b/backend/database/migrations/2026_04_23_000002_expand_event_category_check_constraint.php
@@ -0,0 +1,34 @@
+ "'".$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("UPDATE events SET category = 'OTHER' WHERE category IS NOT NULL AND category NOT IN ($quoted)");
+
+ 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..80c05b6275
--- /dev/null
+++ b/backend/database/migrations/2026_04_28_000001_add_occurrence_id_to_waitlist_entries.php
@@ -0,0 +1,57 @@
+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("
+ DELETE FROM waitlist_entries
+ WHERE status IN ('WAITING', 'OFFERED')
+ AND id NOT IN (
+ SELECT MIN(id)
+ FROM waitlist_entries
+ WHERE status IN ('WAITING', 'OFFERED')
+ GROUP BY email, product_price_id, 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..da7e810efc
--- /dev/null
+++ b/backend/database/migrations/2026_05_11_000000_create_organizer_stripe_platforms_table.php
@@ -0,0 +1,61 @@
+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');
+ });
+
+ 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..d97bcdd792
--- /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..2f1ff9d3ab
--- /dev/null
+++ b/backend/database/migrations/2026_05_12_000001_create_organizer_configurations_table.php
@@ -0,0 +1,84 @@
+id();
+ $table->string('name');
+ $table->boolean('is_system_default')->default(false);
+ $table->json('application_fees')->nullable();
+ $table->boolean('bypass_application_fees')->default(false);
+ $table->unsignedBigInteger('legacy_account_configuration_id')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+
+ $table->index('legacy_account_configuration_id');
+ });
+
+ 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
+ ');
+
+ $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');
+ });
+
+ 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..c5c3590d6d
--- /dev/null
+++ b/backend/database/migrations/2026_05_18_000001_create_locations_table.php
@@ -0,0 +1,41 @@
+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..a7737da079
--- /dev/null
+++ b/backend/database/migrations/2026_05_19_000001_add_location_id_to_organizers_and_backfill.php
@@ -0,0 +1,86 @@
+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]);
+ }
+ });
+
+ }
+
+ 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..f2e09e1a75
--- /dev/null
+++ b/backend/database/migrations/2026_05_22_000002_link_events_and_occurrences_to_event_locations.php
@@ -0,0 +1,222 @@
+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 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)) {
+ $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/database/migrations/2026_07_03_000001_add_occurrence_capacity_visibility.php b/backend/database/migrations/2026_07_03_000001_add_occurrence_capacity_visibility.php
new file mode 100644
index 0000000000..8df4f9ce95
--- /dev/null
+++ b/backend/database/migrations/2026_07_03_000001_add_occurrence_capacity_visibility.php
@@ -0,0 +1,30 @@
+boolean('show_available_occurrence_capacity')->default(false);
+ });
+
+ Schema::table('event_occurrences', function (Blueprint $table) {
+ $table->boolean('show_available_capacity')->nullable()->default(null);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('event_settings', function (Blueprint $table) {
+ $table->dropColumn('show_available_occurrence_capacity');
+ });
+
+ Schema::table('event_occurrences', function (Blueprint $table) {
+ $table->dropColumn('show_available_capacity');
+ });
+ }
+};
diff --git a/backend/database/migrations/2026_07_10_000001_backfill_occurrence_used_capacity.php b/backend/database/migrations/2026_07_10_000001_backfill_occurrence_used_capacity.php
new file mode 100644
index 0000000000..9d49e07ff1
--- /dev/null
+++ b/backend/database/migrations/2026_07_10_000001_backfill_occurrence_used_capacity.php
@@ -0,0 +1,26 @@
+boolean('hide_sold_out_occurrences')->default(false);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('event_settings', function (Blueprint $table) {
+ $table->dropColumn('hide_sold_out_occurrences');
+ });
+ }
+};
diff --git a/backend/database/migrations/2026_07_19_000001_add_cancelled_attendees_count_to_event_occurrences.php b/backend/database/migrations/2026_07_19_000001_add_cancelled_attendees_count_to_event_occurrences.php
new file mode 100644
index 0000000000..1ca0e7fa24
--- /dev/null
+++ b/backend/database/migrations/2026_07_19_000001_add_cancelled_attendees_count_to_event_occurrences.php
@@ -0,0 +1,22 @@
+integer('cancelled_attendees_count')->nullable();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('event_occurrences', function (Blueprint $table) {
+ $table->dropColumn('cancelled_attendees_count');
+ });
+ }
+};
diff --git a/backend/database/migrations/2026_07_20_000001_backfill_occurrence_cancelled_attendees_count.php b/backend/database/migrations/2026_07_20_000001_backfill_occurrence_cancelled_attendees_count.php
new file mode 100644
index 0000000000..2d3e882a64
--- /dev/null
+++ b/backend/database/migrations/2026_07_20_000001_backfill_occurrence_cancelled_attendees_count.php
@@ -0,0 +1,27 @@
+ 1
+ ) agg
+ WHERE t.id = agg.keep_id
+ ');
+
+ DB::statement('
+ DELETE FROM event_daily_statistics d
+ USING (
+ SELECT event_id, date, MIN(id) AS keep_id
+ FROM event_daily_statistics
+ WHERE deleted_at IS NULL
+ GROUP BY event_id, date
+ HAVING COUNT(*) > 1
+ ) agg
+ WHERE d.deleted_at IS NULL
+ AND d.event_id = agg.event_id
+ AND d.date = agg.date
+ AND d.id <> agg.keep_id
+ ');
+
+ DB::statement('
+ CREATE UNIQUE INDEX IF NOT EXISTS event_daily_statistics_event_id_date_uniq
+ ON event_daily_statistics (event_id, date)
+ WHERE deleted_at IS NULL
+ ');
+ }
+
+ public function down(): void
+ {
+ DB::statement('DROP INDEX IF EXISTS event_daily_statistics_event_id_date_uniq');
+ }
+};
diff --git a/backend/database/migrations/2026_07_26_000000_add_discount_applies_to_to_promo_codes_table.php b/backend/database/migrations/2026_07_26_000000_add_discount_applies_to_to_promo_codes_table.php
new file mode 100644
index 0000000000..1ff105e3a2
--- /dev/null
+++ b/backend/database/migrations/2026_07_26_000000_add_discount_applies_to_to_promo_codes_table.php
@@ -0,0 +1,24 @@
+string('discount_applies_to')
+ ->default(PromoCodeDiscountAppliesToEnum::EACH_PRODUCT->name);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('promo_codes', static function (Blueprint $table) {
+ $table->dropColumn('discount_applies_to');
+ });
+ }
+};
diff --git a/backend/lang/de.json b/backend/lang/de.json
index a15dffc2ea..af48595a11 100644
--- a/backend/lang/de.json
+++ b/backend/lang/de.json
@@ -304,7 +304,7 @@
"Sorry, these products are sold out": "Entschuldigung, diese Produkte sind ausverkauft.",
"The maximum number of products available is :max": "Die maximale Anzahl der verfügbaren Produkte beträgt :max.",
"Product with id :id not found": "Produkt mit ID :id nicht gefunden.",
- "You cannot delete this product because it has orders associated with it. You can hide it instead.": "Sie können dieses Produkt nicht löschen, da damit Bestellungen verknüpft sind. Sie können es stattdessen ausblenden.",
+ "You cannot delete this product because it has orders associated with it or is reserved by a checkout in progress. You can hide it instead.": "Sie können dieses Produkt nicht löschen, da damit Bestellungen verknüpft sind oder es durch einen laufenden Checkout reserviert ist. Sie können es stattdessen ausblenden.",
"Invalid product ids: :ids": "Ungültige Produkt-IDs: :ids.",
"Product is hidden without promo code": "Produkt ist ohne Aktionscode verborgen.",
"Product is sold out": "Produkt ist ausverkauft.",
diff --git a/backend/lang/el.json b/backend/lang/el.json
index f5a7ff5fd5..87a2e0bea0 100644
--- a/backend/lang/el.json
+++ b/backend/lang/el.json
@@ -411,7 +411,7 @@
"Sorry, these products are sold out": "Λυπούμαστε, αυτά τα προϊόντα είναι εξαντλημένα",
"The maximum number of products available is :max": "Ο μέγιστος αριθμός διαθέσιμων προϊόντων είναι :max",
"Product with id :id not found": "Το προϊόν με αναγνωριστικό :id δεν βρέθηκε",
- "You cannot delete this product because it has orders associated with it. You can hide it instead.": "Δεν μπορείτε να διαγράψετε αυτό το προϊόν καθώς υπάρχουν παραγγελίες συνδεδεμένες. Μπορείτε να το αποκρύψετε.",
+ "You cannot delete this product because it has orders associated with it or is reserved by a checkout in progress. You can hide it instead.": "Δεν μπορείτε να διαγράψετε αυτό το προϊόν καθώς υπάρχουν παραγγελίες συνδεδεμένες ή είναι δεσμευμένο από μια αγορά σε εξέλιξη. Μπορείτε να το αποκρύψετε.",
"Invalid product ids: :ids": "Μη έγκυρα αναγνωριστικά προϊόντων: :ids",
"Product is hidden without promo code": "Το προϊόν είναι κρυφό χωρίς κωδικό προώθησης",
"Product is sold out": "Το προϊόν είναι εξαντλημένο",
diff --git a/backend/lang/es.json b/backend/lang/es.json
index daa18cabfd..1a099685cf 100644
--- a/backend/lang/es.json
+++ b/backend/lang/es.json
@@ -304,7 +304,7 @@
"Sorry, these products are sold out": "Lo sentimos, estos productos están agotados.",
"The maximum number of products available is :max": "El número máximo de productos disponibles es :max.",
"Product with id :id not found": "Producto con id :id no encontrado.",
- "You cannot delete this product because it has orders associated with it. You can hide it instead.": "No puedes eliminar este producto porque tiene pedidos asociados. Puedes ocultarlo en su lugar.",
+ "You cannot delete this product because it has orders associated with it or is reserved by a checkout in progress. You can hide it instead.": "No puedes eliminar este producto porque tiene pedidos asociados o está reservado por una compra en curso. Puedes ocultarlo en su lugar.",
"Invalid product ids: :ids": "IDs de productos inválidos: :ids.",
"Product is hidden without promo code": "El producto está oculto sin código promocional.",
"Product is sold out": "El producto está agotado.",
diff --git a/backend/lang/fr.json b/backend/lang/fr.json
index e5f1482357..272fee79d6 100644
--- a/backend/lang/fr.json
+++ b/backend/lang/fr.json
@@ -304,7 +304,7 @@
"Sorry, these products are sold out": "Désolé, ces produits sont épuisés.",
"The maximum number of products available is :max": "Le nombre maximum de produits disponibles est :max.",
"Product with id :id not found": "Produit avec id :id introuvable.",
- "You cannot delete this product because it has orders associated with it. You can hide it instead.": "Vous ne pouvez pas supprimer ce produit car il a des commandes associées. Vous pouvez le masquer à la place.",
+ "You cannot delete this product because it has orders associated with it or is reserved by a checkout in progress. You can hide it instead.": "Vous ne pouvez pas supprimer ce produit car il a des commandes associées ou est réservé par une commande en cours. Vous pouvez le masquer à la place.",
"Invalid product ids: :ids": "Identifiants de produits invalides : :ids.",
"Product is hidden without promo code": "Le produit est masqué sans code promo.",
"Product is sold out": "Le produit est épuisé.",
diff --git a/backend/lang/hu.json b/backend/lang/hu.json
index 197198a615..0dd61eeb40 100644
--- a/backend/lang/hu.json
+++ b/backend/lang/hu.json
@@ -304,7 +304,7 @@
"Sorry, these products are sold out": "Sajnáljuk, ezek a termékek elfogytak.",
"The maximum number of products available is :max": "A termékek maximális száma: :max.",
"Product with id :id not found": "Termék a következő azonosítóval nem található: :id.",
- "You cannot delete this product because it has orders associated with it. You can hide it instead.": "Nem törölheti ezt a terméket, mert megrendelések kapcsolódnak hozzá. Helyette elrejtheti.",
+ "You cannot delete this product because it has orders associated with it or is reserved by a checkout in progress. You can hide it instead.": "Nem törölheti ezt a terméket, mert megrendelések kapcsolódnak hozzá, vagy egy folyamatban lévő vásárlás lefoglalta. Helyette elrejtheti.",
"Invalid product ids: :ids": "Érvénytelen termék azonosítók: :ids.",
"Product is hidden without promo code": "A termék promóciós kód nélkül rejtett.",
"Product is sold out": "A termék elfogyott.",
diff --git a/backend/lang/it.json b/backend/lang/it.json
index dea6eda9c5..b9283f6b07 100644
--- a/backend/lang/it.json
+++ b/backend/lang/it.json
@@ -304,7 +304,7 @@
"Sorry, these products are sold out": "Siamo spiacenti, questi prodotti sono esauriti.",
"The maximum number of products available is :max": "Il numero massimo di prodotti disponibili è: max.",
"Product with id :id not found": "Prodotto con id:id non trovato.",
- "You cannot delete this product because it has orders associated with it. You can hide it instead.": "Non puoi eliminare questo prodotto perché ha ordini associati. In alternativa, puoi nasconderlo.",
+ "You cannot delete this product because it has orders associated with it or is reserved by a checkout in progress. You can hide it instead.": "Non puoi eliminare questo prodotto perché ha ordini associati o è riservato da un acquisto in corso. In alternativa, puoi nasconderlo.",
"Invalid product ids: :ids": "Identificatori di prodotto non validi : :ids.",
"Product is hidden without promo code": "Il prodotto è nascosto senza un codice promozionale.",
"Product is sold out": "Il prodotto è esaurito.",
diff --git a/backend/lang/nl.json b/backend/lang/nl.json
index 13961122a2..c772413339 100644
--- a/backend/lang/nl.json
+++ b/backend/lang/nl.json
@@ -304,7 +304,7 @@
"Sorry, these products are sold out": "Sorry, deze producten zijn uitverkocht",
"The maximum number of products available is :max": "Het maximale aantal beschikbare producten is :max",
"Product with id :id not found": "Product met ID :id niet gevonden",
- "You cannot delete this product because it has orders associated with it. You can hide it instead.": "U kunt dit product niet verwijderen omdat er bestellingen aan gekoppeld zijn. U kunt het in plaats daarvan verbergen.",
+ "You cannot delete this product because it has orders associated with it or is reserved by a checkout in progress. You can hide it instead.": "U kunt dit product niet verwijderen omdat er bestellingen aan gekoppeld zijn of omdat het is gereserveerd door een lopende checkout. U kunt het in plaats daarvan verbergen.",
"Invalid product ids: :ids": "Ongeldige product-ID's: :ids",
"Product is hidden without promo code": "Product is verborgen zonder promocode",
"Product is sold out": "Product is uitverkocht",
diff --git a/backend/lang/pl.json b/backend/lang/pl.json
index bdf9ae60c6..fb2eadf68b 100644
--- a/backend/lang/pl.json
+++ b/backend/lang/pl.json
@@ -304,7 +304,7 @@
"Sorry, these products are sold out": "Przepraszamy, te produkty są wyprzedane",
"The maximum number of products available is :max": "Maksymalna liczba dostępnych produktów to :max",
"Product with id :id not found": "Produkt o ID :id nie znaleziony",
- "You cannot delete this product because it has orders associated with it. You can hide it instead.": "Nie możesz usunąć tego produktu, ponieważ ma powiązane zamówienia. Możesz go zamiast tego ukryć.",
+ "You cannot delete this product because it has orders associated with it or is reserved by a checkout in progress. You can hide it instead.": "Nie możesz usunąć tego produktu, ponieważ ma powiązane zamówienia lub jest zarezerwowany przez trwający proces zakupu. Możesz go zamiast tego ukryć.",
"Invalid product ids: :ids": "Nieprawidłowe ID produktów: :ids",
"Product is hidden without promo code": "Produkt jest ukryty bez kodu promocyjnego",
"Product is sold out": "Produkt jest wyprzedany",
diff --git a/backend/lang/pt-br.json b/backend/lang/pt-br.json
index a8a633ca50..c4062eeeb8 100644
--- a/backend/lang/pt-br.json
+++ b/backend/lang/pt-br.json
@@ -304,7 +304,7 @@
"Sorry, these products are sold out": "Desculpe, esses produtos estão esgotados.",
"The maximum number of products available is :max": "O número máximo de produtos disponíveis é :max.",
"Product with id :id not found": "Produto com id :id não encontrado.",
- "You cannot delete this product because it has orders associated with it. You can hide it instead.": "Você não pode excluir este produto porque ele possui pedidos associados. Você pode ocultá-lo.",
+ "You cannot delete this product because it has orders associated with it or is reserved by a checkout in progress. You can hide it instead.": "Você não pode excluir este produto porque ele possui pedidos associados ou está reservado por uma compra em andamento. Você pode ocultá-lo.",
"Invalid product ids: :ids": "IDs de produto inválidos: :ids.",
"Product is hidden without promo code": "O produto está oculto sem código promocional.",
"Product is sold out": "O produto está esgotado.",
diff --git a/backend/lang/pt.json b/backend/lang/pt.json
index d0a5cf2216..84e1e228df 100644
--- a/backend/lang/pt.json
+++ b/backend/lang/pt.json
@@ -304,7 +304,7 @@
"Sorry, these products are sold out": "Desculpe, esses produtos estão esgotados.",
"The maximum number of products available is :max": "O número máximo de produtos disponíveis é :max.",
"Product with id :id not found": "Produto com id :id não encontrado.",
- "You cannot delete this product because it has orders associated with it. You can hide it instead.": "Você não pode excluir este produto porque ele tem pedidos associados. Você pode ocultá-lo.",
+ "You cannot delete this product because it has orders associated with it or is reserved by a checkout in progress. You can hide it instead.": "Você não pode excluir este produto porque ele tem pedidos associados ou está reservado por uma compra em andamento. Você pode ocultá-lo.",
"Invalid product ids: :ids": "IDs de produto inválidos: :ids.",
"Product is hidden without promo code": "O produto está oculto sem código promocional.",
"Product is sold out": "O produto está esgotado.",
diff --git a/backend/lang/ru.json b/backend/lang/ru.json
index 6d57f5424b..c9a3417757 100644
--- a/backend/lang/ru.json
+++ b/backend/lang/ru.json
@@ -319,7 +319,7 @@
"Sorry, these products are sold out": "",
"The maximum number of products available is :max": "",
"Product with id :id not found": "",
- "You cannot delete this product because it has orders associated with it. You can hide it instead.": "",
+ "You cannot delete this product because it has orders associated with it or is reserved by a checkout in progress. You can hide it instead.": "",
"Invalid product ids: :ids": "",
"Product is hidden without promo code": "",
"Product is sold out": "",
diff --git a/backend/lang/se.json b/backend/lang/se.json
index de2975f3d0..0c64cfd25d 100644
--- a/backend/lang/se.json
+++ b/backend/lang/se.json
@@ -304,7 +304,7 @@
"Sorry, these products are sold out": "Dessa produkterna är tyvärr slutsålda",
"The maximum number of products available is :max": "Det maximala antalet tillgängliga produkter är :max",
"Product with id :id not found": "Produkt med id :id hittades inte",
- "You cannot delete this product because it has orders associated with it. You can hide it instead.": "Du kan inte ta bort denna produkt eftersom det finns ordrar kopplade till den. Du kan istället dölja den.",
+ "You cannot delete this product because it has orders associated with it or is reserved by a checkout in progress. You can hide it instead.": "Du kan inte ta bort denna produkt eftersom det finns ordrar kopplade till den eller eftersom den är reserverad av ett pågående köp. Du kan istället dölja den.",
"Invalid product ids: :ids": "Ogiltiga produkt-ID: :ids",
"Product is hidden without promo code": "Produkten är dold utan kampanjkod.",
"Product is sold out": "Produkten är slutsåld.",
diff --git a/backend/lang/sk.json b/backend/lang/sk.json
index 965c0ef5a9..50a4fac0c7 100644
--- a/backend/lang/sk.json
+++ b/backend/lang/sk.json
@@ -304,7 +304,7 @@
"Sorry, these products are sold out": "Prepáčte, tieto produkty sú vypredané.",
"The maximum number of products available is :max": "Maximálny počet dostupných produktov je :max.",
"Product with id :id not found": "Produkt s ID :id nebol nájdený.",
- "You cannot delete this product because it has orders associated with it. You can hide it instead.": "Tento produkt nemôžete vymazať, pretože má priradené objednávky. Môžete ho skryť.",
+ "You cannot delete this product because it has orders associated with it or is reserved by a checkout in progress. You can hide it instead.": "Tento produkt nemôžete vymazať, pretože má priradené objednávky alebo je rezervovaný prebiehajúcim nákupom. Môžete ho skryť.",
"Invalid product ids: :ids": "Neplatné ID produktov: :ids.",
"Product is hidden without promo code": "Produkt je skrytý bez akčného kódu.",
"Product is sold out": "Produkt je vypredaný.",
diff --git a/backend/lang/tr.json b/backend/lang/tr.json
index 801437f842..b8e49c37c4 100644
--- a/backend/lang/tr.json
+++ b/backend/lang/tr.json
@@ -319,7 +319,7 @@
"Sorry, these products are sold out": "Üzgünüz, bu ürünler tükendi",
"The maximum number of products available is :max": "Mevcut maksimum ürün sayısı :max",
"Product with id :id not found": ":id kimlikli ürün bulunamadı",
- "You cannot delete this product because it has orders associated with it. You can hide it instead.": "Bu ürünü silemezsiniz çünkü onunla ilişkili siparişler var. Bunun yerine gizleyebilirsiniz.",
+ "You cannot delete this product because it has orders associated with it or is reserved by a checkout in progress. You can hide it instead.": "Bu ürünü silemezsiniz çünkü onunla ilişkili siparişler var veya devam eden bir satın alma işlemi tarafından rezerve edilmiş. Bunun yerine gizleyebilirsiniz.",
"Invalid product ids: :ids": "Geçersiz ürün kimlikleri: :ids",
"Product is hidden without promo code": "Ürün promosyon kodu olmadan gizlenmiş",
"Product is sold out": "Ürün tükendi",
diff --git a/backend/lang/tr/passwords.php b/backend/lang/tr/passwords.php
index c9db36d47b..8a94e15051 100644
--- a/backend/lang/tr/passwords.php
+++ b/backend/lang/tr/passwords.php
@@ -17,6 +17,6 @@
'sent' => 'Şifre sıfırlama bağlantınızı e-posta olarak gönderdik.',
'throttled' => 'Lütfen tekrar denemeden önce bekleyin.',
'token' => 'Bu şifre sıfırlama token\'ı geçersiz.',
- 'user' => "Bu e-posta adresine sahip bir kullanıcı bulamıyoruz.",
+ 'user' => 'Bu e-posta adresine sahip bir kullanıcı bulamıyoruz.',
];
diff --git a/backend/lang/vi.json b/backend/lang/vi.json
index f1e6282fa0..613ffa3570 100644
--- a/backend/lang/vi.json
+++ b/backend/lang/vi.json
@@ -241,7 +241,7 @@
"There was an error communicating with the payment provider. Please try again later.": "Có lỗi khi giao tiếp với nhà cung cấp thanh toán. Vui lòng thử lại sau.",
"Stripe Connect account not found for the event organizer": "Không tìm thấy tài khoản Stripe Connect cho nhà tổ chức sự kiện",
"Cannot Refund: Stripe connect account not found and saas_mode_enabled is enabled": "Không thể hoàn tiền: Không tìm thấy tài khoản Stripe connect và chế độ saas_mode_enabled được kích hoạt",
- "You cannot delete this product because it has orders associated with it. You can hide it instead.": "Bạn không thể xóa sản phẩm này vì nó có các đơn hàng",
+ "You cannot delete this product because it has orders associated with it or is reserved by a checkout in progress. You can hide it instead.": "Bạn không thể xóa sản phẩm này vì nó có các đơn hàng liên kết hoặc đang được giữ chỗ bởi một lượt thanh toán đang diễn ra. Bạn có thể ẩn nó thay thế.",
"Invalid product ids: :ids": "ID sản phẩm không hợp lệ: :ids",
"Product is hidden without promo code": "Sản phẩm bị ẩn không có mã khuyến mãi",
"Product is sold out": "Sản phẩm đã bán hết",
diff --git a/backend/lang/zh-cn.json b/backend/lang/zh-cn.json
index 3d68375abf..7f2ca9dc92 100644
--- a/backend/lang/zh-cn.json
+++ b/backend/lang/zh-cn.json
@@ -304,7 +304,7 @@
"Sorry, these products are sold out": "抱歉,这些产品已售罄。",
"The maximum number of products available is :max": "最大可用产品数量为 :max。",
"Product with id :id not found": "未找到 ID 为 :id 的产品。",
- "You cannot delete this product because it has orders associated with it. You can hide it instead.": "您无法删除此产品,因为它已与订单关联。您可以将其隐藏。",
+ "You cannot delete this product because it has orders associated with it or is reserved by a checkout in progress. You can hide it instead.": "您无法删除此产品,因为它已与订单关联,或正被进行中的结账流程预留。您可以将其隐藏。",
"Invalid product ids: :ids": "产品 ID 无效::ids。",
"Product is hidden without promo code": "没有促销代码时产品隐藏。",
"Product is sold out": "产品已售罄。",
diff --git a/backend/lang/zh-hk.json b/backend/lang/zh-hk.json
index 41bff342e0..f3502c24c5 100644
--- a/backend/lang/zh-hk.json
+++ b/backend/lang/zh-hk.json
@@ -304,7 +304,7 @@
"Sorry, these products are sold out": "抱歉,這些產品已售罄。",
"The maximum number of products available is :max": "最大可用產品數量為 :max。",
"Product with id :id not found": "未找到 ID 為 :id 的產品。",
- "You cannot delete this product because it has orders associated with it. You can hide it instead.": "您無法刪除此產品,因為它已與訂單關聯。您可以將其隱藏。",
+ "You cannot delete this product because it has orders associated with it or is reserved by a checkout in progress. You can hide it instead.": "您無法刪除此產品,因為它已與訂單關聯,或正被進行中的結帳流程保留。您可以將其隱藏。",
"Invalid product ids: :ids": "產品 ID 無效::ids。",
"Product is hidden without promo code": "沒有促銷代碼時產品隱藏。",
"Product is sold out": "產品已售罄。",
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/pint.json b/backend/pint.json
new file mode 100644
index 0000000000..640e0403da
--- /dev/null
+++ b/backend/pint.json
@@ -0,0 +1,9 @@
+{
+ "preset": "laravel",
+ "exclude": [
+ "app/DomainObjects/Generated"
+ ],
+ "rules": {
+ "ordered_interfaces": false
+ }
+}
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..50110103cd
--- /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)
+{{ __('If your order is eligible, a refund will be processed automatically and may take a few business days to appear on your statement. If we are unable to process it automatically, our team will be in touch.') }}
+@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 75f0459c9c..660d1a015d 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,46 @@
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\GetEventOccurrencesPublicAction;
+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 +113,21 @@
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\UpdateEventLocationAction;
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 +151,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 +163,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 +205,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 +216,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 +231,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 +293,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 +308,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 +318,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 +359,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', 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 +486,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 +515,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);
@@ -494,6 +556,8 @@ function (Router $router): void {
function (Router $router): void {
// Events
$router->get('/events/{event_id}', GetEventPublicAction::class);
+ $router->get('/events/{event_id}/occurrences', GetEventOccurrencesPublicAction::class)
+ ->middleware('throttle:60,1');
// Organizers
$router->get('/organizers/{organizer_id}', GetPublicOrganizerAction::class);
@@ -536,8 +600,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);
@@ -545,7 +611,8 @@ function (Router $router): void {
$router->get('/color-themes', GetColorThemesAction::class);
// Ticket Lookup
- $router->post('/ticket-lookup', SendTicketLookupEmailAction::class);
+ $router->post('/ticket-lookup', SendTicketLookupEmailAction::class)
+ ->middleware('throttle:10,1');
$router->get('/ticket-lookup/{token}', GetOrdersByLookupTokenAction::class);
// Self-service order and attendee edits
@@ -564,4 +631,4 @@ function (Router $router): void {
}
);
-include_once __DIR__ . '/mail.php';
+include_once __DIR__.'/mail.php';
diff --git a/backend/routes/console.php b/backend/routes/console.php
index 62bf76b980..ef9acd39ee 100644
--- a/backend/routes/console.php
+++ b/backend/routes/console.php
@@ -4,5 +4,5 @@
use Illuminate\Support\Facades\Artisan;
Artisan::command('generate-domain-objects',
- fn() => app()->make(ClassGenerator::class)->run()
+ fn () => app()->make(ClassGenerator::class)->run()
)->describe('Generate domain objects from db');
diff --git a/backend/routes/mail.php b/backend/routes/mail.php
index 0d07ea5f1a..f6bc1a4a43 100644
--- a/backend/routes/mail.php
+++ b/backend/routes/mail.php
@@ -7,22 +7,23 @@
use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\DomainObjects\Status\OrderStatus;
use HiEvents\Helper\IdHelper;
+use HiEvents\Mail\Organizer\OrderSummaryForOrganizer;
use Illuminate\Support\Facades\Route;
Route::get('/mail-test', static function () {
- $orderItem = (new OrderItemDomainObject())
+ $orderItem = (new OrderItemDomainObject)
->setId(1)
->setQuantity(1)
->setPrice(100)
->setItemName('Test Item');
- $orderItem2 = (new OrderItemDomainObject())
+ $orderItem2 = (new OrderItemDomainObject)
->setId(1)
->setQuantity(1)
->setPrice(100)
->setItemName('Test Item');
- $order = (new OrderDomainObject())
+ $order = (new OrderDomainObject)
->setFirstName('Test')
->setLastName('User')
->setEmail('test@test.com')
@@ -33,16 +34,16 @@
->setOrderItems(collect([$orderItem, $orderItem2]))
->setTotalGross(200);
- $organizer = (new OrganizerDomainObject())
+ $organizer = (new OrganizerDomainObject)
->setId(1)
->setName('Test Organizer')
->setEmail('s@d.com');
- $eventSettings = (new EventSettingDomainObject())
+ $eventSettings = (new EventSettingDomainObject)
->setSupportEmail('d@d.com')
->setPostCheckoutMessage('Thank you for your order');
- $event = (new EventDomainObject())
+ $event = (new EventDomainObject)
->setId(1)
->setTitle('Test Event')
->setStartDate(now())
@@ -50,7 +51,7 @@
->setOrganizer($organizer)
->setEventSettings($eventSettings);
- return new \HiEvents\Mail\Organizer\OrderSummaryForOrganizer(
+ return new OrderSummaryForOrganizer(
order: $order,
event: $event,
);
diff --git a/backend/routes/web.php b/backend/routes/web.php
index d259f33ea8..443ffa6c25 100644
--- a/backend/routes/web.php
+++ b/backend/routes/web.php
@@ -16,3 +16,7 @@
Route::get('/', function () {
return view('welcome');
});
+
+Route::get('/up', function () {
+ return response()->json(['status' => 'ok']);
+});
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..d44f15daeb 100644
--- a/backend/tests/CreatesApplication.php
+++ b/backend/tests/CreatesApplication.php
@@ -4,18 +4,65 @@
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
{
- /**
- * Creates the application.
- */
+ private static bool $migrationsApplied = false;
+
public function createApplication(): Application
{
$app = require __DIR__.'/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
+ $this->guardAgainstNonTestDatabase($app);
+
+ if ($this->currentTestNeedsDatabase()) {
+ $this->ensureTestDatabaseIsMigrated($app);
+ }
+
return $app;
}
+
+ 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,
+ ));
+ }
+ }
+
+ private function ensureTestDatabaseIsMigrated(Application $app): void
+ {
+ if (self::$migrationsApplied) {
+ return;
+ }
+
+ $app->make(Kernel::class)->call('migrate:fresh', ['--force' => true]);
+
+ self::$migrationsApplied = true;
+ }
+
+ 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/LoginTest.php b/backend/tests/Feature/Auth/LoginTest.php
index 1b8bdd046d..618fdd8d61 100644
--- a/backend/tests/Feature/Auth/LoginTest.php
+++ b/backend/tests/Feature/Auth/LoginTest.php
@@ -14,10 +14,12 @@ class LoginTest extends TestCase
use RefreshDatabase;
private const LOGIN_ROUTE = '/auth/login';
+
private const LOGOUT_ROUTE = '/auth/logout';
+
private const USERS_ME_ROUTE = '/users/me';
- public function setUp(): void
+ protected function setUp(): void
{
parent::setUp();
@@ -28,7 +30,7 @@ public function setUp(): void
'application_fees' => [
'percentage' => 1.5,
'fixed' => 0,
- ]
+ ],
]);
}
@@ -50,7 +52,7 @@ public function test_login_with_valid_credentials(): void
'token_type',
'expires_in',
'user',
- 'accounts'
+ 'accounts',
]);
}
@@ -69,7 +71,6 @@ public function test_login_with_invalid_credentials(): void
$response->assertHeaderMissing('X-Auth-Token');
}
-
public function test_logout(): void
{
$password = fake()->password(16);
@@ -82,14 +83,14 @@ public function test_logout(): void
$response->assertCookie('token');
$response2 = $this->postJson(self::LOGOUT_ROUTE, [], [
- 'Authorization' => 'Bearer ' . $response->headers->get('X-Auth-Token'),
+ 'Authorization' => 'Bearer '.$response->headers->get('X-Auth-Token'),
]);
$response2->assertStatus(200);
$response2->assertCookieExpired('token');
// try to use the expired token
$response3 = $this->getJson(self::USERS_ME_ROUTE, [
- 'Authorization' => 'Bearer ' . $response->headers->get('X-Auth-Token'),
+ 'Authorization' => 'Bearer '.$response->headers->get('X-Auth-Token'),
]);
$response3->assertStatus(401);
}
diff --git a/backend/tests/Feature/Auth/RegisterTest.php b/backend/tests/Feature/Auth/RegisterTest.php
index e8d4417ffc..ec962f4b9e 100644
--- a/backend/tests/Feature/Auth/RegisterTest.php
+++ b/backend/tests/Feature/Auth/RegisterTest.php
@@ -17,7 +17,7 @@ class RegisterTest extends TestCase
private const REGISTER_ROUTE = '/auth/register';
- public function setUp(): void
+ protected function setUp(): void
{
parent::setUp();
AccountConfiguration::firstOrCreate(['id' => 1], [
@@ -35,7 +35,7 @@ public function test_register_user(): void
{
$password = fake()->password(16);
$user = User::factory()->password($password)->make([
- 'email' => fake()->unique()->safeEmail()
+ 'email' => fake()->unique()->safeEmail(),
]);
$account = Account::factory()->make();
diff --git a/backend/tests/Feature/Auth/ResetPasswordTest.php b/backend/tests/Feature/Auth/ResetPasswordTest.php
index 643aba4c7e..c44256548b 100644
--- a/backend/tests/Feature/Auth/ResetPasswordTest.php
+++ b/backend/tests/Feature/Auth/ResetPasswordTest.php
@@ -18,9 +18,10 @@ class ResetPasswordTest extends TestCase
use RefreshDatabase;
private const RESET_PASSWORD_ROUTE = '/auth/reset-password';
+
private const FORGOT_PASSWORD_ROUTE = '/auth/forgot-password';
- public function setUp(): void
+ protected function setUp(): void
{
parent::setUp();
AccountConfiguration::firstOrCreate(['id' => 1], [
@@ -77,15 +78,14 @@ 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));
+ $response2 = $this->getJson(self::RESET_PASSWORD_ROUTE.'/'.urlencode($token));
// assert token is valid
$response2->assertStatus(204);
$password = fake()->password(16);
- $response3 = $this->postJson(self::RESET_PASSWORD_ROUTE . '/' . urlencode($token), [
+ $response3 = $this->postJson(self::RESET_PASSWORD_ROUTE.'/'.urlencode($token), [
'password' => $password,
'password_confirmation' => $password,
]);
@@ -105,12 +105,12 @@ public function test_reset_password_with_invalid_token(): void
]);
$response->assertStatus(200);
- $response2 = $this->getJson(self::RESET_PASSWORD_ROUTE . '/' . 'invalid_token');
+ $response2 = $this->getJson(self::RESET_PASSWORD_ROUTE.'/'.'invalid_token');
$response2->assertStatus(404);
$password = fake()->password(16);
- $response3 = $this->postJson(self::RESET_PASSWORD_ROUTE . '/' . 'invalid_token', [
+ $response3 = $this->postJson(self::RESET_PASSWORD_ROUTE.'/'.'invalid_token', [
'password' => $password,
'password_confirmation' => $password,
]);
@@ -138,10 +138,9 @@ 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), [
+ $response2 = $this->postJson(self::RESET_PASSWORD_ROUTE.'/'.urlencode($token), [
'password' => $password,
'password_confirmation' => $password,
]);
diff --git a/backend/tests/Feature/Database/Migrations/BackfillOccurrenceUsedCapacityTest.php b/backend/tests/Feature/Database/Migrations/BackfillOccurrenceUsedCapacityTest.php
new file mode 100644
index 0000000000..20fc8769d6
--- /dev/null
+++ b/backend/tests/Feature/Database/Migrations/BackfillOccurrenceUsedCapacityTest.php
@@ -0,0 +1,201 @@
+withAccount()->create();
+ $this->userId = $user->id;
+ $this->accountId = $user->accounts()->first()->id;
+ $this->organizerId = $this->insertOrganizer();
+ $this->eventId = $this->insertEvent();
+ $this->productId = $this->insertProduct();
+ $this->productPriceId = $this->insertProductPrice($this->productId);
+ }
+
+ public function test_counts_completed_and_offline_pending_spots_only(): void
+ {
+ $occurrenceId = $this->insertOccurrence(usedCapacity: 0);
+
+ $completed = $this->insertOrder('COMPLETED');
+ $this->insertAttendee($completed, $occurrenceId, 'ACTIVE');
+ $this->insertAttendee($completed, $occurrenceId, 'ACTIVE');
+
+ $offline = $this->insertOrder('AWAITING_OFFLINE_PAYMENT');
+ $this->insertAttendee($offline, $occurrenceId, 'AWAITING_PAYMENT');
+
+ $reserved = $this->insertOrder('RESERVED');
+ $this->insertAttendee($reserved, $occurrenceId, 'AWAITING_PAYMENT');
+
+ $cancelled = $this->insertOrder('CANCELLED');
+ $this->insertAttendee($cancelled, $occurrenceId, 'CANCELLED');
+
+ $this->migration()->up();
+
+ $this->assertSame(3, (int) DB::table('event_occurrences')->where('id', $occurrenceId)->value('used_capacity'));
+ }
+
+ public function test_resets_inflated_counter_when_no_qualifying_attendees(): void
+ {
+ $occurrenceId = $this->insertOccurrence(usedCapacity: 99);
+
+ $reserved = $this->insertOrder('RESERVED');
+ $this->insertAttendee($reserved, $occurrenceId, 'AWAITING_PAYMENT');
+
+ $this->migration()->up();
+
+ $this->assertSame(0, (int) DB::table('event_occurrences')->where('id', $occurrenceId)->value('used_capacity'));
+ }
+
+ public function test_ignores_soft_deleted_attendees(): void
+ {
+ $occurrenceId = $this->insertOccurrence(usedCapacity: 0);
+
+ $completed = $this->insertOrder('COMPLETED');
+ $this->insertAttendee($completed, $occurrenceId, 'ACTIVE');
+ $this->insertAttendee($completed, $occurrenceId, 'ACTIVE', deleted: true);
+
+ $this->migration()->up();
+
+ $this->assertSame(1, (int) DB::table('event_occurrences')->where('id', $occurrenceId)->value('used_capacity'));
+ }
+
+ private function migration(): Migration
+ {
+ return require self::MIGRATION_PATH;
+ }
+
+ private function insertOrganizer(): int
+ {
+ $now = now()->toDateTimeString();
+
+ return DB::table('organizers')->insertGetId([
+ 'account_id' => $this->accountId,
+ 'name' => 'Capacity Organizer',
+ 'email' => 'org+'.uniqid().'@example.test',
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+
+ private function insertEvent(): int
+ {
+ $now = now()->toDateTimeString();
+
+ return DB::table('events')->insertGetId([
+ 'title' => 'Capacity Event '.uniqid(),
+ 'account_id' => $this->accountId,
+ 'user_id' => $this->userId,
+ 'organizer_id' => $this->organizerId,
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'short_id' => 'evt_'.uniqid(),
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+
+ private function insertProduct(): int
+ {
+ $now = now()->toDateTimeString();
+
+ return DB::table('products')->insertGetId([
+ 'title' => 'Capacity Ticket',
+ 'event_id' => $this->eventId,
+ 'order' => 1,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+
+ private function insertProductPrice(int $productId): int
+ {
+ $now = now()->toDateTimeString();
+
+ return DB::table('product_prices')->insertGetId([
+ 'product_id' => $productId,
+ 'price' => 1000,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+
+ private function insertOccurrence(int $usedCapacity): int
+ {
+ $now = now()->toDateTimeString();
+
+ return DB::table('event_occurrences')->insertGetId([
+ 'short_id' => 'occ_'.uniqid(),
+ 'event_id' => $this->eventId,
+ 'start_date' => $now,
+ 'used_capacity' => $usedCapacity,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+
+ private function insertOrder(string $status): int
+ {
+ $now = now()->toDateTimeString();
+
+ return DB::table('orders')->insertGetId([
+ 'short_id' => 'ord_'.uniqid(),
+ 'event_id' => $this->eventId,
+ 'currency' => 'USD',
+ 'status' => $status,
+ 'public_id' => 'PUB_'.uniqid(),
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+
+ private function insertAttendee(int $orderId, int $occurrenceId, string $status, bool $deleted = false): void
+ {
+ $now = now()->toDateTimeString();
+
+ DB::table('attendees')->insert([
+ 'short_id' => 'att_'.uniqid(),
+ 'email' => 'attendee+'.uniqid().'@example.test',
+ 'order_id' => $orderId,
+ 'product_id' => $this->productId,
+ 'product_price_id' => $this->productPriceId,
+ 'event_id' => $this->eventId,
+ 'event_occurrence_id' => $occurrenceId,
+ 'public_id' => 'ATT_'.uniqid(),
+ 'status' => $status,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ 'deleted_at' => $deleted ? $now : null,
+ ]);
+ }
+}
diff --git a/backend/tests/Feature/Database/Migrations/LinkEventsToEventLocationsBackfillTest.php b/backend/tests/Feature/Database/Migrations/LinkEventsToEventLocationsBackfillTest.php
new file mode 100644
index 0000000000..ff9c71d286
--- /dev/null
+++ b/backend/tests/Feature/Database/Migrations/LinkEventsToEventLocationsBackfillTest.php
@@ -0,0 +1,215 @@
+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('Cafe',
+ 'structured_address' => ['venue_name' => 'I <3 NY', 'city' => 'Dublin', 'country' => 'IE'],
+ ], $this->authHeaders());
+
+ $create->assertStatus(ResponseCodes::HTTP_CREATED);
+ $this->assertSame('Cafe', $create->json('data.name'));
+ $this->assertSame('I <3 NY', $create->json('data.structured_address.venue_name'));
+
+ $list = $this->getJson("/organizers/{$this->organizerId}/locations", $this->authHeaders());
+ $this->assertSame('Cafe', $list->json('data.0.name'));
+ $this->assertSame('I <3 NY', $list->json('data.0.structured_address.venue_name'));
+ }
+
+ public function test_create_accepts_numeric_string_coordinates(): void
+ {
+ $create = $this->postJson("/organizers/{$this->organizerId}/locations", [
+ 'name' => 'Quoted Coords',
+ 'structured_address' => ['city' => 'Dublin', 'country' => 'IE'],
+ 'latitude' => '45.5',
+ 'longitude' => '-122.1',
+ ], $this->authHeaders());
+
+ $create->assertStatus(ResponseCodes::HTTP_CREATED);
+ $this->assertEqualsWithDelta(45.5, (float) $create->json('data.latitude'), 0.000001);
+ $this->assertEqualsWithDelta(-122.1, (float) $create->json('data.longitude'), 0.000001);
+ }
+
+ public function test_form_encoded_create_accepts_coordinates(): void
+ {
+ $create = $this->post("/organizers/{$this->organizerId}/locations", [
+ 'name' => 'Form Encoded',
+ 'structured_address' => ['city' => 'Dublin', 'country' => 'IE'],
+ 'latitude' => '45.5',
+ 'longitude' => '-122.1',
+ ], array_merge($this->authHeaders(), ['Accept' => 'application/json']));
+
+ $create->assertStatus(ResponseCodes::HTTP_CREATED);
+ $this->assertEqualsWithDelta(45.5, (float) $create->json('data.latitude'), 0.000001);
+ }
+
+ public function test_update_accepts_numeric_string_coordinates(): void
+ {
+ $locationId = $this->postJson("/organizers/{$this->organizerId}/locations", [
+ 'structured_address' => ['city' => 'Dublin', 'country' => 'IE'],
+ ], $this->authHeaders())->json('data.id');
+
+ $update = $this->putJson("/organizers/{$this->organizerId}/locations/{$locationId}", [
+ 'structured_address' => ['city' => 'Dublin', 'country' => 'IE'],
+ 'latitude' => '53.35',
+ 'longitude' => '-6.26',
+ ], $this->authHeaders());
+
+ $update->assertStatus(ResponseCodes::HTTP_OK);
+ $this->assertEqualsWithDelta(53.35, (float) $update->json('data.latitude'), 0.000001);
+ }
+
+ public function test_autocomplete_with_array_query_parameter_returns_empty_results(): void
+ {
+ $response = $this->getJson(
+ "/organizers/{$this->organizerId}/locations/autocomplete?query[]=x",
+ $this->authHeaders(),
+ );
+
+ $response->assertStatus(ResponseCodes::HTTP_OK);
+ $this->assertSame([], $response->json('data'));
+ }
+
+ public function test_create_without_any_address_field_fails_validation(): void
+ {
+ $response = $this->postJson("/organizers/{$this->organizerId}/locations", [
+ 'structured_address' => [],
+ ], $this->authHeaders());
+
+ $response->assertStatus(ResponseCodes::HTTP_UNPROCESSABLE_ENTITY);
+ $this->assertArrayHasKey('structured_address', $response->json('errors'));
+ }
+
+ public function test_create_reuses_existing_location_for_same_provider_place(): void
+ {
+ $payload = [
+ 'name' => 'Reusable Venue',
+ 'structured_address' => ['city' => 'Dublin', 'country' => 'IE'],
+ 'provider' => 'google',
+ 'provider_place_id' => 'ChIJ-reuse',
+ ];
+
+ $first = $this->postJson("/organizers/{$this->organizerId}/locations", $payload, $this->authHeaders());
+ $second = $this->postJson("/organizers/{$this->organizerId}/locations", $payload, $this->authHeaders());
+
+ $first->assertStatus(ResponseCodes::HTTP_CREATED);
+ $second->assertStatus(ResponseCodes::HTTP_CREATED);
+ $this->assertSame($first->json('data.id'), $second->json('data.id'));
+ }
+
+ public function test_update_to_provider_place_used_by_another_location_returns_conflict(): void
+ {
+ $this->postJson("/organizers/{$this->organizerId}/locations", [
+ 'structured_address' => ['city' => 'Dublin', 'country' => 'IE'],
+ 'provider' => 'google',
+ 'provider_place_id' => 'ChIJ-taken',
+ ], $this->authHeaders())->assertStatus(ResponseCodes::HTTP_CREATED);
+
+ $other = $this->postJson("/organizers/{$this->organizerId}/locations", [
+ 'structured_address' => ['city' => 'Cork', 'country' => 'IE'],
+ ], $this->authHeaders());
+
+ $response = $this->putJson("/organizers/{$this->organizerId}/locations/{$other->json('data.id')}", [
+ 'structured_address' => ['city' => 'Cork', 'country' => 'IE'],
+ 'provider' => 'google',
+ 'provider_place_id' => 'ChIJ-taken',
+ ], $this->authHeaders());
+
+ $response->assertStatus(ResponseCodes::HTTP_CONFLICT);
+ }
+
+ public function test_delete_referenced_location_returns_conflict(): void
+ {
+ $locationId = $this->postJson("/organizers/{$this->organizerId}/locations", [
+ 'structured_address' => ['city' => 'Dublin', 'country' => 'IE'],
+ ], $this->authHeaders())->json('data.id');
+
+ $eventId = DB::table('events')->insertGetId([
+ 'title' => 'Referencing Event',
+ 'account_id' => $this->accountId,
+ 'user_id' => $this->user->id,
+ 'organizer_id' => $this->organizerId,
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'short_id' => 'ev_'.uniqid(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ DB::table('event_locations')->insert([
+ 'short_id' => 'el_'.uniqid(),
+ 'event_id' => $eventId,
+ 'type' => 'IN_PERSON',
+ 'location_id' => $locationId,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $this->deleteJson("/organizers/{$this->organizerId}/locations/{$locationId}", [], $this->authHeaders())
+ ->assertStatus(ResponseCodes::HTTP_CONFLICT);
+ }
+
+ public function test_cross_account_access_is_denied(): void
+ {
+ [, $otherToken] = $this->makeAuthenticatedUser();
+
+ $this->getJson("/organizers/{$this->organizerId}/locations", $this->authHeaders($otherToken))
+ ->assertStatus(ResponseCodes::HTTP_FORBIDDEN);
+
+ $this->postJson("/organizers/{$this->organizerId}/locations", [
+ 'structured_address' => ['city' => 'Dublin', 'country' => 'IE'],
+ ], $this->authHeaders($otherToken))->assertStatus(ResponseCodes::HTTP_FORBIDDEN);
+ }
+
+ public function test_cross_organizer_location_is_not_found(): void
+ {
+ $locationId = $this->postJson("/organizers/{$this->organizerId}/locations", [
+ 'structured_address' => ['city' => 'Dublin', 'country' => 'IE'],
+ ], $this->authHeaders())->json('data.id');
+
+ $otherOrganizerId = $this->makeOrganizer($this->accountId);
+
+ $this->putJson("/organizers/{$otherOrganizerId}/locations/{$locationId}", [
+ 'structured_address' => ['city' => 'Dublin', 'country' => 'IE'],
+ ], $this->authHeaders())->assertStatus(ResponseCodes::HTTP_NOT_FOUND);
+
+ $this->deleteJson("/organizers/{$otherOrganizerId}/locations/{$locationId}", [], $this->authHeaders())
+ ->assertStatus(ResponseCodes::HTTP_NOT_FOUND);
+ }
+
+ public function test_endpoints_require_authentication(): void
+ {
+ $this->flushGuards();
+
+ $this->getJson("/organizers/{$this->organizerId}/locations")
+ ->assertStatus(ResponseCodes::HTTP_UNAUTHORIZED);
+ }
+
+ private function makeAuthenticatedUser(): array
+ {
+ $user = User::factory()->withAccount()->create();
+ $accountId = $user->accounts()->first()->id;
+
+ $token = JWTAuth::claims(['account_id' => $accountId])->fromUser($user);
+
+ return [$user, $token, $accountId];
+ }
+
+ private function makeOrganizer(int $accountId): int
+ {
+ return DB::table('organizers')->insertGetId([
+ 'account_id' => $accountId,
+ 'name' => 'Test Organizer',
+ 'email' => 'organizer-'.uniqid().'@test.com',
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ private function authHeaders(?string $token = null): array
+ {
+ $this->flushGuards();
+
+ return ['Authorization' => 'Bearer '.($token ?? $this->authToken)];
+ }
+
+ private function flushGuards(): void
+ {
+ $this->app['auth']->forgetGuards();
+ }
+}
diff --git a/backend/tests/Feature/Http/Actions/Webhooks/WebhookStatusDefaultTest.php b/backend/tests/Feature/Http/Actions/Webhooks/WebhookStatusDefaultTest.php
new file mode 100644
index 0000000000..e427b99f5e
--- /dev/null
+++ b/backend/tests/Feature/Http/Actions/Webhooks/WebhookStatusDefaultTest.php
@@ -0,0 +1,140 @@
+ 1], [
+ 'id' => 1,
+ 'name' => 'Default',
+ 'is_system_default' => true,
+ 'application_fees' => ['percentage' => 1.5, 'fixed' => 0],
+ ]);
+
+ [$this->user, $this->authToken, $this->accountId] = $this->makeAuthenticatedUser();
+ $this->organizerId = $this->makeOrganizer($this->accountId);
+ $this->eventId = $this->makeEvent();
+ }
+
+ public function test_create_event_webhook_without_status_defaults_to_enabled(): void
+ {
+ $response = $this->postJson("/events/{$this->eventId}/webhooks", [
+ 'url' => 'https://example.com/webhook',
+ 'event_types' => [DomainEventType::ORDER_CREATED->value],
+ ], $this->authHeaders());
+
+ $response->assertStatus(ResponseCodes::HTTP_OK);
+ $this->assertSame(WebhookStatus::ENABLED->value, $response->json('data.status'));
+ }
+
+ public function test_create_organizer_webhook_without_status_defaults_to_enabled(): void
+ {
+ $response = $this->postJson("/organizers/{$this->organizerId}/webhooks", [
+ 'url' => 'https://example.com/webhook',
+ 'event_types' => [DomainEventType::ORDER_CREATED->value],
+ ], $this->authHeaders());
+
+ $response->assertStatus(ResponseCodes::HTTP_OK);
+ $this->assertSame(WebhookStatus::ENABLED->value, $response->json('data.status'));
+ }
+
+ public function test_create_event_webhook_with_explicit_status_is_respected(): void
+ {
+ $response = $this->postJson("/events/{$this->eventId}/webhooks", [
+ 'url' => 'https://example.com/webhook',
+ 'event_types' => [DomainEventType::ORDER_CREATED->value],
+ 'status' => WebhookStatus::PAUSED->value,
+ ], $this->authHeaders());
+
+ $response->assertStatus(ResponseCodes::HTTP_OK);
+ $this->assertSame(WebhookStatus::PAUSED->value, $response->json('data.status'));
+ }
+
+ public function test_edit_event_webhook_without_status_defaults_to_enabled(): void
+ {
+ $webhookId = $this->postJson("/events/{$this->eventId}/webhooks", [
+ 'url' => 'https://example.com/webhook',
+ 'event_types' => [DomainEventType::ORDER_CREATED->value],
+ 'status' => WebhookStatus::PAUSED->value,
+ ], $this->authHeaders())->json('data.id');
+
+ $response = $this->putJson("/events/{$this->eventId}/webhooks/{$webhookId}", [
+ 'url' => 'https://example.com/webhook',
+ 'event_types' => [DomainEventType::ORDER_CREATED->value],
+ ], $this->authHeaders());
+
+ $response->assertStatus(ResponseCodes::HTTP_OK);
+ $this->assertSame(WebhookStatus::ENABLED->value, $response->json('data.status'));
+ }
+
+ private function makeAuthenticatedUser(): array
+ {
+ $user = User::factory()->withAccount()->create();
+ $accountId = $user->accounts()->first()->id;
+
+ $token = JWTAuth::claims(['account_id' => $accountId])->fromUser($user);
+
+ return [$user, $token, $accountId];
+ }
+
+ private function makeOrganizer(int $accountId): int
+ {
+ return DB::table('organizers')->insertGetId([
+ 'account_id' => $accountId,
+ 'name' => 'Test Organizer',
+ 'email' => 'organizer-'.uniqid().'@test.com',
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ private function makeEvent(): int
+ {
+ return DB::table('events')->insertGetId([
+ 'title' => 'Webhook Test Event',
+ 'account_id' => $this->accountId,
+ 'user_id' => $this->user->id,
+ 'organizer_id' => $this->organizerId,
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'short_id' => 'ev_'.uniqid(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ private function authHeaders(): array
+ {
+ $this->app['auth']->forgetGuards();
+
+ return ['Authorization' => 'Bearer '.$this->authToken];
+ }
+}
diff --git a/backend/tests/Feature/Repository/BaseRepositoryTest.php b/backend/tests/Feature/Repository/BaseRepositoryTest.php
new file mode 100644
index 0000000000..738da6871d
--- /dev/null
+++ b/backend/tests/Feature/Repository/BaseRepositoryTest.php
@@ -0,0 +1,652 @@
+id();
+ $table->string('name');
+ $table->timestamps();
+ });
+
+ Schema::create('br_test_widgets', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('category_id')->nullable();
+ $table->string('name');
+ $table->string('sku')->nullable();
+ $table->integer('quantity')->default(0);
+ $table->decimal('price', 10, 2)->default(0);
+ $table->boolean('is_active')->default(true);
+ $table->text('description')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ $this->repository = $this->app->make(WidgetRepository::class);
+ $this->categoryRepository = $this->app->make(WidgetCategoryRepository::class);
+ }
+
+ protected function tearDown(): void
+ {
+ Schema::dropIfExists('br_test_widgets');
+ Schema::dropIfExists('br_test_widget_categories');
+
+ parent::tearDown();
+ }
+
+ private function makeCategory(string $name = 'Default'): WidgetCategoryModel
+ {
+ $category = new WidgetCategoryModel;
+ $category->name = $name;
+ $category->save();
+
+ return $category;
+ }
+
+ private function makeWidget(array $overrides = []): WidgetModel
+ {
+ $widget = new WidgetModel;
+ $widget->fill(array_merge([
+ 'name' => 'Widget '.uniqid('', true),
+ 'sku' => 'SKU-'.uniqid('', true),
+ 'quantity' => 10,
+ 'price' => 9.99,
+ 'is_active' => true,
+ 'category_id' => null,
+ ], $overrides));
+ $widget->save();
+
+ return $widget;
+ }
+
+ public function test_create_inserts_a_row_and_hydrates_a_domain_object(): void
+ {
+ $widget = $this->repository->create([
+ 'name' => 'Sprocket',
+ 'sku' => 'SP-001',
+ 'quantity' => 5,
+ 'price' => 12.50,
+ 'is_active' => true,
+ ]);
+
+ $this->assertInstanceOf(WidgetDomainObject::class, $widget);
+ $this->assertNotNull($widget->getId());
+ $this->assertSame('Sprocket', $widget->getName());
+ $this->assertSame(5, $widget->getQuantity());
+ $this->assertSame(12.50, $widget->getPrice());
+ $this->assertTrue($widget->getIsActive());
+
+ $this->assertDatabaseHas('br_test_widgets', ['sku' => 'SP-001']);
+ }
+
+ public function test_insert_bulk_inserts_rows_and_autofills_timestamps(): void
+ {
+ $result = $this->repository->insert([
+ ['name' => 'A', 'sku' => 'A-1', 'quantity' => 1, 'price' => 1, 'is_active' => true],
+ ['name' => 'B', 'sku' => 'B-1', 'quantity' => 2, 'price' => 2, 'is_active' => true],
+ ]);
+
+ $this->assertTrue($result);
+ $this->assertSame(2, WidgetModel::query()->count());
+ $this->assertSame(0, WidgetModel::query()->whereNull('created_at')->count());
+ $this->assertSame(0, WidgetModel::query()->whereNull('updated_at')->count());
+ }
+
+ public function test_insert_preserves_caller_supplied_timestamps(): void
+ {
+ $supplied = '2020-01-01 00:00:00';
+
+ $this->repository->insert([
+ [
+ 'name' => 'A',
+ 'sku' => 'A-1',
+ 'quantity' => 1,
+ 'price' => 1,
+ 'is_active' => true,
+ 'created_at' => $supplied,
+ 'updated_at' => $supplied,
+ ],
+ ]);
+
+ $this->assertSame(1, WidgetModel::query()->where('created_at', $supplied)->count());
+ }
+
+ public function test_find_by_id_returns_hydrated_domain_object(): void
+ {
+ $widget = $this->makeWidget(['name' => 'Cog']);
+
+ $found = $this->repository->findById($widget->id);
+
+ $this->assertInstanceOf(WidgetDomainObject::class, $found);
+ $this->assertSame($widget->id, $found->getId());
+ $this->assertSame('Cog', $found->getName());
+ }
+
+ public function test_find_by_id_throws_when_missing(): void
+ {
+ $this->expectException(ModelNotFoundException::class);
+ $this->repository->findById(999_999);
+ }
+
+ public function test_find_first_returns_domain_object_when_present(): void
+ {
+ $widget = $this->makeWidget(['name' => 'Hinge']);
+
+ $found = $this->repository->findFirst($widget->id);
+
+ $this->assertNotNull($found);
+ $this->assertSame('Hinge', $found->getName());
+ }
+
+ public function test_find_first_by_field_returns_match(): void
+ {
+ $this->makeWidget(['sku' => 'UNIQ-1']);
+
+ $found = $this->repository->findFirstByField('sku', 'UNIQ-1');
+
+ $this->assertNotNull($found);
+ $this->assertSame('UNIQ-1', $found->getSku());
+ }
+
+ public function test_find_first_by_field_returns_null_when_no_match(): void
+ {
+ $found = $this->repository->findFirstByField('sku', 'does-not-exist');
+
+ $this->assertNull($found);
+ }
+
+ public function test_find_first_where_returns_first_matching_row(): void
+ {
+ $this->makeWidget(['name' => 'A', 'is_active' => false]);
+ $this->makeWidget(['name' => 'B', 'is_active' => true]);
+
+ $found = $this->repository->findFirstWhere(['is_active' => true]);
+
+ $this->assertNotNull($found);
+ $this->assertSame('B', $found->getName());
+ }
+
+ public function test_find_first_where_returns_null_when_no_match(): void
+ {
+ $this->makeWidget(['is_active' => true]);
+
+ $this->assertNull($this->repository->findFirstWhere(['is_active' => false]));
+ }
+
+ public function test_find_where_returns_collection_of_domain_objects(): void
+ {
+ $this->makeWidget(['name' => 'A', 'is_active' => true]);
+ $this->makeWidget(['name' => 'B', 'is_active' => true]);
+ $this->makeWidget(['name' => 'C', 'is_active' => false]);
+
+ $results = $this->repository->findWhere(['is_active' => true]);
+
+ $this->assertInstanceOf(Collection::class, $results);
+ $this->assertCount(2, $results);
+ $this->assertContainsOnlyInstancesOf(WidgetDomainObject::class, $results);
+ }
+
+ public function test_find_where_orders_results_using_order_and_directions(): void
+ {
+ $this->makeWidget(['name' => 'B']);
+ $this->makeWidget(['name' => 'A']);
+ $this->makeWidget(['name' => 'C']);
+
+ $results = $this->repository->findWhere(
+ where: [],
+ orderAndDirections: [new OrderAndDirection('name', 'asc')],
+ );
+
+ $names = $results->map(fn (WidgetDomainObject $w) => $w->getName())->all();
+ $this->assertSame(['A', 'B', 'C'], $names);
+ }
+
+ public function test_find_where_in_filters_by_inclusion_with_additional_where(): void
+ {
+ $w1 = $this->makeWidget(['name' => 'X', 'is_active' => true]);
+ $w2 = $this->makeWidget(['name' => 'Y', 'is_active' => false]);
+ $this->makeWidget(['name' => 'Z', 'is_active' => true]);
+
+ $results = $this->repository->findWhereIn(
+ field: 'id',
+ values: [$w1->id, $w2->id],
+ additionalWhere: ['is_active' => true],
+ );
+
+ $this->assertCount(1, $results);
+ $this->assertSame('X', $results->first()->getName());
+ }
+
+ public function test_all_returns_every_row(): void
+ {
+ $this->makeWidget();
+ $this->makeWidget();
+ $this->makeWidget();
+
+ $this->assertCount(3, $this->repository->all());
+ }
+
+ public function test_count_where_counts_matching_rows(): void
+ {
+ $this->makeWidget(['is_active' => true]);
+ $this->makeWidget(['is_active' => true]);
+ $this->makeWidget(['is_active' => false]);
+
+ $this->assertSame(2, $this->repository->countWhere(['is_active' => true]));
+ $this->assertSame(3, $this->repository->countWhere([]));
+ }
+
+ public function test_apply_conditions_supports_in_operator(): void
+ {
+ $a = $this->makeWidget();
+ $b = $this->makeWidget();
+ $this->makeWidget();
+
+ $results = $this->repository->findWhere([
+ ['id', 'in', [$a->id, $b->id]],
+ ]);
+
+ $this->assertCount(2, $results);
+ }
+
+ public function test_apply_conditions_supports_not_in_operator(): void
+ {
+ $a = $this->makeWidget();
+ $this->makeWidget();
+ $this->makeWidget();
+
+ $results = $this->repository->findWhere([
+ ['id', 'not in', [$a->id]],
+ ]);
+
+ $this->assertCount(2, $results);
+ }
+
+ public function test_apply_conditions_supports_null_operator(): void
+ {
+ $this->makeWidget(['description' => null]);
+ $this->makeWidget(['description' => 'has text']);
+
+ $results = $this->repository->findWhere([
+ ['description', 'null', null],
+ ]);
+
+ $this->assertCount(1, $results);
+ }
+
+ public function test_apply_conditions_supports_not_null_operator(): void
+ {
+ $this->makeWidget(['description' => null]);
+ $this->makeWidget(['description' => 'has text']);
+
+ $results = $this->repository->findWhere([
+ ['description', 'not null', null],
+ ]);
+
+ $this->assertCount(1, $results);
+ }
+
+ public function test_apply_conditions_supports_comparison_operators(): void
+ {
+ $this->makeWidget(['quantity' => 5]);
+ $this->makeWidget(['quantity' => 10]);
+ $this->makeWidget(['quantity' => 15]);
+
+ $this->assertCount(2, $this->repository->findWhere([['quantity', '>=', 10]]));
+ $this->assertCount(1, $this->repository->findWhere([['quantity', '<', 10]]));
+ $this->assertCount(1, $this->repository->findWhere([['quantity', '=', 15]]));
+ }
+
+ public function test_apply_conditions_treats_simple_pairs_as_equality(): void
+ {
+ $this->makeWidget(['name' => 'foo']);
+ $this->makeWidget(['name' => 'bar']);
+
+ $results = $this->repository->findWhere(['name' => 'foo']);
+
+ $this->assertCount(1, $results);
+ }
+
+ public function test_apply_conditions_supports_callable_value(): void
+ {
+ $this->makeWidget(['name' => 'foo', 'is_active' => true]);
+ $this->makeWidget(['name' => 'bar', 'is_active' => true]);
+ $this->makeWidget(['name' => 'foo', 'is_active' => false]);
+
+ $results = $this->repository->findWhere([
+ 'name' => 'foo',
+ fn ($q) => $q->where('is_active', true),
+ ]);
+
+ $this->assertCount(1, $results);
+ }
+
+ public function test_update_from_array_persists_changes_and_returns_fresh_object(): void
+ {
+ $widget = $this->makeWidget(['name' => 'old', 'quantity' => 1]);
+
+ $updated = $this->repository->updateFromArray($widget->id, [
+ 'name' => 'new',
+ 'quantity' => 99,
+ ]);
+
+ $this->assertSame('new', $updated->getName());
+ $this->assertSame(99, $updated->getQuantity());
+ $this->assertDatabaseHas('br_test_widgets', ['id' => $widget->id, 'name' => 'new']);
+ }
+
+ public function test_update_where_returns_affected_count(): void
+ {
+ $this->makeWidget(['is_active' => true]);
+ $this->makeWidget(['is_active' => true]);
+ $this->makeWidget(['is_active' => false]);
+
+ $affected = $this->repository->updateWhere(
+ attributes: ['name' => 'renamed'],
+ where: ['is_active' => true],
+ );
+
+ $this->assertSame(2, $affected);
+ $this->assertSame(2, WidgetModel::query()->where('name', 'renamed')->count());
+ }
+
+ public function test_update_by_id_where_updates_when_predicate_matches(): void
+ {
+ $widget = $this->makeWidget(['is_active' => true, 'name' => 'old']);
+
+ $updated = $this->repository->updateByIdWhere(
+ id: $widget->id,
+ attributes: ['name' => 'new'],
+ where: ['is_active' => true],
+ );
+
+ $this->assertSame('new', $updated->getName());
+ }
+
+ public function test_update_by_id_where_throws_when_predicate_does_not_match(): void
+ {
+ $widget = $this->makeWidget(['is_active' => true]);
+
+ $this->expectException(ModelNotFoundException::class);
+ $this->repository->updateByIdWhere(
+ id: $widget->id,
+ attributes: ['name' => 'new'],
+ where: ['is_active' => false],
+ );
+ }
+
+ public function test_delete_by_id_soft_deletes_the_row(): void
+ {
+ $widget = $this->makeWidget();
+
+ $this->assertTrue($this->repository->deleteById($widget->id));
+ $this->assertSoftDeleted('br_test_widgets', ['id' => $widget->id]);
+ }
+
+ public function test_delete_where_returns_affected_count(): void
+ {
+ $this->makeWidget(['is_active' => true]);
+ $this->makeWidget(['is_active' => true]);
+ $this->makeWidget(['is_active' => false]);
+
+ $deleted = $this->repository->deleteWhere(['is_active' => true]);
+
+ $this->assertSame(2, $deleted);
+ }
+
+ public function test_increment_bumps_an_integer_column(): void
+ {
+ $widget = $this->makeWidget(['quantity' => 10]);
+
+ $this->repository->increment($widget->id, 'quantity', 3);
+
+ $this->assertSame(13, (int) WidgetModel::query()->find($widget->id)->quantity);
+ }
+
+ public function test_increment_supports_float_amount(): void
+ {
+ $widget = $this->makeWidget(['price' => 10.00]);
+
+ $this->repository->increment($widget->id, 'price', 2.50);
+
+ $this->assertSame(12.50, (float) WidgetModel::query()->find($widget->id)->price);
+ }
+
+ public function test_decrement_lowers_an_integer_column(): void
+ {
+ $widget = $this->makeWidget(['quantity' => 10]);
+
+ $this->repository->decrement($widget->id, 'quantity', 4);
+
+ $this->assertSame(6, (int) WidgetModel::query()->find($widget->id)->quantity);
+ }
+
+ public function test_increment_where_bumps_matching_rows(): void
+ {
+ $a = $this->makeWidget(['quantity' => 1, 'is_active' => true]);
+ $b = $this->makeWidget(['quantity' => 1, 'is_active' => true]);
+ $c = $this->makeWidget(['quantity' => 1, 'is_active' => false]);
+
+ $this->repository->incrementWhere(['is_active' => true], 'quantity', 5);
+
+ $this->assertSame(6, (int) WidgetModel::query()->find($a->id)->quantity);
+ $this->assertSame(6, (int) WidgetModel::query()->find($b->id)->quantity);
+ $this->assertSame(1, (int) WidgetModel::query()->find($c->id)->quantity);
+ }
+
+ public function test_increment_each_updates_multiple_columns(): void
+ {
+ $widget = $this->makeWidget(['quantity' => 1, 'price' => 1.00]);
+
+ $this->repository->incrementEach(
+ columns: ['quantity' => 2, 'price' => 3.00],
+ where: ['id' => $widget->id],
+ );
+
+ $fresh = WidgetModel::query()->find($widget->id);
+ $this->assertSame(3, (int) $fresh->quantity);
+ $this->assertSame(4.00, (float) $fresh->price);
+ }
+
+ public function test_decrement_each_updates_multiple_columns(): void
+ {
+ $widget = $this->makeWidget(['quantity' => 10, 'price' => 10.00]);
+
+ $this->repository->decrementEach(
+ where: ['id' => $widget->id],
+ columns: ['quantity' => 2, 'price' => 1.00],
+ );
+
+ $fresh = WidgetModel::query()->find($widget->id);
+ $this->assertSame(8, (int) $fresh->quantity);
+ $this->assertSame(9.00, (float) $fresh->price);
+ }
+
+ public function test_paginate_returns_a_length_aware_paginator(): void
+ {
+ for ($i = 0; $i < 5; $i++) {
+ $this->makeWidget();
+ }
+
+ $page = $this->repository->paginate(limit: 2);
+
+ $this->assertInstanceOf(LengthAwarePaginator::class, $page);
+ $this->assertSame(5, $page->total());
+ $this->assertCount(2, $page->items());
+ $this->assertContainsOnlyInstancesOf(WidgetDomainObject::class, $page->items());
+ }
+
+ public function test_paginate_where_filters_then_paginates(): void
+ {
+ for ($i = 0; $i < 3; $i++) {
+ $this->makeWidget(['is_active' => true]);
+ }
+ $this->makeWidget(['is_active' => false]);
+
+ $page = $this->repository->paginateWhere(['is_active' => true], limit: 2);
+
+ $this->assertSame(3, $page->total());
+ $this->assertCount(2, $page->items());
+ }
+
+ public function test_simple_paginate_where_returns_a_simple_paginator(): void
+ {
+ for ($i = 0; $i < 4; $i++) {
+ $this->makeWidget(['is_active' => true]);
+ }
+
+ $page = $this->repository->simplePaginateWhere(['is_active' => true], limit: 2);
+
+ $this->assertInstanceOf(Paginator::class, $page);
+ $this->assertCount(2, $page->items());
+ }
+
+ public function test_load_relation_hydrates_a_belongs_to_relation(): void
+ {
+ $category = $this->makeCategory('Tools');
+ $widget = $this->makeWidget(['category_id' => $category->id]);
+
+ $found = $this->repository
+ ->loadRelation(new Relationship(WidgetCategoryDomainObject::class, name: 'category'))
+ ->findById($widget->id);
+
+ $this->assertNotNull($found->getCategory());
+ $this->assertInstanceOf(WidgetCategoryDomainObject::class, $found->getCategory());
+ $this->assertSame('Tools', $found->getCategory()->getName());
+ }
+
+ public function test_load_relation_hydrates_a_has_many_relation_as_a_collection(): void
+ {
+ $category = $this->makeCategory('Bolts');
+ $this->makeWidget(['category_id' => $category->id, 'name' => 'M3']);
+ $this->makeWidget(['category_id' => $category->id, 'name' => 'M4']);
+
+ $found = $this->categoryRepository
+ ->loadRelation(new Relationship(WidgetDomainObject::class, name: 'widgets'))
+ ->findById($category->id);
+
+ $this->assertInstanceOf(Collection::class, $found->getWidgets());
+ $this->assertCount(2, $found->getWidgets());
+ }
+
+ public function test_include_deleted_returns_soft_deleted_rows(): void
+ {
+ $widget = $this->makeWidget();
+ $this->repository->deleteById($widget->id);
+
+ $this->assertNull($this->repository->findFirstWhere(['id' => $widget->id]));
+
+ $found = $this->repository->includeDeleted()->findFirstWhere(['id' => $widget->id]);
+ $this->assertNotNull($found);
+ $this->assertSame($widget->id, $found->getId());
+ }
+
+ public function test_consecutive_finds_do_not_leak_where_clauses(): void
+ {
+ $a = $this->makeWidget(['is_active' => true]);
+ $b = $this->makeWidget(['is_active' => false]);
+
+ $first = $this->repository->findWhere(['is_active' => true]);
+ $this->assertCount(1, $first);
+
+ $second = $this->repository->findWhere([]);
+ $this->assertCount(2, $second, 'Second findWhere([]) inherited state from the previous query');
+ }
+
+ public function test_eager_loads_are_reset_between_queries(): void
+ {
+ $category = $this->makeCategory('Cat');
+ $widgetA = $this->makeWidget(['category_id' => $category->id]);
+ $widgetB = $this->makeWidget(['category_id' => $category->id]);
+
+ $first = $this->repository
+ ->loadRelation(new Relationship(WidgetCategoryDomainObject::class, name: 'category'))
+ ->findById($widgetA->id);
+ $this->assertNotNull($first->getCategory());
+
+ $this->assertSame([], $this->repository->exposeEagerLoads());
+
+ $second = $this->repository->findById($widgetB->id);
+ $this->assertNull($second->getCategory());
+ }
+
+ public function test_state_is_reset_even_when_the_query_throws(): void
+ {
+ $this->makeWidget(['is_active' => true]);
+
+ try {
+ $this->repository
+ ->loadRelation(new Relationship(WidgetCategoryDomainObject::class, name: 'category'))
+ ->findById(999_999);
+ $this->fail('Expected ModelNotFoundException');
+ } catch (ModelNotFoundException) {
+ }
+
+ $this->assertSame([], $this->repository->exposeEagerLoads());
+ $this->assertFalse($this->repository->exposeBuilderHasWheres());
+ }
+
+ public function test_set_max_per_page_caps_pagination_size(): void
+ {
+ for ($i = 0; $i < 10; $i++) {
+ $this->makeWidget();
+ }
+
+ $page = $this->repository->setMaxPerPage(3)->paginate(limit: 100);
+
+ $this->assertCount(3, $page->items());
+ }
+
+ public function test_hydration_calls_setters_via_studly_case(): void
+ {
+ $category = $this->makeCategory();
+ $widget = $this->makeWidget(['category_id' => $category->id]);
+
+ $found = $this->repository->findById($widget->id);
+
+ $this->assertSame($category->id, $found->getCategoryId());
+ }
+
+ public function test_hydration_silently_skips_columns_with_no_setter(): void
+ {
+ Schema::table('br_test_widgets', function (Blueprint $table) {
+ $table->string('mystery_field')->nullable();
+ });
+
+ $widget = $this->makeWidget();
+ WidgetModel::query()->where('id', $widget->id)->update(['mystery_field' => 'something']);
+
+ $found = $this->repository->findById($widget->id);
+ $this->assertNotNull($found);
+ }
+}
diff --git a/backend/tests/Feature/Repository/Eloquent/EventOccurrenceRepositoryTest.php b/backend/tests/Feature/Repository/Eloquent/EventOccurrenceRepositoryTest.php
new file mode 100644
index 0000000000..e9c849eb06
--- /dev/null
+++ b/backend/tests/Feature/Repository/Eloquent/EventOccurrenceRepositoryTest.php
@@ -0,0 +1,121 @@
+withAccount()->create();
+ $accountId = $user->accounts()->first()->id;
+ $now = now()->toDateTimeString();
+
+ $organizerId = DB::table('organizers')->insertGetId([
+ 'account_id' => $accountId,
+ 'name' => 'Occurrences Organizer',
+ 'email' => 'occurrences-organizer@example.test',
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->eventId = DB::table('events')->insertGetId([
+ 'title' => 'Occurrence filter event',
+ 'status' => 'DRAFT',
+ 'account_id' => $accountId,
+ 'user_id' => $user->id,
+ 'organizer_id' => $organizerId,
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'short_id' => 'evt_'.uniqid(),
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->runningOccurrenceId = $this->createOccurrence(now()->subDay(), now()->addDay());
+ $this->futureOccurrenceId = $this->createOccurrence(now()->addDays(2), now()->addDays(2)->addHours(2));
+ $this->endedOccurrenceId = $this->createOccurrence(now()->subDays(3), now()->subDays(3)->addHours(2));
+ $this->nullEndPastStartOccurrenceId = $this->createOccurrence(now()->subHours(3), null);
+ $this->nullEndFutureStartOccurrenceId = $this->createOccurrence(now()->addHours(3), null);
+ }
+
+ public function test_upcoming_time_period_includes_running_occurrence(): void
+ {
+ $ids = $this->findOccurrenceIds('upcoming');
+
+ $this->assertContains($this->runningOccurrenceId, $ids);
+ $this->assertContains($this->futureOccurrenceId, $ids);
+ $this->assertContains($this->nullEndFutureStartOccurrenceId, $ids);
+ $this->assertNotContains($this->endedOccurrenceId, $ids);
+ $this->assertNotContains($this->nullEndPastStartOccurrenceId, $ids);
+ }
+
+ public function test_past_time_period_excludes_running_occurrence(): void
+ {
+ $ids = $this->findOccurrenceIds('past');
+
+ $this->assertContains($this->endedOccurrenceId, $ids);
+ $this->assertContains($this->nullEndPastStartOccurrenceId, $ids);
+ $this->assertNotContains($this->runningOccurrenceId, $ids);
+ $this->assertNotContains($this->futureOccurrenceId, $ids);
+ $this->assertNotContains($this->nullEndFutureStartOccurrenceId, $ids);
+ }
+
+ private function findOccurrenceIds(string $timePeriod): array
+ {
+ $params = QueryParamsDTO::fromArray([
+ 'filter_fields' => ['time_period' => ['eq' => $timePeriod]],
+ 'per_page' => 100,
+ ]);
+
+ $result = $this->app->make(EventOccurrenceRepository::class)->findByEventId($this->eventId, $params);
+
+ return collect($result->items())
+ ->map(fn (EventOccurrenceDomainObject $occurrence) => $occurrence->getId())
+ ->all();
+ }
+
+ private function createOccurrence($startDate, $endDate): int
+ {
+ $now = now()->toDateTimeString();
+
+ return DB::table('event_occurrences')->insertGetId([
+ 'short_id' => 'occ_'.uniqid(),
+ 'event_id' => $this->eventId,
+ 'start_date' => $startDate->toDateTimeString(),
+ 'end_date' => $endDate?->toDateTimeString(),
+ 'status' => 'ACTIVE',
+ 'used_capacity' => 0,
+ 'is_overridden' => false,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+}
diff --git a/backend/tests/Feature/Repository/Eloquent/EventRepositoryTest.php b/backend/tests/Feature/Repository/Eloquent/EventRepositoryTest.php
new file mode 100644
index 0000000000..37ce415ebe
--- /dev/null
+++ b/backend/tests/Feature/Repository/Eloquent/EventRepositoryTest.php
@@ -0,0 +1,211 @@
+withAccount()->create();
+ $this->userId = $user->id;
+ $this->accountId = $user->accounts()->first()->id;
+
+ $now = now()->toDateTimeString();
+
+ $this->organizerId = DB::table('organizers')->insertGetId([
+ 'account_id' => $this->accountId,
+ 'name' => 'Events Organizer',
+ 'email' => 'events-organizer@example.test',
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->eventWithoutOccurrencesId = $this->createEvent('Event without occurrences');
+ $this->eventWithFutureOccurrenceId = $this->createEvent('Event with future occurrence');
+ $this->eventWithPastOccurrenceId = $this->createEvent('Event with past occurrence');
+
+ $this->createOccurrence($this->eventWithFutureOccurrenceId, now()->addDay(), now()->addDay()->addHours(2));
+ $this->createOccurrence($this->eventWithPastOccurrenceId, now()->subDays(2), now()->subDays(2)->addHours(2));
+ }
+
+ public function test_upcoming_filter_includes_events_with_no_occurrences(): void
+ {
+ $ids = $this->findEventIds('upcoming');
+
+ $this->assertContains($this->eventWithoutOccurrencesId, $ids);
+ $this->assertContains($this->eventWithFutureOccurrenceId, $ids);
+ $this->assertNotContains($this->eventWithPastOccurrenceId, $ids);
+ }
+
+ public function test_ended_filter_only_includes_events_whose_occurrences_have_all_passed(): void
+ {
+ $ids = $this->findEventIds('ended');
+
+ $this->assertContains($this->eventWithPastOccurrenceId, $ids);
+ $this->assertNotContains($this->eventWithoutOccurrencesId, $ids);
+ $this->assertNotContains($this->eventWithFutureOccurrenceId, $ids);
+ }
+
+ public function test_running_multi_day_occurrence_keeps_event_upcoming(): void
+ {
+ $eventId = $this->createEvent('Running multi-day '.uniqid());
+ $this->createOccurrence($eventId, now()->subDay(), now()->addDay());
+
+ $this->assertContains($eventId, $this->findEventIds('upcoming'));
+ $this->assertNotContains($eventId, $this->findEventIds('ended'));
+ }
+
+ public function test_null_end_occurrence_with_past_start_marks_event_ended(): void
+ {
+ $eventId = $this->createEvent('Null end past start '.uniqid());
+ $this->createOccurrence($eventId, now()->subDay(), null);
+
+ $this->assertContains($eventId, $this->findEventIds('ended'));
+ $this->assertNotContains($eventId, $this->findEventIds('upcoming'));
+ }
+
+ public function test_null_end_occurrence_with_future_start_keeps_event_upcoming(): void
+ {
+ $eventId = $this->createEvent('Null end future start '.uniqid());
+ $this->createOccurrence($eventId, now()->addDay(), null);
+
+ $this->assertContains($eventId, $this->findEventIds('upcoming'));
+ $this->assertNotContains($eventId, $this->findEventIds('ended'));
+ }
+
+ public function test_get_all_events_for_admin_hydrates_occurrence_dates(): void
+ {
+ $title = 'Admin hydration event '.uniqid();
+ $eventId = $this->createEvent($title, status: 'LIVE');
+ $this->createOccurrence($eventId, now()->addHours(2), now()->addHours(4));
+
+ $result = $this->app->make(EventRepository::class)->getAllEventsForAdmin(search: $title);
+
+ $events = collect($result->items());
+ $this->assertCount(1, $events);
+
+ /** @var EventDomainObject $event */
+ $event = $events->first();
+ $this->assertSame($eventId, $event->getId());
+ $this->assertNotNull($event->getStartDate(), 'Admin list must hydrate occurrences so start_date resolves');
+ $this->assertNotNull($event->getOrganizer());
+ $this->assertNotNull($event->getAccount());
+ }
+
+ public function test_get_upcoming_events_for_admin_hydrates_occurrence_dates(): void
+ {
+ $eventId = $this->createEvent('Upcoming admin event '.uniqid(), status: 'LIVE');
+ $this->createOccurrence($eventId, now()->addHours(2), now()->addHours(4));
+
+ $result = $this->app->make(EventRepository::class)->getUpcomingEventsForAdmin(perPage: 100);
+
+ /** @var EventDomainObject|null $event */
+ $event = collect($result->items())->first(fn (EventDomainObject $e) => $e->getId() === $eventId);
+
+ $this->assertNotNull($event, 'LIVE event with an occurrence in the next 24h should appear in the upcoming admin list');
+ $this->assertNotNull($event->getStartDate(), 'Upcoming admin list must hydrate occurrences so start_date resolves');
+ $this->assertNotNull($event->getOrganizer());
+ $this->assertNotNull($event->getAccount());
+ }
+
+ public function test_get_all_events_for_admin_sorts_by_earliest_occurrence_start_date(): void
+ {
+ $earlyId = $this->createEvent('Sort early '.uniqid());
+ $lateId = $this->createEvent('Sort late '.uniqid());
+ $this->createOccurrence($earlyId, now()->addDays(3), now()->addDays(3)->addHours(2));
+ $this->createOccurrence($lateId, now()->addDays(30), now()->addDays(30)->addHours(2));
+
+ $result = $this->app->make(EventRepository::class)
+ ->getAllEventsForAdmin(perPage: 100, sortBy: 'start_date', sortDirection: 'asc');
+
+ $ids = collect($result->items())->map(fn (EventDomainObject $e) => $e->getId())->all();
+
+ $this->assertLessThan(array_search($lateId, $ids, true), array_search($earlyId, $ids, true));
+ $this->assertLessThan(array_search($this->eventWithoutOccurrencesId, $ids, true), array_search($lateId, $ids, true));
+ }
+
+ private function findEventIds(string $eventsStatus): array
+ {
+ $params = QueryParamsDTO::fromArray([
+ 'eventsStatus' => $eventsStatus,
+ 'sort_by' => 'created_at',
+ 'sort_direction' => 'desc',
+ 'per_page' => 100,
+ ]);
+
+ $result = $this->app->make(EventRepository::class)->findEvents(
+ where: [
+ 'account_id' => $this->accountId,
+ 'organizer_id' => $this->organizerId,
+ ],
+ params: $params,
+ );
+
+ return collect($result->items())
+ ->map(fn (EventDomainObject $event) => $event->getId())
+ ->all();
+ }
+
+ private function createEvent(string $title, string $status = 'DRAFT'): int
+ {
+ $now = now()->toDateTimeString();
+
+ return DB::table('events')->insertGetId([
+ 'title' => $title,
+ 'status' => $status,
+ 'account_id' => $this->accountId,
+ 'user_id' => $this->userId,
+ 'organizer_id' => $this->organizerId,
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'short_id' => 'evt_'.uniqid(),
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+
+ private function createOccurrence(int $eventId, $startDate, $endDate): void
+ {
+ $now = now()->toDateTimeString();
+
+ DB::table('event_occurrences')->insert([
+ 'short_id' => 'occ_'.uniqid(),
+ 'event_id' => $eventId,
+ 'start_date' => $startDate->toDateTimeString(),
+ 'end_date' => $endDate?->toDateTimeString(),
+ 'status' => 'ACTIVE',
+ 'used_capacity' => 0,
+ 'is_overridden' => false,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+}
diff --git a/backend/tests/Feature/Repository/Eloquent/LocationRepositoryTest.php b/backend/tests/Feature/Repository/Eloquent/LocationRepositoryTest.php
new file mode 100644
index 0000000000..6c573a27b5
--- /dev/null
+++ b/backend/tests/Feature/Repository/Eloquent/LocationRepositoryTest.php
@@ -0,0 +1,132 @@
+repository = $this->app->make(LocationRepository::class);
+
+ $user = User::factory()->withAccount()->create();
+ $this->userId = $user->id;
+ $this->accountId = $user->accounts()->first()->id;
+ $this->organizerId = $this->insertOrganizer();
+ }
+
+ public function test_returns_true_when_location_is_referenced_by_active_event_location(): void
+ {
+ $locationId = $this->insertLocation();
+ $eventId = $this->insertEvent();
+ $this->insertEventLocation($eventId, $locationId);
+
+ $this->assertTrue($this->repository->isReferenced($locationId));
+ }
+
+ public function test_returns_true_when_location_is_referenced_only_by_organizer(): void
+ {
+ $locationId = $this->insertLocation();
+ DB::table('organizers')
+ ->where('id', $this->organizerId)
+ ->update(['location_id' => $locationId]);
+
+ $this->assertTrue($this->repository->isReferenced($locationId));
+ }
+
+ public function test_returns_false_when_location_is_unreferenced(): void
+ {
+ $locationId = $this->insertLocation();
+
+ $this->assertFalse($this->repository->isReferenced($locationId));
+ }
+
+ public function test_soft_deleted_event_location_does_not_count_as_reference(): void
+ {
+ $locationId = $this->insertLocation();
+ $eventId = $this->insertEvent();
+ $this->insertEventLocation($eventId, $locationId, deletedAt: now());
+
+ $this->assertFalse($this->repository->isReferenced($locationId));
+ }
+
+ private function insertOrganizer(): int
+ {
+ $now = now()->toDateTimeString();
+
+ return DB::table('organizers')->insertGetId([
+ 'account_id' => $this->accountId,
+ 'name' => 'Test Organizer',
+ 'email' => 'organizer+'.uniqid().'@example.test',
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+
+ private function insertLocation(): int
+ {
+ $now = now()->toDateTimeString();
+
+ return DB::table('locations')->insertGetId([
+ 'short_id' => 'loc_'.uniqid(),
+ 'account_id' => $this->accountId,
+ 'organizer_id' => $this->organizerId,
+ 'name' => 'Test Venue',
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+
+ private function insertEvent(): int
+ {
+ $now = now()->toDateTimeString();
+
+ return DB::table('events')->insertGetId([
+ 'title' => 'Test Event',
+ 'account_id' => $this->accountId,
+ 'user_id' => $this->userId,
+ 'organizer_id' => $this->organizerId,
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'short_id' => 'evt_'.uniqid(),
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+
+ private function insertEventLocation(int $eventId, int $locationId, ?\DateTimeInterface $deletedAt = null): int
+ {
+ $now = now()->toDateTimeString();
+
+ return DB::table('event_locations')->insertGetId([
+ 'short_id' => 'el_'.uniqid(),
+ 'event_id' => $eventId,
+ 'type' => 'IN_PERSON',
+ 'location_id' => $locationId,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ 'deleted_at' => $deletedAt,
+ ]);
+ }
+}
diff --git a/backend/tests/Feature/Repository/Eloquent/OrderItemRepositoryTest.php b/backend/tests/Feature/Repository/Eloquent/OrderItemRepositoryTest.php
new file mode 100644
index 0000000000..41709a463a
--- /dev/null
+++ b/backend/tests/Feature/Repository/Eloquent/OrderItemRepositoryTest.php
@@ -0,0 +1,287 @@
+repository = $this->app->make(OrderItemRepository::class);
+
+ $user = User::factory()->withAccount()->create();
+ $this->accountId = $user->accounts()->first()->id;
+
+ $now = now()->toDateTimeString();
+
+ $organizerId = DB::table('organizers')->insertGetId([
+ 'account_id' => $this->accountId,
+ 'name' => 'Test Organizer',
+ 'email' => 'organizer@example.test',
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->eventId = DB::table('events')->insertGetId([
+ 'title' => 'Test Event',
+ 'account_id' => $this->accountId,
+ 'user_id' => $user->id,
+ 'organizer_id' => $organizerId,
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'short_id' => 'test_evt_'.uniqid(),
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->occurrenceId = DB::table('event_occurrences')->insertGetId([
+ 'short_id' => 'occ_'.uniqid(),
+ 'event_id' => $this->eventId,
+ 'start_date' => now()->addDay()->toDateTimeString(),
+ 'end_date' => now()->addDays(1)->addHours(2)->toDateTimeString(),
+ 'status' => 'ACTIVE',
+ 'used_capacity' => 0,
+ 'is_overridden' => false,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->otherOccurrenceId = DB::table('event_occurrences')->insertGetId([
+ 'short_id' => 'occ_'.uniqid(),
+ 'event_id' => $this->eventId,
+ 'start_date' => now()->addDays(2)->toDateTimeString(),
+ 'end_date' => now()->addDays(2)->addHours(2)->toDateTimeString(),
+ 'status' => 'ACTIVE',
+ 'used_capacity' => 0,
+ 'is_overridden' => false,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->productId = DB::table('products')->insertGetId([
+ 'title' => 'Test Product',
+ 'event_id' => $this->eventId,
+ 'order' => 1,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->productPriceId = DB::table('product_prices')->insertGetId([
+ 'product_id' => $this->productId,
+ 'price' => 10.00,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+
+ public function test_returns_zero_when_no_reservations(): void
+ {
+ $this->assertSame(0, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId));
+ }
+
+ public function test_sums_active_reservations_for_occurrence(): void
+ {
+ $this->insertOrderWithItems(
+ status: OrderStatus::RESERVED->name,
+ reservedUntil: now()->addHour(),
+ occurrenceQuantities: [$this->occurrenceId => 3],
+ );
+ $this->insertOrderWithItems(
+ status: OrderStatus::RESERVED->name,
+ reservedUntil: now()->addMinutes(30),
+ occurrenceQuantities: [$this->occurrenceId => 2],
+ );
+
+ $this->assertSame(5, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId));
+ }
+
+ public function test_ignores_expired_reservations(): void
+ {
+ $this->insertOrderWithItems(
+ status: OrderStatus::RESERVED->name,
+ reservedUntil: now()->subMinute(),
+ occurrenceQuantities: [$this->occurrenceId => 5],
+ );
+ $this->insertOrderWithItems(
+ status: OrderStatus::RESERVED->name,
+ reservedUntil: now()->addHour(),
+ occurrenceQuantities: [$this->occurrenceId => 4],
+ );
+
+ $this->assertSame(4, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId));
+ }
+
+ public function test_ignores_non_reserved_orders(): void
+ {
+ $this->insertOrderWithItems(
+ status: OrderStatus::COMPLETED->name,
+ reservedUntil: now()->addHour(),
+ occurrenceQuantities: [$this->occurrenceId => 7],
+ );
+ $this->insertOrderWithItems(
+ status: OrderStatus::CANCELLED->name,
+ reservedUntil: now()->addHour(),
+ occurrenceQuantities: [$this->occurrenceId => 9],
+ );
+ $this->insertOrderWithItems(
+ status: OrderStatus::RESERVED->name,
+ reservedUntil: now()->addHour(),
+ occurrenceQuantities: [$this->occurrenceId => 1],
+ );
+
+ $this->assertSame(1, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId));
+ }
+
+ public function test_ignores_soft_deleted_orders(): void
+ {
+ $this->insertOrderWithItems(
+ status: OrderStatus::RESERVED->name,
+ reservedUntil: now()->addHour(),
+ occurrenceQuantities: [$this->occurrenceId => 6],
+ deletedAt: now(),
+ );
+ $this->insertOrderWithItems(
+ status: OrderStatus::RESERVED->name,
+ reservedUntil: now()->addHour(),
+ occurrenceQuantities: [$this->occurrenceId => 2],
+ );
+
+ $this->assertSame(2, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId));
+ }
+
+ public function test_scopes_by_occurrence_id(): void
+ {
+ $this->insertOrderWithItems(
+ status: OrderStatus::RESERVED->name,
+ reservedUntil: now()->addHour(),
+ occurrenceQuantities: [
+ $this->occurrenceId => 3,
+ $this->otherOccurrenceId => 7,
+ ],
+ );
+
+ $this->assertSame(3, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId));
+ $this->assertSame(7, $this->repository->getReservedQuantityForOccurrence($this->otherOccurrenceId));
+ }
+
+ public function test_ignores_general_product_order_items(): void
+ {
+ $this->insertOrderWithItems(
+ status: OrderStatus::RESERVED->name,
+ reservedUntil: now()->addHour(),
+ occurrenceQuantities: [$this->occurrenceId => 9],
+ productType: ProductType::GENERAL->name,
+ );
+ $this->insertOrderWithItems(
+ status: OrderStatus::RESERVED->name,
+ reservedUntil: now()->addHour(),
+ occurrenceQuantities: [$this->occurrenceId => 2],
+ );
+
+ $this->assertSame(2, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId));
+ }
+
+ public function test_ignores_soft_deleted_order_items(): void
+ {
+ $orderId = DB::table('orders')->insertGetId([
+ 'short_id' => 'ord_'.uniqid(),
+ 'event_id' => $this->eventId,
+ 'currency' => 'USD',
+ 'status' => OrderStatus::RESERVED->name,
+ 'reserved_until' => now()->addHour(),
+ 'public_id' => 'pub_'.uniqid(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ DB::table('order_items')->insert([
+ 'order_id' => $orderId,
+ 'product_id' => $this->productId,
+ 'product_price_id' => $this->productPriceId,
+ 'event_occurrence_id' => $this->occurrenceId,
+ 'quantity' => 4,
+ 'price' => 10.00,
+ 'total_before_additions' => 40.00,
+ ]);
+
+ DB::table('order_items')->insert([
+ 'order_id' => $orderId,
+ 'product_id' => $this->productId,
+ 'product_price_id' => $this->productPriceId,
+ 'event_occurrence_id' => $this->occurrenceId,
+ 'quantity' => 6,
+ 'price' => 10.00,
+ 'total_before_additions' => 60.00,
+ 'deleted_at' => now(),
+ ]);
+
+ $this->assertSame(4, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId));
+ }
+
+ /**
+ * @param array $occurrenceQuantities Map of event_occurrence_id => quantity
+ */
+ private function insertOrderWithItems(
+ string $status,
+ \DateTimeInterface $reservedUntil,
+ array $occurrenceQuantities,
+ ?\DateTimeInterface $deletedAt = null,
+ string $productType = ProductType::TICKET->name,
+ ): int {
+ $orderId = DB::table('orders')->insertGetId([
+ 'short_id' => 'ord_'.uniqid(),
+ 'event_id' => $this->eventId,
+ 'currency' => 'USD',
+ 'status' => $status,
+ 'reserved_until' => $reservedUntil,
+ 'public_id' => 'pub_'.uniqid(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ 'deleted_at' => $deletedAt,
+ ]);
+
+ foreach ($occurrenceQuantities as $occurrenceId => $quantity) {
+ DB::table('order_items')->insert([
+ 'order_id' => $orderId,
+ 'product_id' => $this->productId,
+ 'product_price_id' => $this->productPriceId,
+ 'product_type' => $productType,
+ 'event_occurrence_id' => $occurrenceId,
+ 'quantity' => $quantity,
+ 'price' => 10.00,
+ 'total_before_additions' => $quantity * 10.00,
+ ]);
+ }
+
+ return $orderId;
+ }
+}
diff --git a/backend/tests/Feature/Repository/Eloquent/OrganizerRepositoryTest.php b/backend/tests/Feature/Repository/Eloquent/OrganizerRepositoryTest.php
new file mode 100644
index 0000000000..adb3868c1a
--- /dev/null
+++ b/backend/tests/Feature/Repository/Eloquent/OrganizerRepositoryTest.php
@@ -0,0 +1,204 @@
+repository = $this->app->make(OrganizerRepository::class);
+
+ $user = User::factory()->withAccount()->create();
+ $this->accountId = $user->accounts()->first()->id;
+
+ $now = now()->toDateTimeString();
+
+ $this->organizerId = DB::table('organizers')->insertGetId([
+ 'account_id' => $this->accountId,
+ 'name' => 'Stats Organizer',
+ 'email' => 'stats-organizer@example.test',
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->eventAId = DB::table('events')->insertGetId([
+ 'title' => 'Event A',
+ 'account_id' => $this->accountId,
+ 'user_id' => $user->id,
+ 'organizer_id' => $this->organizerId,
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'short_id' => 'evt_a_'.uniqid(),
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->eventBId = DB::table('events')->insertGetId([
+ 'title' => 'Event B',
+ 'account_id' => $this->accountId,
+ 'user_id' => $user->id,
+ 'organizer_id' => $this->organizerId,
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'short_id' => 'evt_b_'.uniqid(),
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->occurrenceAId = DB::table('event_occurrences')->insertGetId([
+ 'short_id' => 'occ_a_'.uniqid(),
+ 'event_id' => $this->eventAId,
+ 'start_date' => now()->addDay()->toDateTimeString(),
+ 'end_date' => now()->addDay()->addHours(2)->toDateTimeString(),
+ 'status' => 'ACTIVE',
+ 'used_capacity' => 0,
+ 'is_overridden' => false,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->occurrenceBId = DB::table('event_occurrences')->insertGetId([
+ 'short_id' => 'occ_b_'.uniqid(),
+ 'event_id' => $this->eventBId,
+ 'start_date' => now()->addDays(2)->toDateTimeString(),
+ 'end_date' => now()->addDays(2)->addHours(2)->toDateTimeString(),
+ 'status' => 'ACTIVE',
+ 'used_capacity' => 0,
+ 'is_overridden' => false,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+
+ public function test_get_organizer_stats_returns_daily_breakdown_and_aggregates_across_date_window(): void
+ {
+ $today = Carbon::now()->startOfDay();
+ $rangeStart = (clone $today)->subDays(59);
+
+ $rows = [];
+ for ($i = 0; $i < 60; $i++) {
+ $date = (clone $rangeStart)->addDays($i)->toDateString();
+
+ $rows[] = $this->dailyRow(
+ eventId: $this->eventAId,
+ occurrenceId: $this->occurrenceAId,
+ date: $date,
+ productsSold: 2,
+ attendeesRegistered: 3,
+ salesGross: 10.00,
+ ordersCreated: 1,
+ totalRefunded: 1.50,
+ );
+ $rows[] = $this->dailyRow(
+ eventId: $this->eventBId,
+ occurrenceId: $this->occurrenceBId,
+ date: $date,
+ productsSold: 5,
+ attendeesRegistered: 7,
+ salesGross: 25.00,
+ ordersCreated: 2,
+ totalRefunded: 0.50,
+ );
+ }
+ DB::table('event_occurrence_daily_statistics')->insert($rows);
+
+ $endDate = (clone $today)->format('Y-m-d H:i:s');
+ $startDate = (clone $today)->subDays(29)->format('Y-m-d H:i:s');
+
+ $stats = $this->repository->getOrganizerStats(
+ organizerId: $this->organizerId,
+ accountId: $this->accountId,
+ currencyCode: 'USD',
+ startDate: $startDate,
+ endDate: $endDate,
+ );
+
+ $this->assertCount(30, $stats->daily_stats);
+ $stats->daily_stats->each(function ($row) {
+ $this->assertInstanceOf(OrganizerDailyStatsResponseDTO::class, $row);
+ });
+
+ $firstDay = $stats->daily_stats->first();
+ $this->assertSame(7, $firstDay->products_sold);
+ $this->assertSame(10, $firstDay->attendees_registered);
+ $this->assertSame(35.0, $firstDay->total_sales_gross);
+ $this->assertSame(3, $firstDay->orders_created);
+ $this->assertSame(2.0, $firstDay->total_refunded);
+
+ $this->assertSame(7 * 30, $stats->total_products_sold);
+ $this->assertSame(10 * 30, $stats->total_attendees_registered);
+ $this->assertSame(3 * 30, $stats->total_orders);
+ $this->assertEqualsWithDelta(35.0 * 30, $stats->total_gross_sales, 0.001);
+ $this->assertEqualsWithDelta(2.0 * 30, $stats->total_refunded, 0.001);
+
+ $this->assertSame('USD', $stats->currency_code);
+ $this->assertSame($startDate, $stats->start_date);
+ $this->assertSame($endDate, $stats->end_date);
+
+ $this->assertContains('USD', $stats->all_organizers_currencies);
+ }
+
+ /**
+ * @return array
+ */
+ private function dailyRow(
+ int $eventId,
+ int $occurrenceId,
+ string $date,
+ int $productsSold,
+ int $attendeesRegistered,
+ float $salesGross,
+ int $ordersCreated,
+ float $totalRefunded,
+ ): array {
+ $now = now()->toDateTimeString();
+
+ return [
+ 'event_id' => $eventId,
+ 'event_occurrence_id' => $occurrenceId,
+ 'date' => $date,
+ 'products_sold' => $productsSold,
+ 'attendees_registered' => $attendeesRegistered,
+ 'sales_total_gross' => $salesGross,
+ 'sales_total_before_additions' => $salesGross,
+ 'total_tax' => 0,
+ 'total_fee' => 0,
+ 'orders_created' => $ordersCreated,
+ 'orders_cancelled' => 0,
+ 'total_refunded' => $totalRefunded,
+ 'version' => 0,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ];
+ }
+}
diff --git a/backend/tests/Feature/Repository/Eloquent/ProductRepositoryTest.php b/backend/tests/Feature/Repository/Eloquent/ProductRepositoryTest.php
new file mode 100644
index 0000000000..f2c5084110
--- /dev/null
+++ b/backend/tests/Feature/Repository/Eloquent/ProductRepositoryTest.php
@@ -0,0 +1,169 @@
+repository = $this->app->make(ProductRepository::class);
+
+ $user = User::factory()->withAccount()->create();
+ $accountId = $user->accounts()->first()->id;
+
+ $now = now()->toDateTimeString();
+
+ $organizerId = DB::table('organizers')->insertGetId([
+ 'account_id' => $accountId,
+ 'name' => 'Test Organizer',
+ 'email' => 'organizer@example.test',
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->eventId = DB::table('events')->insertGetId([
+ 'title' => 'Test Event',
+ 'account_id' => $accountId,
+ 'user_id' => $user->id,
+ 'organizer_id' => $organizerId,
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'short_id' => 'test_evt_'.uniqid(),
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->productId = DB::table('products')->insertGetId([
+ 'title' => 'Test Product',
+ 'event_id' => $this->eventId,
+ 'order' => 1,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->productPriceId = DB::table('product_prices')->insertGetId([
+ 'product_id' => $this->productId,
+ 'price' => 10.00,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+
+ public function test_returns_false_when_product_has_no_orders(): void
+ {
+ $this->assertFalse($this->repository->hasAssociatedOrders($this->productId));
+ }
+
+ public function test_unexpired_reserved_order_blocks_deletion(): void
+ {
+ $this->insertOrderWithItem(
+ status: OrderStatus::RESERVED->name,
+ reservedUntil: now()->addMinutes(15),
+ );
+
+ $this->assertTrue($this->repository->hasAssociatedOrders($this->productId));
+ }
+
+ public function test_expired_reserved_order_does_not_block_deletion(): void
+ {
+ $this->insertOrderWithItem(
+ status: OrderStatus::RESERVED->name,
+ reservedUntil: now()->subMinute(),
+ );
+
+ $this->assertFalse($this->repository->hasAssociatedOrders($this->productId));
+ }
+
+ public function test_soft_deleted_reserved_order_does_not_block_deletion(): void
+ {
+ $this->insertOrderWithItem(
+ status: OrderStatus::RESERVED->name,
+ reservedUntil: now()->addMinutes(15),
+ deletedAt: now(),
+ );
+
+ $this->assertFalse($this->repository->hasAssociatedOrders($this->productId));
+ }
+
+ public function test_soft_deleted_completed_order_blocks_deletion(): void
+ {
+ $this->insertOrderWithItem(
+ status: OrderStatus::COMPLETED->name,
+ reservedUntil: now()->subHour(),
+ deletedAt: now(),
+ );
+
+ $this->assertTrue($this->repository->hasAssociatedOrders($this->productId));
+ }
+
+ public function test_completed_order_blocks_deletion(): void
+ {
+ $this->insertOrderWithItem(
+ status: OrderStatus::COMPLETED->name,
+ reservedUntil: now()->subHour(),
+ );
+
+ $this->assertTrue($this->repository->hasAssociatedOrders($this->productId));
+ }
+
+ public function test_abandoned_order_does_not_block_deletion(): void
+ {
+ $this->insertOrderWithItem(
+ status: OrderStatus::ABANDONED->name,
+ reservedUntil: now()->addHour(),
+ );
+
+ $this->assertFalse($this->repository->hasAssociatedOrders($this->productId));
+ }
+
+ private function insertOrderWithItem(
+ string $status,
+ DateTimeInterface $reservedUntil,
+ ?DateTimeInterface $deletedAt = null,
+ ): void {
+ $orderId = DB::table('orders')->insertGetId([
+ 'short_id' => 'ord_'.uniqid(),
+ 'event_id' => $this->eventId,
+ 'currency' => 'USD',
+ 'status' => $status,
+ 'reserved_until' => $reservedUntil,
+ 'deleted_at' => $deletedAt,
+ 'public_id' => 'pub_'.uniqid(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ DB::table('order_items')->insert([
+ 'order_id' => $orderId,
+ 'product_id' => $this->productId,
+ 'product_price_id' => $this->productPriceId,
+ 'quantity' => 1,
+ 'price' => 10.00,
+ 'total_before_additions' => 10.00,
+ ]);
+ }
+}
diff --git a/backend/tests/Feature/Repository/Fixtures/WidgetCategoryDomainObject.php b/backend/tests/Feature/Repository/Fixtures/WidgetCategoryDomainObject.php
new file mode 100644
index 0000000000..c71558aec2
--- /dev/null
+++ b/backend/tests/Feature/Repository/Fixtures/WidgetCategoryDomainObject.php
@@ -0,0 +1,65 @@
+id = $id;
+
+ return $this;
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function setName(?string $name): self
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ public function getName(): ?string
+ {
+ return $this->name;
+ }
+
+ public function setWidgets(?Collection $widgets): self
+ {
+ $this->widgets = $widgets;
+
+ return $this;
+ }
+
+ public function getWidgets(): ?Collection
+ {
+ return $this->widgets;
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'name' => $this->name,
+ ];
+ }
+}
diff --git a/backend/tests/Feature/Repository/Fixtures/WidgetCategoryModel.php b/backend/tests/Feature/Repository/Fixtures/WidgetCategoryModel.php
new file mode 100644
index 0000000000..08c57c97f5
--- /dev/null
+++ b/backend/tests/Feature/Repository/Fixtures/WidgetCategoryModel.php
@@ -0,0 +1,23 @@
+hasMany(WidgetModel::class, 'category_id');
+ }
+}
diff --git a/backend/tests/Feature/Repository/Fixtures/WidgetCategoryRepository.php b/backend/tests/Feature/Repository/Fixtures/WidgetCategoryRepository.php
new file mode 100644
index 0000000000..ff7f7520ed
--- /dev/null
+++ b/backend/tests/Feature/Repository/Fixtures/WidgetCategoryRepository.php
@@ -0,0 +1,23 @@
+
+ */
+class WidgetCategoryRepository extends BaseRepository
+{
+ protected function getModel(): string
+ {
+ return WidgetCategoryModel::class;
+ }
+
+ public function getDomainObject(): string
+ {
+ return WidgetCategoryDomainObject::class;
+ }
+}
diff --git a/backend/tests/Feature/Repository/Fixtures/WidgetDomainObject.php b/backend/tests/Feature/Repository/Fixtures/WidgetDomainObject.php
new file mode 100644
index 0000000000..c673e4470a
--- /dev/null
+++ b/backend/tests/Feature/Repository/Fixtures/WidgetDomainObject.php
@@ -0,0 +1,199 @@
+id = $id;
+
+ return $this;
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function setCategoryId(?int $category_id): self
+ {
+ $this->category_id = $category_id;
+
+ return $this;
+ }
+
+ public function getCategoryId(): ?int
+ {
+ return $this->category_id;
+ }
+
+ public function setName(?string $name): self
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ public function getName(): ?string
+ {
+ return $this->name;
+ }
+
+ public function setSku(?string $sku): self
+ {
+ $this->sku = $sku;
+
+ return $this;
+ }
+
+ public function getSku(): ?string
+ {
+ return $this->sku;
+ }
+
+ public function setQuantity(?int $quantity): self
+ {
+ $this->quantity = $quantity;
+
+ return $this;
+ }
+
+ public function getQuantity(): ?int
+ {
+ return $this->quantity;
+ }
+
+ public function setPrice(float|int|null $price): self
+ {
+ $this->price = $price === null ? null : (float) $price;
+
+ return $this;
+ }
+
+ public function getPrice(): ?float
+ {
+ return $this->price;
+ }
+
+ public function setIsActive(?bool $is_active): self
+ {
+ $this->is_active = $is_active;
+
+ return $this;
+ }
+
+ public function getIsActive(): ?bool
+ {
+ return $this->is_active;
+ }
+
+ public function setDescription(?string $description): self
+ {
+ $this->description = $description;
+
+ return $this;
+ }
+
+ public function getDescription(): ?string
+ {
+ return $this->description;
+ }
+
+ public function setCreatedAt(?string $created_at): self
+ {
+ $this->created_at = $created_at;
+
+ return $this;
+ }
+
+ public function getCreatedAt(): ?string
+ {
+ return $this->created_at;
+ }
+
+ public function setUpdatedAt(?string $updated_at): self
+ {
+ $this->updated_at = $updated_at;
+
+ return $this;
+ }
+
+ public function getUpdatedAt(): ?string
+ {
+ return $this->updated_at;
+ }
+
+ public function setDeletedAt(?string $deleted_at): self
+ {
+ $this->deleted_at = $deleted_at;
+
+ return $this;
+ }
+
+ public function getDeletedAt(): ?string
+ {
+ return $this->deleted_at;
+ }
+
+ public function setCategory(?WidgetCategoryDomainObject $category): self
+ {
+ $this->category = $category;
+
+ return $this;
+ }
+
+ public function getCategory(): ?WidgetCategoryDomainObject
+ {
+ return $this->category;
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'id' => $this->id,
+ 'category_id' => $this->category_id,
+ 'name' => $this->name,
+ 'sku' => $this->sku,
+ 'quantity' => $this->quantity,
+ 'price' => $this->price,
+ 'is_active' => $this->is_active,
+ 'description' => $this->description,
+ 'created_at' => $this->created_at,
+ 'updated_at' => $this->updated_at,
+ 'deleted_at' => $this->deleted_at,
+ ];
+ }
+}
diff --git a/backend/tests/Feature/Repository/Fixtures/WidgetModel.php b/backend/tests/Feature/Repository/Fixtures/WidgetModel.php
new file mode 100644
index 0000000000..387a657ef3
--- /dev/null
+++ b/backend/tests/Feature/Repository/Fixtures/WidgetModel.php
@@ -0,0 +1,43 @@
+ 'boolean',
+ 'quantity' => 'integer',
+ 'price' => 'float',
+ ];
+ }
+
+ public function category(): BelongsTo
+ {
+ return $this->belongsTo(WidgetCategoryModel::class, 'category_id');
+ }
+}
diff --git a/backend/tests/Feature/Repository/Fixtures/WidgetRepository.php b/backend/tests/Feature/Repository/Fixtures/WidgetRepository.php
new file mode 100644
index 0000000000..16bab70d81
--- /dev/null
+++ b/backend/tests/Feature/Repository/Fixtures/WidgetRepository.php
@@ -0,0 +1,35 @@
+
+ */
+class WidgetRepository extends BaseRepository
+{
+ protected function getModel(): string
+ {
+ return WidgetModel::class;
+ }
+
+ public function getDomainObject(): string
+ {
+ return WidgetDomainObject::class;
+ }
+
+ public function exposeEagerLoads(): array
+ {
+ return $this->eagerLoads;
+ }
+
+ public function exposeBuilderHasWheres(): bool
+ {
+ $base = $this->model->getQuery();
+
+ return ! empty($base->wheres);
+ }
+}
diff --git a/backend/tests/Feature/Services/Domain/Event/DuplicateEventServiceTest.php b/backend/tests/Feature/Services/Domain/Event/DuplicateEventServiceTest.php
new file mode 100644
index 0000000000..d87f2defb1
--- /dev/null
+++ b/backend/tests/Feature/Services/Domain/Event/DuplicateEventServiceTest.php
@@ -0,0 +1,111 @@
+withAccount()->create();
+ $this->actingAs($user);
+ $accountId = $user->accounts()->first()->id;
+ $now = now()->toDateTimeString();
+
+ $organizerId = DB::table('organizers')->insertGetId([
+ 'account_id' => $accountId,
+ 'name' => 'Duplicate Organizer',
+ 'email' => 'duplicate-organizer@example.test',
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ DB::table('organizer_settings')->insert([
+ 'organizer_id' => $organizerId,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $eventId = DB::table('events')->insertGetId([
+ 'title' => 'Recurring source event',
+ 'status' => 'DRAFT',
+ 'type' => 'RECURRING',
+ 'recurrence_rule' => json_encode([
+ 'range' => ['type' => 'count', 'count' => 2, 'start' => now()->utc()->toDateString()],
+ 'interval' => 1,
+ 'frequency' => 'weekly',
+ 'days_of_week' => ['monday'],
+ 'times_of_day' => ['19:00'],
+ ]),
+ 'account_id' => $accountId,
+ 'user_id' => $user->id,
+ 'organizer_id' => $organizerId,
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'short_id' => 'evt_'.uniqid(),
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $pastTodayStart = now()->utc()->startOfDay()->addSecond();
+ $futureStart = now()->utc()->addDay();
+
+ $this->insertOccurrence($eventId, $pastTodayStart->toDateTimeString());
+ $this->insertOccurrence($eventId, $futureStart->toDateTimeString());
+
+ $newEvent = $this->app->make(DuplicateEventService::class)->duplicateEvent(
+ eventId: (string) $eventId,
+ accountId: (string) $accountId,
+ title: 'Duplicated event',
+ startDate: now()->utc()->addDays(10)->toDateTimeString(),
+ duplicateProducts: false,
+ duplicateQuestions: false,
+ duplicateSettings: false,
+ duplicatePromoCodes: false,
+ duplicateCapacityAssignments: false,
+ duplicateCheckInLists: false,
+ duplicateEventCoverImage: false,
+ duplicateTicketLogo: false,
+ duplicateWebhooks: false,
+ duplicateAffiliates: false,
+ );
+
+ $clonedStartDates = DB::table('event_occurrences')
+ ->where('event_id', $newEvent->getId())
+ ->whereNull('deleted_at')
+ ->pluck('start_date')
+ ->map(fn ($date) => (string) $date)
+ ->all();
+
+ $this->assertContains($futureStart->toDateTimeString(), $clonedStartDates, 'The future occurrence must be cloned');
+ $this->assertNotContains($pastTodayStart->toDateTimeString(), $clonedStartDates, 'A same-UTC-day but already-past occurrence must not be cloned');
+ }
+
+ private function insertOccurrence(int $eventId, string $startDate): void
+ {
+ $now = now()->toDateTimeString();
+
+ DB::table('event_occurrences')->insert([
+ 'short_id' => 'occ_'.uniqid(),
+ 'event_id' => $eventId,
+ 'start_date' => $startDate,
+ 'end_date' => null,
+ 'status' => 'ACTIVE',
+ 'used_capacity' => 0,
+ 'is_overridden' => false,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+}
diff --git a/backend/tests/Feature/Services/Domain/Event/EventOccurrenceGeneratorServiceTest.php b/backend/tests/Feature/Services/Domain/Event/EventOccurrenceGeneratorServiceTest.php
new file mode 100644
index 0000000000..d5982e73cb
--- /dev/null
+++ b/backend/tests/Feature/Services/Domain/Event/EventOccurrenceGeneratorServiceTest.php
@@ -0,0 +1,120 @@
+generator = $this->app->make(EventOccurrenceGeneratorService::class);
+
+ $user = User::factory()->withAccount()->create();
+ $accountId = $user->accounts()->first()->id;
+ $now = now()->toDateTimeString();
+
+ $organizerId = DB::table('organizers')->insertGetId([
+ 'account_id' => $accountId,
+ 'name' => 'Generator Organizer',
+ 'email' => 'generator@example.test',
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->eventId = DB::table('events')->insertGetId([
+ 'title' => 'Generator Event',
+ 'account_id' => $accountId,
+ 'user_id' => $user->id,
+ 'organizer_id' => $organizerId,
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'short_id' => 'evt_'.uniqid(),
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+
+ private function weeklyRule(int $count = 3): array
+ {
+ return [
+ 'frequency' => 'weekly',
+ 'interval' => 1,
+ 'days_of_week' => ['monday'],
+ 'range' => ['type' => 'count', 'count' => $count, 'start' => '2030-06-03'],
+ 'times_of_day' => ['19:00'],
+ ];
+ }
+
+ private function event(): EventDomainObject
+ {
+ return $this->app
+ ->make(EventRepositoryInterface::class)
+ ->findById($this->eventId);
+ }
+
+ private function liveOccurrences(): array
+ {
+ return DB::table('event_occurrences')
+ ->where('event_id', $this->eventId)
+ ->whereNull('deleted_at')
+ ->orderBy('start_date')
+ ->get()
+ ->all();
+ }
+
+ public function test_regenerating_the_same_rule_is_idempotent(): void
+ {
+ $this->generator->generate($this->event(), $this->weeklyRule());
+ $firstRun = $this->liveOccurrences();
+ $this->assertCount(3, $firstRun);
+
+ $this->generator->generate($this->event(), $this->weeklyRule());
+ $secondRun = $this->liveOccurrences();
+
+ $this->assertCount(3, $secondRun, 'Re-running an identical rule must not create new occurrences');
+ $this->assertSame(
+ array_column($firstRun, 'id'),
+ array_column($secondRun, 'id'),
+ 'Re-running an identical rule must keep the same occurrence ids'
+ );
+ $this->assertSame(
+ 0,
+ DB::table('event_occurrences')->where('event_id', $this->eventId)->whereNotNull('deleted_at')->count(),
+ 'Re-running an identical rule must not soft-delete any occurrence'
+ );
+ }
+
+ public function test_extending_a_rule_keeps_matching_occurrences_and_appends_new_ones(): void
+ {
+ $this->generator->generate($this->event(), $this->weeklyRule(count: 3));
+ $originalIds = array_column($this->liveOccurrences(), 'id');
+
+ $this->generator->generate($this->event(), $this->weeklyRule(count: 5));
+ $extended = $this->liveOccurrences();
+
+ $this->assertCount(5, $extended);
+ $this->assertSame(
+ $originalIds,
+ array_slice(array_column($extended, 'id'), 0, 3),
+ 'Existing occurrences matching the new rule must keep their ids'
+ );
+ }
+}
diff --git a/backend/tests/Feature/Services/Domain/EventLocation/EventLocationCanaryTest.php b/backend/tests/Feature/Services/Domain/EventLocation/EventLocationCanaryTest.php
new file mode 100644
index 0000000000..398686d696
--- /dev/null
+++ b/backend/tests/Feature/Services/Domain/EventLocation/EventLocationCanaryTest.php
@@ -0,0 +1,345 @@
+eventRepo = $this->app->make(EventRepositoryInterface::class);
+ $this->occurrenceRepo = $this->app->make(EventOccurrenceRepositoryInterface::class);
+ $this->eventLocationRepo = $this->app->make(EventLocationRepositoryInterface::class);
+
+ $user = User::factory()->withAccount()->create();
+ $this->userId = $user->id;
+ $this->accountId = $user->accounts()->first()->id;
+
+ $now = now()->toDateTimeString();
+
+ $this->organizerId = DB::table('organizers')->insertGetId([
+ 'account_id' => $this->accountId,
+ 'name' => 'Canary Organizer',
+ 'email' => 'canary@example.test',
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+
+ public function test_event_in_person_default_resolves_with_venue_via_event_location_relation(): void
+ {
+ $venueId = $this->createVenue('Main Venue', ['city' => 'Dublin']);
+ $eventLocationId = $this->createEventLocation(
+ eventId: $eventId = $this->createEvent('In-Person Event'),
+ type: LocationType::IN_PERSON,
+ locationId: $venueId,
+ );
+ $this->linkEventLocationToEvent($eventId, $eventLocationId);
+
+ $event = $this->eventRepo
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]))
+ ->findFirstWhere(['id' => $eventId]);
+
+ $payload = (new EventResource($event))->toArray(new Request);
+
+ $this->assertArrayHasKey('event_location', $payload);
+ $eventLocation = $payload['event_location']->toArray(new Request);
+ $this->assertSame(LocationType::IN_PERSON->name, $eventLocation['type']);
+ $this->assertArrayHasKey('location', $eventLocation);
+ $nestedLocation = $eventLocation['location']->toArray(new Request);
+ $this->assertSame($venueId, $nestedLocation['id']);
+ }
+
+ public function test_occurrence_inherits_event_location_when_null(): void
+ {
+ $venueId = $this->createVenue('Event Venue');
+ $eventId = $this->createEvent('Event with Inherited Occurrence');
+ $eventLocationId = $this->createEventLocation($eventId, LocationType::IN_PERSON, $venueId);
+ $this->linkEventLocationToEvent($eventId, $eventLocationId);
+
+ $occurrenceId = $this->createOccurrence($eventId, eventLocationId: null);
+
+ $occurrence = $this->occurrenceRepo
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]))
+ ->findFirstWhere([EventOccurrenceDomainObjectAbstract::ID => $occurrenceId]);
+
+ $payload = (new EventOccurrenceResource($occurrence))->toArray(new Request);
+
+ $this->assertTrue(
+ ! array_key_exists('event_location', $payload)
+ || $payload['event_location'] instanceof MissingValue,
+ 'Inheriting occurrence must not carry its own event_location on the resource',
+ );
+ }
+
+ public function test_occurrence_in_person_override_returned_in_resource(): void
+ {
+ $eventVenueId = $this->createVenue('Event Default Venue');
+ $overrideVenueId = $this->createVenue('Override Venue');
+ $eventId = $this->createEvent('Event with Override Occurrence');
+
+ $eventEventLocationId = $this->createEventLocation($eventId, LocationType::IN_PERSON, $eventVenueId);
+ $this->linkEventLocationToEvent($eventId, $eventEventLocationId);
+
+ $overrideEventLocationId = $this->createEventLocation($eventId, LocationType::IN_PERSON, $overrideVenueId);
+ $occurrenceId = $this->createOccurrence($eventId, eventLocationId: $overrideEventLocationId);
+
+ $occurrence = $this->occurrenceRepo
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]))
+ ->findFirstWhere([EventOccurrenceDomainObjectAbstract::ID => $occurrenceId]);
+
+ $payload = (new EventOccurrenceResource($occurrence))->toArray(new Request);
+
+ $this->assertArrayHasKey('event_location', $payload);
+ $embed = $payload['event_location']->toArray(new Request);
+ $this->assertSame(LocationType::IN_PERSON->name, $embed['type']);
+ $this->assertSame($overrideVenueId, $embed['location']->toArray(new Request)['id']);
+ }
+
+ public function test_online_event_default_with_online_occurrence_override(): void
+ {
+ $eventId = $this->createEvent('Online Event');
+
+ $eventEventLocationId = $this->createEventLocation(
+ eventId: $eventId,
+ type: LocationType::ONLINE,
+ onlineDetails: 'Event default Zoom
',
+ );
+ $this->linkEventLocationToEvent($eventId, $eventEventLocationId);
+
+ $occurrenceEventLocationId = $this->createEventLocation(
+ eventId: $eventId,
+ type: LocationType::ONLINE,
+ onlineDetails: 'Occurrence override Zoom
',
+ );
+ $occurrenceId = $this->createOccurrence($eventId, eventLocationId: $occurrenceEventLocationId);
+
+ $event = $this->eventRepo
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location'))
+ ->findFirstWhere(['id' => $eventId]);
+
+ $eventPayload = (new EventResource($event))->toArray(new Request);
+ $eventEmbed = $eventPayload['event_location']->toArray(new Request);
+ $this->assertSame(LocationType::ONLINE->name, $eventEmbed['type']);
+ $this->assertSame('Event default Zoom
', $eventEmbed['online_event_connection_details']);
+
+ $occurrence = $this->occurrenceRepo
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location'))
+ ->findFirstWhere([EventOccurrenceDomainObjectAbstract::ID => $occurrenceId]);
+
+ $occPayload = (new EventOccurrenceResource($occurrence))->toArray(new Request);
+ $occEmbed = $occPayload['event_location']->toArray(new Request);
+ $this->assertSame(LocationType::ONLINE->name, $occEmbed['type']);
+ $this->assertSame('Occurrence override Zoom
', $occEmbed['online_event_connection_details']);
+ }
+
+ public function test_clear_event_location_on_occurrence_orphans_cleaned_up(): void
+ {
+ $venueId = $this->createVenue('Override Venue');
+ $eventId = $this->createEvent('Event with Cleanable Override');
+ $occurrenceEventLocationId = $this->createEventLocation($eventId, LocationType::IN_PERSON, $venueId);
+ $occurrenceId = $this->createOccurrence($eventId, eventLocationId: $occurrenceEventLocationId);
+
+ $this->assertDatabaseHas('event_locations', [
+ 'id' => $occurrenceEventLocationId,
+ 'deleted_at' => null,
+ ]);
+
+ $handler = $this->app->make(UpdateEventOccurrenceHandler::class);
+ $handler->handle(
+ $occurrenceId,
+ new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: now()->addDay()->toDateTimeString(),
+ end_date: now()->addDay()->addHours(2)->toDateTimeString(),
+ clear_event_location: true,
+ ),
+ );
+
+ $occurrence = $this->occurrenceRepo->findFirstWhere([EventOccurrenceDomainObjectAbstract::ID => $occurrenceId]);
+ $this->assertNull($occurrence->getEventLocationId());
+
+ $stillAlive = DB::table('event_locations')
+ ->where('id', $occurrenceEventLocationId)
+ ->whereNull('deleted_at')
+ ->exists();
+ $this->assertFalse($stillAlive, 'Orphaned event_location row should be soft-deleted by EventLocationCleaner');
+ }
+
+ public function test_public_event_resource_hides_internal_location_fields(): void
+ {
+ $venueId = $this->createVenue('Public Venue', ['city' => 'Dublin']);
+ DB::table('locations')->where('id', $venueId)->update([
+ 'provider' => 'google',
+ 'provider_place_id' => 'ChIJsecretplaceid',
+ ]);
+ $eventId = $this->createEvent('In-Person Public Event');
+ $eventLocationId = $this->createEventLocation($eventId, LocationType::IN_PERSON, $venueId);
+ $this->linkEventLocationToEvent($eventId, $eventLocationId);
+
+ $event = $this->eventRepo
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]))
+ ->findFirstWhere(['id' => $eventId]);
+
+ $payload = (new EventResourcePublic($event, false))->toArray(new Request);
+ $eventLocation = $payload['event_location']->toArray(new Request);
+ $location = $eventLocation['location']->toArray(new Request);
+
+ $this->assertSame('Public Venue', $location['name']);
+ $this->assertSame('Dublin', $location['structured_address']['city']);
+
+ foreach (['id', 'organizer_id', 'provider', 'provider_place_id', 'created_at', 'updated_at'] as $sensitive) {
+ $this->assertArrayNotHasKey($sensitive, $location, "$sensitive must not appear in the public location payload");
+ }
+ }
+
+ public function test_public_event_resource_hides_online_connection_details_pre_checkout(): void
+ {
+ $eventId = $this->createEvent('Online Public Event');
+ $eventLocationId = $this->createEventLocation(
+ eventId: $eventId,
+ type: LocationType::ONLINE,
+ onlineDetails: 'Secret Zoom: https://zoom.example/secret
',
+ );
+ $this->linkEventLocationToEvent($eventId, $eventLocationId);
+
+ $event = $this->eventRepo
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location'))
+ ->findFirstWhere(['id' => $eventId]);
+
+ $prePayload = (new EventResourcePublic($event, false))->toArray(new Request);
+ $preEmbed = $prePayload['event_location']->toArray(new Request);
+ $preAssertable = $this->resolveWhen($preEmbed);
+ $this->assertArrayNotHasKey('online_event_connection_details', $preAssertable);
+
+ $postPayload = (new EventResourcePublic($event, true))->toArray(new Request);
+ $postEmbed = $postPayload['event_location']->toArray(new Request);
+ $postAssertable = $this->resolveWhen($postEmbed);
+ $this->assertArrayHasKey('online_event_connection_details', $postAssertable);
+ $this->assertSame('Secret Zoom: https://zoom.example/secret
', $postAssertable['online_event_connection_details']);
+ }
+
+ private function resolveWhen(array $payload): array
+ {
+ return array_filter(
+ $payload,
+ fn ($value) => ! ($value instanceof MissingValue),
+ );
+ }
+
+ private function createVenue(string $name, array $structuredAddress = []): int
+ {
+ return DB::table('locations')->insertGetId([
+ 'short_id' => 'loc_'.uniqid(),
+ 'account_id' => $this->accountId,
+ 'organizer_id' => $this->organizerId,
+ 'name' => $name,
+ 'structured_address' => json_encode(array_merge(['venue_name' => $name], $structuredAddress)),
+ 'latitude' => 53.3478,
+ 'longitude' => -6.2289,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ private function createEvent(string $title): int
+ {
+ return DB::table('events')->insertGetId([
+ 'title' => $title,
+ 'account_id' => $this->accountId,
+ 'user_id' => $this->userId,
+ 'organizer_id' => $this->organizerId,
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'short_id' => 'evt_'.uniqid(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ private function createEventLocation(
+ int $eventId,
+ LocationType $type,
+ ?int $locationId = null,
+ ?string $onlineDetails = null,
+ ): int {
+ return DB::table('event_locations')->insertGetId([
+ 'short_id' => 'el_'.uniqid(),
+ 'event_id' => $eventId,
+ 'type' => $type->name,
+ 'location_id' => $locationId,
+ 'online_event_connection_details' => $onlineDetails,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ private function linkEventLocationToEvent(int $eventId, int $eventLocationId): void
+ {
+ DB::table('events')->where('id', $eventId)->update([
+ 'event_location_id' => $eventLocationId,
+ ]);
+ }
+
+ private function createOccurrence(int $eventId, ?int $eventLocationId): int
+ {
+ return DB::table('event_occurrences')->insertGetId([
+ 'short_id' => 'occ_'.uniqid(),
+ 'event_id' => $eventId,
+ 'event_location_id' => $eventLocationId,
+ 'start_date' => now()->addDay()->toDateTimeString(),
+ 'end_date' => now()->addDay()->addHours(2)->toDateTimeString(),
+ 'status' => 'ACTIVE',
+ 'used_capacity' => 0,
+ 'is_overridden' => false,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+}
diff --git a/backend/tests/Feature/Services/Domain/Order/OrderApplicationFeeFlowTest.php b/backend/tests/Feature/Services/Domain/Order/OrderApplicationFeeFlowTest.php
new file mode 100644
index 0000000000..8e6eab0a3b
--- /dev/null
+++ b/backend/tests/Feature/Services/Domain/Order/OrderApplicationFeeFlowTest.php
@@ -0,0 +1,309 @@
+calculationService = $this->app->make(OrderApplicationFeeCalculationService::class);
+ $this->feeService = $this->app->make(OrderApplicationFeeService::class);
+ $this->feeRepository = $this->app->make(OrderApplicationFeeRepositoryInterface::class);
+ $this->orderRepository = $this->app->make(OrderRepositoryInterface::class);
+ $this->eventRepository = $this->app->make(EventRepositoryInterface::class);
+
+ $now = now()->toDateTimeString();
+
+ $user = User::factory()->withAccount()->create();
+ $this->accountId = $user->accounts()->first()->id;
+
+ $this->organizerConfigurationId = DB::table('organizer_configurations')->insertGetId([
+ 'name' => 'Test Fee Config',
+ 'is_system_default' => false,
+ 'application_fees' => json_encode([
+ 'fixed' => 1.00,
+ 'percentage' => 10,
+ 'currency' => 'USD',
+ ], JSON_THROW_ON_ERROR),
+ 'bypass_application_fees' => false,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $organizerId = DB::table('organizers')->insertGetId([
+ 'account_id' => $this->accountId,
+ 'name' => 'Test Organizer',
+ 'email' => 'organizer@example.test',
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'organizer_configuration_id' => $this->organizerConfigurationId,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->eventId = DB::table('events')->insertGetId([
+ 'title' => 'Test Event',
+ 'account_id' => $this->accountId,
+ 'user_id' => $user->id,
+ 'organizer_id' => $organizerId,
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'short_id' => 'test_evt_'.uniqid(),
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->productId = DB::table('products')->insertGetId([
+ 'title' => 'Test Product',
+ 'event_id' => $this->eventId,
+ 'order' => 1,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ $this->productPriceId = DB::table('product_prices')->insertGetId([
+ 'product_id' => $this->productId,
+ 'price' => 25.00,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+ }
+
+ public function test_application_fee_recorded_exactly_once_on_completion(): void
+ {
+ $orderId = $this->insertCompletedOrder(price: 25.00, quantity: 2, totalGross: 50.00);
+
+ $this->recordApplicationFeeAsMarkOrderAsPaidWould($orderId);
+
+ $fees = $this->feeRepository->findWhere([
+ OrderApplicationFeeDomainObjectAbstract::ORDER_ID => $orderId,
+ ]);
+
+ $this->assertCount(1, $fees, 'Exactly one application fee row must be recorded on completion');
+
+ /** @var OrderApplicationFeeDomainObject $fee */
+ $fee = $fees->first();
+
+ $this->assertEqualsWithDelta(7.00, $fee->getAmount(), 0.001, 'Recorded fee amount must be 7.00 (fixed 1.00 x 2 + 10% of 50.00)');
+ $this->assertSame('USD', $fee->getCurrency());
+ $this->assertSame(OrderApplicationFeeStatus::AWAITING_PAYMENT->value, $fee->getStatus());
+ $this->assertSame(PaymentProviders::OFFLINE->value, $fee->getPaymentMethod());
+
+ $this->assertSame(
+ 1,
+ $this->liveFeeRowCount($orderId),
+ 'Exactly one non-deleted order_application_fees row must exist for the order',
+ );
+ }
+
+ public function test_free_order_records_zero_fee(): void
+ {
+ $orderId = $this->insertCompletedOrder(price: 0.00, quantity: 3, totalGross: 0.00);
+
+ $this->recordApplicationFeeAsMarkOrderAsPaidWould($orderId);
+
+ $fees = $this->feeRepository->findWhere([
+ OrderApplicationFeeDomainObjectAbstract::ORDER_ID => $orderId,
+ ]);
+
+ $this->assertCount(1, $fees);
+
+ /** @var OrderApplicationFeeDomainObject $fee */
+ $fee = $fees->first();
+
+ $this->assertEqualsWithDelta(0.00, $fee->getAmount(), 0.001);
+ $this->assertSame('USD', $fee->getCurrency());
+ }
+
+ public function test_re_recording_after_completion_does_not_create_a_second_fee_row_when_guarded_by_status(): void
+ {
+ $orderId = $this->insertCompletedOrder(price: 25.00, quantity: 2, totalGross: 50.00);
+
+ $this->recordApplicationFeeAsMarkOrderAsPaidWould($orderId);
+
+ $this->recordApplicationFeeIfStillAwaitingOfflinePayment($orderId);
+
+ $this->assertSame(
+ 1,
+ $this->liveFeeRowCount($orderId),
+ 'A second completion attempt must not create a duplicate fee row',
+ );
+ }
+
+ public function test_refund_does_not_create_a_duplicate_or_negative_fee_row(): void
+ {
+ $orderId = $this->insertCompletedOrder(price: 25.00, quantity: 2, totalGross: 50.00);
+
+ $this->recordApplicationFeeAsMarkOrderAsPaidWould($orderId);
+
+ $this->orderRepository->updateFromArray($orderId, [
+ 'status' => OrderStatus::CANCELLED->name,
+ 'refund_status' => 'REFUNDED',
+ 'total_refunded' => 50.00,
+ ]);
+
+ $this->assertSame(
+ 1,
+ $this->liveFeeRowCount($orderId),
+ 'A refund must not add another application fee row',
+ );
+
+ $remaining = $this->feeRepository->findWhere([
+ OrderApplicationFeeDomainObjectAbstract::ORDER_ID => $orderId,
+ ]);
+
+ /** @var OrderApplicationFeeDomainObject $fee */
+ $fee = $remaining->first();
+ $this->assertGreaterThanOrEqual(0.0, $fee->getAmount(), 'Fee amount must never go negative after a refund');
+ $this->assertEqualsWithDelta(7.00, $fee->getAmount(), 0.001);
+ }
+
+ private function recordApplicationFeeAsMarkOrderAsPaidWould(int $orderId): void
+ {
+ $order = $this->loadOrderWithItems($orderId);
+ $config = $this->loadOrganizerConfigurationForOrder($order);
+
+ $this->assertInstanceOf(
+ OrganizerConfigurationDomainObject::class,
+ $config,
+ 'Organizer configuration must hydrate through the event->organizer eager-load chain',
+ );
+
+ $this->feeService->createOrderApplicationFee(
+ orderId: $order->getId(),
+ applicationFeeAmountMinorUnit: $this->calculationService->calculateApplicationFee(
+ configuration: $config,
+ order: $order,
+ )?->netApplicationFee?->toMinorUnit() ?? 0,
+ orderApplicationFeeStatus: OrderApplicationFeeStatus::AWAITING_PAYMENT,
+ paymentMethod: PaymentProviders::OFFLINE,
+ currency: $order->getCurrency(),
+ );
+ }
+
+ private function recordApplicationFeeIfStillAwaitingOfflinePayment(int $orderId): void
+ {
+ $order = $this->loadOrderWithItems($orderId);
+
+ if ($order->getStatus() !== OrderStatus::AWAITING_OFFLINE_PAYMENT->name) {
+ return;
+ }
+
+ $this->recordApplicationFeeAsMarkOrderAsPaidWould($orderId);
+ }
+
+ private function loadOrderWithItems(int $orderId): OrderDomainObject
+ {
+ /** @var OrderDomainObject $order */
+ $order = $this->orderRepository
+ ->loadRelation(OrderItemDomainObject::class)
+ ->findById($orderId);
+
+ return $order;
+ }
+
+ private function loadOrganizerConfigurationForOrder(OrderDomainObject $order): ?OrganizerConfigurationDomainObject
+ {
+ /** @var EventDomainObject $event */
+ $event = $this->eventRepository
+ ->loadRelation(new Relationship(
+ domainObject: OrganizerDomainObject::class,
+ nested: [
+ new Relationship(
+ domainObject: OrganizerConfigurationDomainObject::class,
+ name: 'organizer_configuration',
+ ),
+ ],
+ name: 'organizer',
+ ))
+ ->findById($order->getEventId());
+
+ return $event->getOrganizer()?->getOrganizerConfiguration();
+ }
+
+ private function insertCompletedOrder(float $price, int $quantity, float $totalGross): int
+ {
+ $now = now();
+
+ $orderId = DB::table('orders')->insertGetId([
+ 'short_id' => 'ord_'.uniqid(),
+ 'event_id' => $this->eventId,
+ 'currency' => 'USD',
+ 'status' => OrderStatus::COMPLETED->name,
+ 'total_gross' => $totalGross,
+ 'total_before_additions' => $totalGross,
+ 'public_id' => 'pub_'.uniqid(),
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]);
+
+ DB::table('order_items')->insert([
+ 'order_id' => $orderId,
+ 'product_id' => $this->productId,
+ 'product_price_id' => $this->productPriceId,
+ 'quantity' => $quantity,
+ 'price' => $price,
+ 'total_before_additions' => $price * $quantity,
+ ]);
+
+ return $orderId;
+ }
+
+ private function liveFeeRowCount(int $orderId): int
+ {
+ return DB::table('order_application_fees')
+ ->where('order_id', $orderId)
+ ->whereNull('deleted_at')
+ ->count();
+ }
+}
diff --git a/backend/tests/Unit/DomainObjects/Enums/StripePlatformTest.php b/backend/tests/Unit/DomainObjects/Enums/StripePlatformTest.php
index acbacfb41b..80b8930f28 100644
--- a/backend/tests/Unit/DomainObjects/Enums/StripePlatformTest.php
+++ b/backend/tests/Unit/DomainObjects/Enums/StripePlatformTest.php
@@ -26,4 +26,4 @@ public function test_get_all_values(): void
$expected = ['ca', 'ie'];
$this->assertEquals($expected, StripePlatform::getAllValues());
}
-}
\ No newline at end of file
+}
diff --git a/backend/tests/Unit/DomainObjects/Enums/TrackingPixelProviderTest.php b/backend/tests/Unit/DomainObjects/Enums/TrackingPixelProviderTest.php
index 0aa09b7670..2c918c0ca7 100644
--- a/backend/tests/Unit/DomainObjects/Enums/TrackingPixelProviderTest.php
+++ b/backend/tests/Unit/DomainObjects/Enums/TrackingPixelProviderTest.php
@@ -9,13 +9,13 @@
class TrackingPixelProviderTest extends TestCase
{
#[DataProvider('validPixelIdProvider')]
- public function testValidPixelIdsPassValidation(TrackingPixelProvider $provider, string $pixelId): void
+ public function test_valid_pixel_ids_pass_validation(TrackingPixelProvider $provider, string $pixelId): void
{
$this->assertMatchesRegularExpression($provider->pixelIdPattern(), $pixelId);
}
#[DataProvider('invalidPixelIdProvider')]
- public function testInvalidPixelIdsFailValidation(TrackingPixelProvider $provider, string $pixelId): void
+ public function test_invalid_pixel_ids_fail_validation(TrackingPixelProvider $provider, string $pixelId): void
{
$this->assertDoesNotMatchRegularExpression($provider->pixelIdPattern(), $pixelId);
}
diff --git a/backend/tests/Unit/DomainObjects/EventDomainObjectTest.php b/backend/tests/Unit/DomainObjects/EventDomainObjectTest.php
new file mode 100644
index 0000000000..55229570b5
--- /dev/null
+++ b/backend/tests/Unit/DomainObjects/EventDomainObjectTest.php
@@ -0,0 +1,354 @@
+setStartDate($startDate);
+ $occurrence->setEndDate($endDate);
+ $occurrence->setStatus($status);
+
+ return $occurrence;
+ }
+
+ private function createEvent(?Collection $occurrences = null, ?string $timezone = null): EventDomainObject
+ {
+ $event = new EventDomainObject;
+
+ if ($occurrences !== null) {
+ $event->setEventOccurrences($occurrences);
+ }
+
+ if ($timezone !== null) {
+ $event->setTimezone($timezone);
+ }
+
+ return $event;
+ }
+
+ public function test_get_start_date_returns_earliest_occurrence_start_date(): void
+ {
+ $earlier = Carbon::now()->subDays(3)->toDateTimeString();
+ $later = Carbon::now()->subDay()->toDateTimeString();
+
+ $occurrences = collect([
+ $this->createOccurrence($later),
+ $this->createOccurrence($earlier),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertEquals($earlier, $event->getStartDate());
+ }
+
+ public function test_get_start_date_returns_null_when_no_occurrences(): void
+ {
+ $event = $this->createEvent();
+ $this->assertNull($event->getStartDate());
+
+ $eventWithEmpty = $this->createEvent(collect([]));
+ $this->assertNull($eventWithEmpty->getStartDate());
+ }
+
+ public function test_get_end_date_returns_latest_occurrence_end_date(): void
+ {
+ $earlierEnd = Carbon::now()->addDay()->toDateTimeString();
+ $laterEnd = Carbon::now()->addDays(3)->toDateTimeString();
+
+ $occurrences = collect([
+ $this->createOccurrence(
+ Carbon::now()->subDay()->toDateTimeString(),
+ $earlierEnd,
+ ),
+ $this->createOccurrence(
+ Carbon::now()->toDateTimeString(),
+ $laterEnd,
+ ),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertEquals($laterEnd, $event->getEndDate());
+ }
+
+ public function test_get_end_date_falls_back_to_latest_start_date_when_no_end_dates(): void
+ {
+ $earlierStart = Carbon::now()->subDay()->toDateTimeString();
+ $laterStart = Carbon::now()->addDay()->toDateTimeString();
+
+ $occurrences = collect([
+ $this->createOccurrence($earlierStart),
+ $this->createOccurrence($laterStart),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertEquals($laterStart, $event->getEndDate());
+ }
+
+ public function test_get_end_date_returns_null_when_no_occurrences(): void
+ {
+ $event = $this->createEvent();
+ $this->assertNull($event->getEndDate());
+
+ $eventWithEmpty = $this->createEvent(collect([]));
+ $this->assertNull($eventWithEmpty->getEndDate());
+ }
+
+ public function test_is_event_ongoing_returns_true_when_active_occurrence_has_started_but_not_ended(): void
+ {
+ $occurrences = collect([
+ $this->createOccurrence(
+ Carbon::now()->subHour()->toDateTimeString(),
+ Carbon::now()->addHour()->toDateTimeString(),
+ EventOccurrenceStatus::ACTIVE->name,
+ ),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertTrue($event->isEventOngoing());
+ }
+
+ public function test_is_event_ongoing_returns_false_for_cancelled_occurrences(): void
+ {
+ $occurrences = collect([
+ $this->createOccurrence(
+ Carbon::now()->subHour()->toDateTimeString(),
+ Carbon::now()->addHour()->toDateTimeString(),
+ EventOccurrenceStatus::CANCELLED->name,
+ ),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertFalse($event->isEventOngoing());
+ }
+
+ public function test_is_event_ongoing_returns_false_when_started_occurrence_has_no_end_date(): void
+ {
+ $occurrences = collect([
+ $this->createOccurrence(
+ Carbon::now()->subHour()->toDateTimeString(),
+ null,
+ EventOccurrenceStatus::ACTIVE->name,
+ ),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertFalse($event->isEventOngoing());
+ }
+
+ public function test_is_event_ongoing_returns_false_when_no_occurrences(): void
+ {
+ $event = $this->createEvent();
+ $this->assertFalse($event->isEventOngoing());
+
+ $eventWithEmpty = $this->createEvent(collect([]));
+ $this->assertFalse($eventWithEmpty->isEventOngoing());
+ }
+
+ public function test_get_lifecycle_status_returns_ongoing_when_ongoing(): void
+ {
+ $occurrences = collect([
+ $this->createOccurrence(
+ Carbon::now()->subHour()->toDateTimeString(),
+ Carbon::now()->addHour()->toDateTimeString(),
+ EventOccurrenceStatus::ACTIVE->name,
+ ),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertEquals(EventLifecycleStatus::ONGOING->name, $event->getLifecycleStatus());
+ }
+
+ public function test_get_lifecycle_status_returns_upcoming_when_all_future(): void
+ {
+ $occurrences = collect([
+ $this->createOccurrence(
+ Carbon::now()->addDay()->toDateTimeString(),
+ Carbon::now()->addDays(2)->toDateTimeString(),
+ ),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertEquals(EventLifecycleStatus::UPCOMING->name, $event->getLifecycleStatus());
+ }
+
+ public function test_get_lifecycle_status_returns_ended_when_all_past(): void
+ {
+ $occurrences = collect([
+ $this->createOccurrence(
+ Carbon::now()->subDays(3)->toDateTimeString(),
+ Carbon::now()->subDay()->toDateTimeString(),
+ ),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertEquals(EventLifecycleStatus::ENDED->name, $event->getLifecycleStatus());
+ }
+
+ public function test_get_lifecycle_status_returns_upcoming_when_no_occurrences(): void
+ {
+ $event = $this->createEvent(collect());
+
+ $this->assertEquals(EventLifecycleStatus::UPCOMING->name, $event->getLifecycleStatus());
+ }
+
+ public function test_get_lifecycle_status_returns_upcoming_for_recurring_event_mid_series(): void
+ {
+ $occurrences = collect([
+ $this->createOccurrence(
+ Carbon::now()->subDays(3)->toDateTimeString(),
+ Carbon::now()->subDays(3)->addHours(2)->toDateTimeString(),
+ ),
+ $this->createOccurrence(
+ Carbon::now()->addDays(3)->toDateTimeString(),
+ Carbon::now()->addDays(3)->addHours(2)->toDateTimeString(),
+ ),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertEquals(EventLifecycleStatus::UPCOMING->name, $event->getLifecycleStatus());
+ }
+
+ public function test_get_lifecycle_status_returns_ongoing_when_one_occurrence_in_series_is_live(): void
+ {
+ $occurrences = collect([
+ $this->createOccurrence(
+ Carbon::now()->subDays(3)->toDateTimeString(),
+ Carbon::now()->subDays(3)->addHours(2)->toDateTimeString(),
+ ),
+ $this->createOccurrence(
+ Carbon::now()->subHour()->toDateTimeString(),
+ Carbon::now()->addHour()->toDateTimeString(),
+ ),
+ $this->createOccurrence(
+ Carbon::now()->addDays(3)->toDateTimeString(),
+ Carbon::now()->addDays(3)->addHours(2)->toDateTimeString(),
+ ),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertEquals(EventLifecycleStatus::ONGOING->name, $event->getLifecycleStatus());
+ }
+
+ public function test_get_lifecycle_status_returns_upcoming_when_only_remaining_occurrence_is_cancelled(): void
+ {
+ $occurrences = collect([
+ $this->createOccurrence(
+ Carbon::now()->subDays(3)->toDateTimeString(),
+ Carbon::now()->subDays(3)->addHours(2)->toDateTimeString(),
+ ),
+ $this->createOccurrence(
+ Carbon::now()->addDay()->toDateTimeString(),
+ Carbon::now()->addDay()->addHours(2)->toDateTimeString(),
+ EventOccurrenceStatus::CANCELLED->name,
+ ),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertEquals(EventLifecycleStatus::UPCOMING->name, $event->getLifecycleStatus());
+ }
+
+ public function test_get_lifecycle_status_returns_ended_when_started_occurrence_has_no_end_date(): void
+ {
+ $occurrences = collect([
+ $this->createOccurrence(Carbon::now()->subHour()->toDateTimeString()),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertEquals(EventLifecycleStatus::ENDED->name, $event->getLifecycleStatus());
+ }
+
+ public function test_is_recurring_returns_true_for_recurring_type(): void
+ {
+ $event = new EventDomainObject;
+ $event->setType(EventType::RECURRING->name);
+
+ $this->assertTrue($event->isRecurring());
+ }
+
+ public function test_is_recurring_returns_false_for_single_type(): void
+ {
+ $event = new EventDomainObject;
+ $event->setType(EventType::SINGLE->name);
+
+ $this->assertFalse($event->isRecurring());
+ }
+
+ public function test_get_next_occurrence_start_date_includes_running_occurrence(): void
+ {
+ $runningStart = Carbon::now()->subDay()->toDateTimeString();
+ $futureStart = Carbon::now()->addDays(3)->toDateTimeString();
+
+ $occurrences = collect([
+ $this->createOccurrence($futureStart, Carbon::now()->addDays(3)->addHours(2)->toDateTimeString()),
+ $this->createOccurrence($runningStart, Carbon::now()->addDay()->toDateTimeString()),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertEquals($runningStart, $event->getNextOccurrenceStartDate());
+ }
+
+ public function test_get_next_occurrence_start_date_skips_ended_and_cancelled_occurrences(): void
+ {
+ $futureStart = Carbon::now()->addDays(3)->toDateTimeString();
+
+ $occurrences = collect([
+ $this->createOccurrence(
+ Carbon::now()->subDays(2)->toDateTimeString(),
+ Carbon::now()->subDay()->toDateTimeString(),
+ ),
+ $this->createOccurrence(Carbon::now()->subHours(3)->toDateTimeString()),
+ $this->createOccurrence(
+ Carbon::now()->addDay()->toDateTimeString(),
+ null,
+ EventOccurrenceStatus::CANCELLED->name,
+ ),
+ $this->createOccurrence($futureStart),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertEquals($futureStart, $event->getNextOccurrenceStartDate());
+ }
+
+ public function test_get_next_occurrence_start_date_returns_null_when_all_ended(): void
+ {
+ $occurrences = collect([
+ $this->createOccurrence(
+ Carbon::now()->subDays(2)->toDateTimeString(),
+ Carbon::now()->subDay()->toDateTimeString(),
+ ),
+ ]);
+
+ $event = $this->createEvent($occurrences);
+
+ $this->assertNull($event->getNextOccurrenceStartDate());
+ }
+}
diff --git a/backend/tests/Unit/DomainObjects/EventOccurrenceDomainObjectTest.php b/backend/tests/Unit/DomainObjects/EventOccurrenceDomainObjectTest.php
new file mode 100644
index 0000000000..3373b233f5
--- /dev/null
+++ b/backend/tests/Unit/DomainObjects/EventOccurrenceDomainObjectTest.php
@@ -0,0 +1,134 @@
+setShowAvailableCapacity($override);
+
+ return $occurrence;
+ }
+
+ public function test_override_null_inherits_event_default_true(): void
+ {
+ $this->assertTrue($this->occurrence(null)->shouldShowAvailableCapacity(true));
+ }
+
+ public function test_override_null_inherits_event_default_false(): void
+ {
+ $this->assertFalse($this->occurrence(null)->shouldShowAvailableCapacity(false));
+ }
+
+ public function test_override_true_shows_regardless_of_event_default(): void
+ {
+ $this->assertTrue($this->occurrence(true)->shouldShowAvailableCapacity(false));
+ $this->assertTrue($this->occurrence(true)->shouldShowAvailableCapacity(true));
+ }
+
+ public function test_override_false_hides_regardless_of_event_default(): void
+ {
+ $this->assertFalse($this->occurrence(false)->shouldShowAvailableCapacity(true));
+ $this->assertFalse($this->occurrence(false)->shouldShowAvailableCapacity(false));
+ }
+
+ public function test_available_capacity_is_null_when_capacity_is_null(): void
+ {
+ $occurrence = new EventOccurrenceDomainObject;
+ $occurrence->setCapacity(null);
+
+ $this->assertNull($occurrence->getAvailableCapacity());
+ }
+
+ public function test_available_capacity_never_goes_negative(): void
+ {
+ $occurrence = new EventOccurrenceDomainObject;
+ $occurrence->setCapacity(10);
+ $occurrence->setUsedCapacity(15);
+
+ $this->assertSame(0, $occurrence->getAvailableCapacity());
+ }
+
+ public function test_status_derives_sold_out_when_active_and_full(): void
+ {
+ $occurrence = (new EventOccurrenceDomainObject)
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name)
+ ->setCapacity(10)
+ ->setUsedCapacity(10);
+
+ $this->assertSame(EventOccurrenceStatus::SOLD_OUT->name, $occurrence->getStatus());
+ $this->assertTrue($occurrence->isSoldOut());
+ $this->assertFalse($occurrence->isActive());
+ }
+
+ public function test_status_stays_active_while_capacity_remains(): void
+ {
+ $occurrence = (new EventOccurrenceDomainObject)
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name)
+ ->setCapacity(10)
+ ->setUsedCapacity(9);
+
+ $this->assertSame(EventOccurrenceStatus::ACTIVE->name, $occurrence->getStatus());
+ $this->assertFalse($occurrence->isSoldOut());
+ $this->assertTrue($occurrence->isActive());
+ }
+
+ public function test_status_stays_active_when_capacity_unlimited(): void
+ {
+ $occurrence = (new EventOccurrenceDomainObject)
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name)
+ ->setCapacity(null)
+ ->setUsedCapacity(500);
+
+ $this->assertSame(EventOccurrenceStatus::ACTIVE->name, $occurrence->getStatus());
+ $this->assertFalse($occurrence->isSoldOut());
+ }
+
+ public function test_cancelled_status_is_never_derived_to_sold_out(): void
+ {
+ $occurrence = (new EventOccurrenceDomainObject)
+ ->setStatus(EventOccurrenceStatus::CANCELLED->name)
+ ->setCapacity(10)
+ ->setUsedCapacity(10);
+
+ $this->assertSame(EventOccurrenceStatus::CANCELLED->name, $occurrence->getStatus());
+ $this->assertFalse($occurrence->isSoldOut());
+ $this->assertTrue($occurrence->isCancelled());
+ }
+
+ public function test_is_past_is_false_while_occurrence_is_running(): void
+ {
+ $occurrence = (new EventOccurrenceDomainObject)
+ ->setStartDate(Carbon::now('UTC')->subDay()->toDateTimeString())
+ ->setEndDate(Carbon::now('UTC')->addHour()->toDateTimeString());
+
+ $this->assertFalse($occurrence->isPast());
+ }
+
+ public function test_is_past_is_true_once_end_date_passes(): void
+ {
+ $occurrence = (new EventOccurrenceDomainObject)
+ ->setStartDate(Carbon::now('UTC')->subDays(2)->toDateTimeString())
+ ->setEndDate(Carbon::now('UTC')->subDay()->toDateTimeString());
+
+ $this->assertTrue($occurrence->isPast());
+ }
+
+ public function test_is_past_falls_back_to_start_date_when_end_is_null(): void
+ {
+ $started = (new EventOccurrenceDomainObject)
+ ->setStartDate(Carbon::now('UTC')->subHour()->toDateTimeString());
+ $upcoming = (new EventOccurrenceDomainObject)
+ ->setStartDate(Carbon::now('UTC')->addHour()->toDateTimeString());
+
+ $this->assertTrue($started->isPast());
+ $this->assertFalse($upcoming->isPast());
+ }
+}
diff --git a/backend/tests/Unit/Helper/StringHelperTest.php b/backend/tests/Unit/Helper/StringHelperTest.php
index 158678af1d..8dcf60ae76 100644
--- a/backend/tests/Unit/Helper/StringHelperTest.php
+++ b/backend/tests/Unit/Helper/StringHelperTest.php
@@ -7,21 +7,21 @@
class StringHelperTest extends TestCase
{
- public function testStripControlCharactersRemovesControlAndSeparatorCharacters(): void
+ public function test_strip_control_characters_removes_control_and_separator_characters(): void
{
$input = "Hello\x00\x1F\x7F\u{2028}\u{2029}World";
$this->assertSame('HelloWorld', StringHelper::stripControlCharacters($input));
}
- public function testStripControlCharactersPreservesLegitimatePrintableCharacters(): void
+ public function test_strip_control_characters_preserves_legitimate_printable_characters(): void
{
$input = 'Rock & Roll: a < b > c "Live" 🎸';
$this->assertSame($input, StringHelper::stripControlCharacters($input));
}
- public function testStripControlCharactersPreservesScriptLikeText(): void
+ public function test_strip_control_characters_preserves_script_like_text(): void
{
// The title is stored faithfully; escaping is the renderer's responsibility.
$input = '';
diff --git a/backend/tests/Unit/Http/Request/Event/UpdateEventLocationRequestTest.php b/backend/tests/Unit/Http/Request/Event/UpdateEventLocationRequestTest.php
new file mode 100644
index 0000000000..6796ab073e
--- /dev/null
+++ b/backend/tests/Unit/Http/Request/Event/UpdateEventLocationRequestTest.php
@@ -0,0 +1,86 @@
+merge([
+ 'event_location' => [
+ 'type' => LocationType::IN_PERSON->name,
+ 'location_id' => null,
+ ],
+ ]);
+
+ $validator = Validator::make($request->all(), $request->rules(), $request->messages());
+
+ $this->assertTrue($validator->fails());
+ $this->assertTrue($validator->errors()->has('event_location.location_id'));
+ }
+
+ public function test_in_person_passes_when_location_id_set(): void
+ {
+ $request = new UpdateEventLocationRequest;
+ $request->merge([
+ 'event_location' => [
+ 'type' => LocationType::IN_PERSON->name,
+ 'location_id' => 42,
+ ],
+ ]);
+
+ $validator = Validator::make($request->all(), $request->rules(), $request->messages());
+
+ $this->assertFalse($validator->errors()->has('event_location.location_id'));
+ }
+
+ public function test_online_does_not_require_location_id(): void
+ {
+ $request = new UpdateEventLocationRequest;
+ $request->merge([
+ 'event_location' => [
+ 'type' => LocationType::ONLINE->name,
+ 'online_event_connection_details' => 'Zoom: https://example.com/abc',
+ ],
+ ]);
+
+ $validator = Validator::make($request->all(), $request->rules(), $request->messages());
+
+ $this->assertFalse($validator->errors()->has('event_location.location_id'));
+ }
+
+ public function test_online_requires_connection_details(): void
+ {
+ $request = new UpdateEventLocationRequest;
+ $request->merge([
+ 'event_location' => [
+ 'type' => LocationType::ONLINE->name,
+ ],
+ ]);
+
+ $validator = Validator::make($request->all(), $request->rules(), $request->messages());
+
+ $this->assertTrue($validator->errors()->has('event_location.online_event_connection_details'));
+ }
+
+ public function test_null_event_location_is_allowed(): void
+ {
+ $request = new UpdateEventLocationRequest;
+ $request->merge([
+ 'event_location' => null,
+ 'clear_event_location' => true,
+ ]);
+
+ $validator = Validator::make($request->all(), $request->rules(), $request->messages());
+
+ $this->assertFalse($validator->fails());
+ }
+}
diff --git a/backend/tests/Unit/Http/Request/EventOccurrence/BulkUpdateOccurrencesRequestTest.php b/backend/tests/Unit/Http/Request/EventOccurrence/BulkUpdateOccurrencesRequestTest.php
new file mode 100644
index 0000000000..925de0534b
--- /dev/null
+++ b/backend/tests/Unit/Http/Request/EventOccurrence/BulkUpdateOccurrencesRequestTest.php
@@ -0,0 +1,76 @@
+merge([
+ 'action' => BulkOccurrenceAction::UPDATE->value,
+ 'apply_to_all' => true,
+ 'event_location' => [
+ 'type' => LocationType::IN_PERSON->name,
+ 'location_id' => null,
+ ],
+ ]);
+
+ $validator = Validator::make($request->all(), $request->rules(), $request->messages());
+
+ $this->assertTrue($validator->errors()->has('event_location.location_id'));
+ }
+
+ public function test_in_person_passes_when_location_id_set(): void
+ {
+ $request = new BulkUpdateOccurrencesRequest;
+ $request->merge([
+ 'action' => BulkOccurrenceAction::UPDATE->value,
+ 'apply_to_all' => true,
+ 'event_location' => [
+ 'type' => LocationType::IN_PERSON->name,
+ 'location_id' => 99,
+ ],
+ ]);
+
+ $validator = Validator::make($request->all(), $request->rules(), $request->messages());
+
+ $this->assertFalse($validator->errors()->has('event_location.location_id'));
+ }
+
+ public function test_duration_minutes_rejects_values_above_seven_days(): void
+ {
+ $request = new BulkUpdateOccurrencesRequest;
+ $request->merge([
+ 'action' => BulkOccurrenceAction::UPDATE->value,
+ 'apply_to_all' => true,
+ 'duration_minutes' => 10081,
+ ]);
+
+ $validator = Validator::make($request->all(), $request->rules(), $request->messages());
+
+ $this->assertTrue($validator->errors()->has('duration_minutes'));
+ }
+
+ public function test_duration_minutes_accepts_seven_days(): void
+ {
+ $request = new BulkUpdateOccurrencesRequest;
+ $request->merge([
+ 'action' => BulkOccurrenceAction::UPDATE->value,
+ 'apply_to_all' => true,
+ 'duration_minutes' => 10080,
+ ]);
+
+ $validator = Validator::make($request->all(), $request->rules(), $request->messages());
+
+ $this->assertFalse($validator->errors()->has('duration_minutes'));
+ }
+}
diff --git a/backend/tests/Unit/Http/Request/EventOccurrence/UpsertEventOccurrenceRequestTest.php b/backend/tests/Unit/Http/Request/EventOccurrence/UpsertEventOccurrenceRequestTest.php
new file mode 100644
index 0000000000..d854fd9ddd
--- /dev/null
+++ b/backend/tests/Unit/Http/Request/EventOccurrence/UpsertEventOccurrenceRequestTest.php
@@ -0,0 +1,45 @@
+merge([
+ 'start_date' => now()->addDay()->toDateTimeString(),
+ 'event_location' => [
+ 'type' => LocationType::IN_PERSON->name,
+ 'location_id' => null,
+ ],
+ ]);
+
+ $validator = Validator::make($request->all(), $request->rules(), $request->messages());
+
+ $this->assertTrue($validator->errors()->has('event_location.location_id'));
+ }
+
+ public function test_in_person_passes_when_location_id_set(): void
+ {
+ $request = new UpsertEventOccurrenceRequest;
+ $request->merge([
+ 'start_date' => now()->addDay()->toDateTimeString(),
+ 'event_location' => [
+ 'type' => LocationType::IN_PERSON->name,
+ 'location_id' => 7,
+ ],
+ ]);
+
+ $validator = Validator::make($request->all(), $request->rules(), $request->messages());
+
+ $this->assertFalse($validator->errors()->has('event_location.location_id'));
+ }
+}
diff --git a/backend/tests/Unit/Http/Request/Location/UpsertLocationRequestTest.php b/backend/tests/Unit/Http/Request/Location/UpsertLocationRequestTest.php
new file mode 100644
index 0000000000..384403e875
--- /dev/null
+++ b/backend/tests/Unit/Http/Request/Location/UpsertLocationRequestTest.php
@@ -0,0 +1,105 @@
+merge([
+ 'structured_address' => ['venue_name' => 'Foo Hall'],
+ 'provider' => 'foo',
+ 'provider_place_id' => 'some_id',
+ ]);
+
+ $validator = Validator::make($request->all(), $request->rules());
+
+ $this->assertTrue($validator->errors()->has('provider'));
+ }
+
+ public function test_accepts_google_provider(): void
+ {
+ $request = new UpsertLocationRequest;
+ $request->merge([
+ 'structured_address' => ['venue_name' => 'Foo Hall'],
+ 'provider' => 'google',
+ 'provider_place_id' => 'ChIJsomething',
+ ]);
+
+ $validator = Validator::make($request->all(), $request->rules());
+
+ $this->assertFalse($validator->errors()->has('provider'));
+ }
+
+ public function test_provider_without_place_id_is_rejected(): void
+ {
+ $request = new UpsertLocationRequest;
+ $request->merge([
+ 'structured_address' => ['venue_name' => 'Foo Hall'],
+ 'provider' => 'google',
+ ]);
+
+ $validator = Validator::make($request->all(), $request->rules());
+
+ $this->assertTrue($validator->errors()->has('provider_place_id'));
+ }
+
+ public function test_place_id_without_provider_is_rejected(): void
+ {
+ $request = new UpsertLocationRequest;
+ $request->merge([
+ 'structured_address' => ['venue_name' => 'Foo Hall'],
+ 'provider_place_id' => 'ChIJsomething',
+ ]);
+
+ $validator = Validator::make($request->all(), $request->rules());
+
+ $this->assertTrue($validator->errors()->has('provider'));
+ }
+
+ public function test_both_null_is_allowed(): void
+ {
+ $request = new UpsertLocationRequest;
+ $request->merge([
+ 'structured_address' => ['venue_name' => 'Foo Hall'],
+ ]);
+
+ $validator = Validator::make($request->all(), $request->rules());
+
+ $this->assertFalse($validator->errors()->has('provider'));
+ $this->assertFalse($validator->errors()->has('provider_place_id'));
+ }
+
+ public function test_country_must_be_exactly_two_characters(): void
+ {
+ foreach (['I', 'IRL'] as $invalidCountry) {
+ $request = new UpsertLocationRequest;
+ $request->merge([
+ 'structured_address' => ['venue_name' => 'Foo Hall', 'country' => $invalidCountry],
+ ]);
+
+ $validator = Validator::make($request->all(), $request->rules());
+
+ $this->assertTrue($validator->errors()->has('structured_address.country'));
+ }
+ }
+
+ public function test_two_character_country_is_accepted(): void
+ {
+ $request = new UpsertLocationRequest;
+ $request->merge([
+ 'structured_address' => ['venue_name' => 'Foo Hall', 'country' => 'ie'],
+ ]);
+
+ $validator = Validator::make($request->all(), $request->rules());
+
+ $this->assertFalse($validator->errors()->has('structured_address.country'));
+ }
+}
diff --git a/backend/tests/Unit/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequestTest.php b/backend/tests/Unit/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequestTest.php
index bf5a2333c7..b1fcf32d9d 100644
--- a/backend/tests/Unit/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequestTest.php
+++ b/backend/tests/Unit/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequestTest.php
@@ -8,9 +8,9 @@
class PartialUpdateOrganizerSettingsRequestTest extends TestCase
{
- public function testConsentMustBeAcknowledgedWhenPixelsAreEnabled(): void
+ public function test_consent_must_be_acknowledged_when_pixels_are_enabled(): void
{
- $request = new PartialUpdateOrganizerSettingsRequest();
+ $request = new PartialUpdateOrganizerSettingsRequest;
$request->merge([
'tracking_pixels' => [
['provider' => 'facebook_pixel', 'pixel_id' => '1234567890', 'enabled' => true],
@@ -30,9 +30,9 @@ public function testConsentMustBeAcknowledgedWhenPixelsAreEnabled(): void
$this->assertTrue($validator->errors()->has('tracking_consent_acknowledged'));
}
- public function testConsentNotRequiredWhenPixelsAreDisabled(): void
+ public function test_consent_not_required_when_pixels_are_disabled(): void
{
- $request = new PartialUpdateOrganizerSettingsRequest();
+ $request = new PartialUpdateOrganizerSettingsRequest;
$request->merge([
'tracking_pixels' => [
['provider' => 'facebook_pixel', 'pixel_id' => '1234567890', 'enabled' => false],
@@ -52,9 +52,9 @@ public function testConsentNotRequiredWhenPixelsAreDisabled(): void
$this->assertFalse($validator->errors()->has('tracking_consent_acknowledged'));
}
- public function testConsentNotRequiredWhenNoPixels(): void
+ public function test_consent_not_required_when_no_pixels(): void
{
- $request = new PartialUpdateOrganizerSettingsRequest();
+ $request = new PartialUpdateOrganizerSettingsRequest;
$request->merge([
'tracking_pixels' => [],
'tracking_consent_acknowledged' => false,
@@ -72,9 +72,9 @@ public function testConsentNotRequiredWhenNoPixels(): void
$this->assertFalse($validator->errors()->has('tracking_consent_acknowledged'));
}
- public function testInvalidPixelIdIsRejected(): void
+ public function test_invalid_pixel_id_is_rejected(): void
{
- $request = new PartialUpdateOrganizerSettingsRequest();
+ $request = new PartialUpdateOrganizerSettingsRequest;
$request->merge([
'tracking_pixels' => [
['provider' => 'facebook_pixel', 'pixel_id' => 'not-a-valid-id', 'enabled' => true],
@@ -94,11 +94,11 @@ public function testInvalidPixelIdIsRejected(): void
$this->assertTrue($validator->errors()->has('tracking_pixels.0.pixel_id'));
}
- public function testGtmBlockedInSaasMode(): void
+ public function test_gtm_blocked_in_saas_mode(): void
{
config(['app.saas_mode_enabled' => true]);
- $request = new PartialUpdateOrganizerSettingsRequest();
+ $request = new PartialUpdateOrganizerSettingsRequest;
$request->merge([
'tracking_pixels' => [
['provider' => 'google_tag_manager', 'pixel_id' => 'GTM-ABCDEF', 'enabled' => true],
@@ -118,11 +118,11 @@ public function testGtmBlockedInSaasMode(): void
$this->assertTrue($validator->errors()->has('tracking_pixels.0.provider'));
}
- public function testGtmAllowedInSelfHostedMode(): void
+ public function test_gtm_allowed_in_self_hosted_mode(): void
{
config(['app.saas_mode_enabled' => false]);
- $request = new PartialUpdateOrganizerSettingsRequest();
+ $request = new PartialUpdateOrganizerSettingsRequest;
$request->merge([
'tracking_pixels' => [
['provider' => 'google_tag_manager', 'pixel_id' => 'GTM-ABCDEF', 'enabled' => true],
diff --git a/backend/tests/Unit/Jobs/Event/SendEventEmailJobTest.php b/backend/tests/Unit/Jobs/Event/SendEventEmailJobTest.php
new file mode 100644
index 0000000000..b63ec482f6
--- /dev/null
+++ b/backend/tests/Unit/Jobs/Event/SendEventEmailJobTest.php
@@ -0,0 +1,83 @@
+shouldReceive('to')->andReturn($pendingMail);
+ $pendingMail->shouldReceive('send')->once();
+
+ $outgoingMessageRepository = Mockery::mock(OutgoingMessageRepositoryInterface::class);
+ $outgoingMessageRepository
+ ->shouldReceive('create')
+ ->once()
+ ->andThrow(new RuntimeException('db blip'));
+
+ $job = $this->makeJob();
+ $job->handle($mailer, $outgoingMessageRepository);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_send_failure_records_failed_row_and_rethrows(): void
+ {
+ $mailer = Mockery::mock(Mailer::class);
+ $pendingMail = Mockery::mock(PendingMail::class);
+ $mailer->shouldReceive('to')->andReturn($pendingMail);
+ $pendingMail->shouldReceive('send')->once()->andThrow(new RuntimeException('smtp down'));
+
+ $outgoingMessageRepository = Mockery::mock(OutgoingMessageRepositoryInterface::class);
+ $outgoingMessageRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with(Mockery::on(fn (array $attributes) => $attributes['status'] === OutgoingMessageStatus::FAILED->name));
+
+ $this->expectException(RuntimeException::class);
+
+ $job = $this->makeJob();
+ $job->handle($mailer, $outgoingMessageRepository);
+ }
+
+ private function makeJob(): SendEventEmailJob
+ {
+ return new SendEventEmailJob(
+ email: 'recipient@example.com',
+ toName: 'Recipient',
+ eventMessage: Mockery::mock(EventMessage::class),
+ messageData: new SendMessageDTO(
+ account_id: 1,
+ event_id: 2,
+ subject: 'Subject',
+ message: 'Body',
+ type: MessageTypeEnum::ALL_ATTENDEES,
+ is_test: false,
+ send_copy_to_current_user: false,
+ sent_by_user_id: 3,
+ order_id: null,
+ id: 4,
+ ),
+ );
+ }
+}
diff --git a/backend/tests/Unit/Jobs/Message/SendScheduledMessagesJobTest.php b/backend/tests/Unit/Jobs/Message/SendScheduledMessagesJobTest.php
index 9f29f0bdd3..cd31edd7c6 100644
--- a/backend/tests/Unit/Jobs/Message/SendScheduledMessagesJobTest.php
+++ b/backend/tests/Unit/Jobs/Message/SendScheduledMessagesJobTest.php
@@ -4,12 +4,10 @@
use HiEvents\DomainObjects\MessageDomainObject;
use HiEvents\DomainObjects\Status\MessageStatus;
-use HiEvents\Jobs\Event\SendMessagesJob;
use HiEvents\Jobs\Message\SendScheduledMessagesJob;
use HiEvents\Repository\Interfaces\MessageRepositoryInterface;
use HiEvents\Services\Domain\Message\MessageDispatchService;
use Illuminate\Support\Collection;
-use Illuminate\Support\Facades\Bus;
use Mockery as m;
use RuntimeException;
use Tests\TestCase;
@@ -17,6 +15,7 @@
class SendScheduledMessagesJobTest extends TestCase
{
private MessageRepositoryInterface $messageRepository;
+
private MessageDispatchService $messageDispatchService;
protected function setUp(): void
@@ -26,7 +25,7 @@ protected function setUp(): void
$this->messageDispatchService = m::mock(MessageDispatchService::class);
}
- public function testPicksUpScheduledMessagesWithPastScheduledAt(): void
+ public function test_picks_up_scheduled_messages_with_past_scheduled_at(): void
{
$message = m::mock(MessageDomainObject::class);
@@ -43,11 +42,11 @@ public function testPicksUpScheduledMessagesWithPastScheduledAt(): void
->once()
->with($message);
- $job = new SendScheduledMessagesJob();
+ $job = new SendScheduledMessagesJob;
$job->handle($this->messageRepository, $this->messageDispatchService);
}
- public function testDoesNotPickUpFutureScheduledMessages(): void
+ public function test_does_not_pick_up_future_scheduled_messages(): void
{
$this->messageRepository->shouldReceive('findWhere')
->once()
@@ -55,11 +54,11 @@ public function testDoesNotPickUpFutureScheduledMessages(): void
$this->messageDispatchService->shouldNotReceive('dispatchMessage');
- $job = new SendScheduledMessagesJob();
+ $job = new SendScheduledMessagesJob;
$job->handle($this->messageRepository, $this->messageDispatchService);
}
- public function testDoesNotPickUpCancelledMessages(): void
+ public function test_does_not_pick_up_cancelled_messages(): void
{
$this->messageRepository->shouldReceive('findWhere')
->once()
@@ -70,11 +69,11 @@ public function testDoesNotPickUpCancelledMessages(): void
$this->messageDispatchService->shouldNotReceive('dispatchMessage');
- $job = new SendScheduledMessagesJob();
+ $job = new SendScheduledMessagesJob;
$job->handle($this->messageRepository, $this->messageDispatchService);
}
- public function testContinuesProcessingWhenOneMessageFails(): void
+ public function test_continues_processing_when_one_message_fails(): void
{
$message1 = m::mock(MessageDomainObject::class);
$message1->shouldReceive('getId')->andReturn(1);
@@ -94,7 +93,7 @@ public function testContinuesProcessingWhenOneMessageFails(): void
->once()
->with($message2);
- $job = new SendScheduledMessagesJob();
+ $job = new SendScheduledMessagesJob;
$job->handle($this->messageRepository, $this->messageDispatchService);
}
}
diff --git a/backend/tests/Unit/Jobs/Occurrence/BulkCancelOccurrencesJobTest.php b/backend/tests/Unit/Jobs/Occurrence/BulkCancelOccurrencesJobTest.php
new file mode 100644
index 0000000000..67d6fcd154
--- /dev/null
+++ b/backend/tests/Unit/Jobs/Occurrence/BulkCancelOccurrencesJobTest.php
@@ -0,0 +1,183 @@
+andReturnUsing(fn ($callback) => $callback());
+
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->exclusionService = Mockery::mock(RecurrenceRuleExclusionService::class);
+ $this->cancelAttendeesService = Mockery::mock(CancelOccurrenceAttendeesService::class);
+ $this->cancelAttendeesService->shouldReceive('cancelForOccurrence')->andReturn(['attendee_ids' => [], 'sales_backed_count' => 0])->byDefault();
+ $this->exclusionService->shouldReceive('addExclusions')->byDefault();
+ }
+
+ public function test_handle_cancels_multiple_occurrences(): void
+ {
+ Log::shouldReceive('info')->once();
+
+ $occ1 = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occ1->shouldReceive('getEventId')->andReturn(1);
+ $occ1->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name);
+ $occ1->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00');
+
+ $occ2 = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occ2->shouldReceive('getEventId')->andReturn(1);
+ $occ2->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name);
+ $occ2->shouldReceive('getStartDate')->andReturn('2026-06-22 10:00:00');
+
+ $this->occurrenceRepository->shouldReceive('findByIdLocked')
+ ->with(10)
+ ->once()
+ ->andReturn($occ1);
+ $this->occurrenceRepository->shouldReceive('findByIdLocked')
+ ->with(20)
+ ->once()
+ ->andReturn($occ2);
+
+ $this->occurrenceRepository->shouldReceive('updateWhere')->times(2);
+
+ $this->exclusionService
+ ->shouldReceive('addExclusions')
+ ->once()
+ ->with(1, ['2026-06-15 10:00:00']);
+ $this->exclusionService
+ ->shouldReceive('addExclusions')
+ ->once()
+ ->with(1, ['2026-06-22 10:00:00']);
+
+ $this->cancelAttendeesService->shouldReceive('cancelForOccurrence')->with(1, 10)->andReturn(['attendee_ids' => [101, 102], 'sales_backed_count' => 2]);
+
+ $job = new BulkCancelOccurrencesJob(1, [10, 20]);
+ $job->handle($this->occurrenceRepository, $this->exclusionService, $this->cancelAttendeesService);
+
+ Event::assertDispatchedTimes(OccurrenceCancelledEvent::class, 2);
+
+ Bus::assertDispatchedTimes(SendOccurrenceCancellationEmailJob::class, 1);
+ Bus::assertDispatched(SendOccurrenceCancellationEmailJob::class, function (SendOccurrenceCancellationEmailJob $emailJob) {
+ return $emailJob->eventId === 1
+ && $emailJob->occurrenceId === 10
+ && $emailJob->attendeeIds === [101, 102];
+ });
+ }
+
+ public function test_handle_skips_already_cancelled_occurrences(): void
+ {
+ Log::shouldReceive('info')->once();
+
+ $occ = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occ->shouldReceive('getEventId')->andReturn(1);
+ $occ->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::CANCELLED->name);
+
+ $this->occurrenceRepository->shouldReceive('findByIdLocked')
+ ->with(10)
+ ->once()
+ ->andReturn($occ);
+ $this->occurrenceRepository->shouldNotReceive('updateWhere');
+ $this->exclusionService->shouldNotReceive('addExclusions');
+
+ $job = new BulkCancelOccurrencesJob(1, [10]);
+ $job->handle($this->occurrenceRepository, $this->exclusionService, $this->cancelAttendeesService);
+
+ Event::assertNotDispatched(OccurrenceCancelledEvent::class);
+ Bus::assertNotDispatched(SendOccurrenceCancellationEmailJob::class);
+ }
+
+ public function test_it_skips_occurrences_not_belonging_to_event(): void
+ {
+ Log::shouldReceive('info')->once();
+
+ $foreignOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $foreignOccurrence->shouldReceive('getEventId')->andReturn(99);
+
+ $this->occurrenceRepository->shouldReceive('findByIdLocked')
+ ->with(10)
+ ->once()
+ ->andReturn($foreignOccurrence);
+
+ $this->occurrenceRepository->shouldNotReceive('updateWhere');
+ $this->exclusionService->shouldNotReceive('addExclusions');
+
+ $job = new BulkCancelOccurrencesJob(1, [10]);
+ $job->handle($this->occurrenceRepository, $this->exclusionService, $this->cancelAttendeesService);
+
+ Event::assertNotDispatched(OccurrenceCancelledEvent::class);
+ }
+
+ public function test_handle_dispatches_event_with_refund_flag_true(): void
+ {
+ Log::shouldReceive('info')->once();
+
+ $occ = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occ->shouldReceive('getEventId')->andReturn(1);
+ $occ->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name);
+ $occ->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00');
+
+ $this->occurrenceRepository->shouldReceive('findByIdLocked')
+ ->with(10)
+ ->once()
+ ->andReturn($occ);
+ $this->occurrenceRepository->shouldReceive('updateWhere')->once();
+
+ $job = new BulkCancelOccurrencesJob(1, [10], refundOrders: true);
+ $job->handle($this->occurrenceRepository, $this->exclusionService, $this->cancelAttendeesService);
+
+ Event::assertDispatched(OccurrenceCancelledEvent::class, fn ($e) => $e->occurrenceId === 10 && $e->refundOrders === true);
+ }
+
+ public function test_handle_dispatches_event_with_refund_flag_false(): void
+ {
+ Log::shouldReceive('info')->once();
+
+ $occ = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occ->shouldReceive('getEventId')->andReturn(1);
+ $occ->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name);
+ $occ->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00');
+
+ $this->occurrenceRepository->shouldReceive('findByIdLocked')
+ ->with(10)
+ ->once()
+ ->andReturn($occ);
+ $this->occurrenceRepository->shouldReceive('updateWhere')->once();
+
+ $job = new BulkCancelOccurrencesJob(1, [10], refundOrders: false);
+ $job->handle($this->occurrenceRepository, $this->exclusionService, $this->cancelAttendeesService);
+
+ Event::assertDispatched(OccurrenceCancelledEvent::class, fn ($e) => $e->refundOrders === false);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Jobs/Occurrence/RefundOccurrenceOrdersJobTest.php b/backend/tests/Unit/Jobs/Occurrence/RefundOccurrenceOrdersJobTest.php
new file mode 100644
index 0000000000..13576b3866
--- /dev/null
+++ b/backend/tests/Unit/Jobs/Occurrence/RefundOccurrenceOrdersJobTest.php
@@ -0,0 +1,201 @@
+refundHandler = Mockery::mock(RefundOrderHandler::class);
+ $this->auditLogRepository = Mockery::mock(OrderAuditLogRepositoryInterface::class);
+ }
+
+ private function mockDbChain(int $occurrenceId, array $orderIds, array $refundableOrders, array $multiOccurrenceOrderIds = []): void
+ {
+ $orderItemsBuilder = Mockery::mock('orderItemsBuilder');
+ $ordersBuilder = Mockery::mock('ordersBuilder');
+ $batchBuilder = Mockery::mock('batchBuilder');
+
+ DB::shouldReceive('table')->with('order_items')->andReturn($orderItemsBuilder, $batchBuilder);
+ DB::shouldReceive('table')->with('orders')->andReturn($ordersBuilder);
+
+ $orderItemsBuilder->shouldReceive('where')->with('event_occurrence_id', $occurrenceId)->andReturnSelf();
+ $orderItemsBuilder->shouldReceive('whereNull')->with('deleted_at')->andReturnSelf();
+ $orderItemsBuilder->shouldReceive('distinct')->andReturnSelf();
+ $orderItemsBuilder->shouldReceive('pluck')->with('order_id')->andReturn(collect($orderIds));
+
+ $ordersBuilder->shouldReceive('whereIn')->with('id', Mockery::any())->andReturnSelf();
+ $ordersBuilder->shouldReceive('where')->with('status', 'COMPLETED')->andReturnSelf();
+ $ordersBuilder->shouldReceive('where')->with('payment_status', 'PAYMENT_RECEIVED')->andReturnSelf();
+ $ordersBuilder->shouldReceive('whereNull')->with('refund_status')->andReturnSelf();
+ $ordersBuilder->shouldReceive('get')->with(['id', 'total_gross', 'currency'])->andReturn(
+ collect(array_map(fn ($o) => (object) $o, $refundableOrders))
+ );
+
+ $batchBuilder->shouldReceive('whereIn')->andReturnSelf();
+ $batchBuilder->shouldReceive('whereNull')->andReturnSelf();
+ $batchBuilder->shouldReceive('select')->andReturnSelf();
+ $batchBuilder->shouldReceive('groupBy')->andReturnSelf();
+ $batchBuilder->shouldReceive('havingRaw')->andReturnSelf();
+ $batchBuilder->shouldReceive('pluck')->with('order_id')->andReturn(collect($multiOccurrenceOrderIds));
+ }
+
+ public function test_handle_refunds_single_occurrence_orders(): void
+ {
+ $this->mockDbChain(
+ occurrenceId: 10,
+ orderIds: [100],
+ refundableOrders: [['id' => 100, 'total_gross' => 50.00, 'currency' => 'USD']],
+ multiOccurrenceOrderIds: [],
+ );
+
+ $this->refundHandler
+ ->shouldReceive('handle')
+ ->once()
+ ->with(Mockery::on(fn (RefundOrderDTO $dto) => $dto->event_id === 1
+ && $dto->order_id === 100
+ && $dto->amount === 50.00
+ && $dto->notify_buyer === true
+ && $dto->cancel_order === true
+ ));
+
+ $job = new RefundOccurrenceOrdersJob(1, 10);
+ $job->handle($this->refundHandler, $this->auditLogRepository);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_handle_skips_multi_occurrence_orders(): void
+ {
+ Log::shouldReceive('warning')->once();
+
+ $this->mockDbChain(
+ occurrenceId: 10,
+ orderIds: [100],
+ refundableOrders: [['id' => 100, 'total_gross' => 50.00, 'currency' => 'USD']],
+ multiOccurrenceOrderIds: [100],
+ );
+
+ $this->refundHandler->shouldNotReceive('handle');
+
+ $this->auditLogRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with(Mockery::on(fn (array $attributes) => $attributes['order_id'] === 100
+ && $attributes['event_id'] === 1
+ && $attributes['action'] === OrderAuditAction::AUTOMATIC_REFUND_SKIPPED->value
+ && $attributes['new_values']['cancelled_occurrence_id'] === 10));
+
+ $job = new RefundOccurrenceOrdersJob(1, 10);
+ $job->handle($this->refundHandler, $this->auditLogRepository);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_handle_returns_early_when_no_order_items(): void
+ {
+ $builder = Mockery::mock('builder');
+ DB::shouldReceive('table')->with('order_items')->andReturn($builder);
+ $builder->shouldReceive('where')->with('event_occurrence_id', 10)->andReturnSelf();
+ $builder->shouldReceive('whereNull')->with('deleted_at')->andReturnSelf();
+ $builder->shouldReceive('distinct')->andReturnSelf();
+ $builder->shouldReceive('pluck')->with('order_id')->andReturn(collect());
+
+ $this->refundHandler->shouldNotReceive('handle');
+
+ $job = new RefundOccurrenceOrdersJob(1, 10);
+ $job->handle($this->refundHandler, $this->auditLogRepository);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_handle_skips_orders_already_refunded(): void
+ {
+ $this->mockDbChain(
+ occurrenceId: 10,
+ orderIds: [100, 101],
+ refundableOrders: [],
+ multiOccurrenceOrderIds: [],
+ );
+
+ $this->refundHandler->shouldNotReceive('handle');
+
+ $job = new RefundOccurrenceOrdersJob(1, 10);
+ $job->handle($this->refundHandler, $this->auditLogRepository);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_unique_id_is_occurrence_scoped(): void
+ {
+ $job = new RefundOccurrenceOrdersJob(1, 10);
+ $this->assertSame('occurrence:10', $job->uniqueId());
+ }
+
+ public function test_handle_continues_on_refund_error_and_writes_audit_log(): void
+ {
+ Log::shouldReceive('error')->once();
+
+ $this->mockDbChain(
+ occurrenceId: 10,
+ orderIds: [100],
+ refundableOrders: [['id' => 100, 'total_gross' => 50.00, 'currency' => 'USD']],
+ multiOccurrenceOrderIds: [],
+ );
+
+ $this->refundHandler
+ ->shouldReceive('handle')
+ ->once()
+ ->andThrow(new \RuntimeException('Stripe error'));
+
+ $this->auditLogRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with(Mockery::on(fn (array $attributes) => $attributes['order_id'] === 100
+ && $attributes['event_id'] === 1
+ && $attributes['action'] === OrderAuditAction::AUTOMATIC_REFUND_FAILED->value
+ && $attributes['new_values']['cancelled_occurrence_id'] === 10
+ && $attributes['new_values']['error'] === 'Stripe error'));
+
+ $job = new RefundOccurrenceOrdersJob(1, 10);
+ $job->handle($this->refundHandler, $this->auditLogRepository);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_failed_logs_critical(): void
+ {
+ Log::shouldReceive('critical')
+ ->once()
+ ->with('RefundOccurrenceOrdersJob permanently failed after retries', Mockery::on(fn (array $context) => $context['event_id'] === 1
+ && $context['occurrence_id'] === 10
+ && $context['error'] === 'queue exhausted'));
+
+ $job = new RefundOccurrenceOrdersJob(1, 10);
+ $job->failed(new \RuntimeException('queue exhausted'));
+
+ $this->assertTrue(true);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Jobs/Occurrence/SendOccurrenceCancellationEmailJobTest.php b/backend/tests/Unit/Jobs/Occurrence/SendOccurrenceCancellationEmailJobTest.php
new file mode 100644
index 0000000000..f1c732f656
--- /dev/null
+++ b/backend/tests/Unit/Jobs/Occurrence/SendOccurrenceCancellationEmailJobTest.php
@@ -0,0 +1,183 @@
+eventRepository = Mockery::mock(EventRepositoryInterface::class);
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class);
+ $this->mailer = Mockery::mock(Mailer::class);
+ $this->mailBuilderService = Mockery::mock(MailBuilderService::class);
+ $this->mailBuilderService->shouldReceive('buildOccurrenceCancellationMail')
+ ->andReturn(Mockery::mock(OccurrenceCancellationMail::class));
+ }
+
+ private function makeEvent(): EventDomainObject|Mockery\MockInterface
+ {
+ $organizer = Mockery::mock(OrganizerDomainObject::class);
+ $eventSettings = Mockery::mock(EventSettingDomainObject::class);
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getTimezone')->andReturn('America/New_York');
+ $event->shouldReceive('getOrganizer')->andReturn($organizer);
+ $event->shouldReceive('getEventSettings')->andReturn($eventSettings);
+
+ return $event;
+ }
+
+ private function makeAttendee(string $email, string $locale = 'en'): AttendeeDomainObject|Mockery\MockInterface
+ {
+ $attendee = Mockery::mock(AttendeeDomainObject::class);
+ $attendee->shouldReceive('getEmail')->andReturn($email);
+ $attendee->shouldReceive('getLocale')->andReturn($locale);
+
+ return $attendee;
+ }
+
+ private function setupCommon(array $attendeeIds, array $attendees): void
+ {
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 14:00:00');
+
+ $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->occurrenceRepository->shouldReceive('findById')->with(10)->once()->andReturn($occurrence);
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->eventRepository->shouldReceive('findById')->with(1)->once()->andReturn($this->makeEvent());
+ $this->attendeeRepository
+ ->shouldReceive('findWhereIn')
+ ->once()
+ ->with('id', $attendeeIds)
+ ->andReturn(collect($attendees));
+ }
+
+ public function test_handle_sends_email_only_to_provided_attendees(): void
+ {
+ $this->setupCommon([101, 102], [
+ $this->makeAttendee('alice@example.com'),
+ $this->makeAttendee('bob@example.com'),
+ ]);
+
+ $emailsSent = [];
+ $pendingMail = Mockery::mock(PendingMail::class);
+ $pendingMail->shouldReceive('locale')->andReturnSelf();
+ $pendingMail->shouldReceive('send')->with(Mockery::type(OccurrenceCancellationMail::class));
+
+ $this->mailer->shouldReceive('to')->andReturnUsing(function ($email) use (&$emailsSent, $pendingMail) {
+ $emailsSent[] = $email;
+
+ return $pendingMail;
+ });
+
+ $job = new SendOccurrenceCancellationEmailJob(1, 10, [101, 102]);
+ $job->handle($this->eventRepository, $this->occurrenceRepository, $this->attendeeRepository, $this->mailer, $this->mailBuilderService);
+
+ $this->assertCount(2, $emailsSent);
+ $this->assertContains('alice@example.com', $emailsSent);
+ $this->assertContains('bob@example.com', $emailsSent);
+ }
+
+ public function test_handle_deduplicates_by_email(): void
+ {
+ $this->setupCommon([101, 102], [
+ $this->makeAttendee('same@example.com'),
+ $this->makeAttendee('same@example.com'),
+ ]);
+
+ $emailsSent = [];
+ $pendingMail = Mockery::mock(PendingMail::class);
+ $pendingMail->shouldReceive('locale')->andReturnSelf();
+ $pendingMail->shouldReceive('send');
+
+ $this->mailer->shouldReceive('to')->andReturnUsing(function ($email) use (&$emailsSent, $pendingMail) {
+ $emailsSent[] = $email;
+
+ return $pendingMail;
+ });
+
+ $job = new SendOccurrenceCancellationEmailJob(1, 10, [101, 102]);
+ $job->handle($this->eventRepository, $this->occurrenceRepository, $this->attendeeRepository, $this->mailer, $this->mailBuilderService);
+
+ $this->assertCount(1, $emailsSent);
+ }
+
+ public function test_handle_returns_early_when_no_attendee_ids(): void
+ {
+ $this->occurrenceRepository->shouldNotReceive('findById');
+ $this->eventRepository->shouldNotReceive('findById');
+ $this->attendeeRepository->shouldNotReceive('findWhereIn');
+ $this->mailer->shouldNotReceive('to');
+
+ $job = new SendOccurrenceCancellationEmailJob(1, 10, []);
+ $job->handle($this->eventRepository, $this->occurrenceRepository, $this->attendeeRepository, $this->mailer, $this->mailBuilderService);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_dispatch_chunked_splits_attendee_ids_into_jobs_of_at_most_one_thousand(): void
+ {
+ Bus::fake();
+
+ SendOccurrenceCancellationEmailJob::dispatchChunked(1, 10, range(1, 2500), true);
+
+ Bus::assertDispatchedTimes(SendOccurrenceCancellationEmailJob::class, 3);
+
+ $dispatchedIds = [];
+ Bus::assertDispatched(SendOccurrenceCancellationEmailJob::class, function (SendOccurrenceCancellationEmailJob $job) use (&$dispatchedIds) {
+ $dispatchedIds[] = $job->attendeeIds;
+
+ return $job->eventId === 1
+ && $job->occurrenceId === 10
+ && $job->refundOrders === true
+ && count($job->attendeeIds) <= 1000;
+ });
+
+ $this->assertSame(range(1, 2500), array_merge(...$dispatchedIds));
+ }
+
+ public function test_dispatch_chunked_dispatches_nothing_when_no_attendee_ids(): void
+ {
+ Bus::fake();
+
+ SendOccurrenceCancellationEmailJob::dispatchChunked(1, 10, [], false);
+
+ Bus::assertNotDispatched(SendOccurrenceCancellationEmailJob::class);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Jobs/Vat/ValidateVatNumberJobTest.php b/backend/tests/Unit/Jobs/Vat/ValidateVatNumberJobTest.php
deleted file mode 100644
index 1a91aa3564..0000000000
--- a/backend/tests/Unit/Jobs/Vat/ValidateVatNumberJobTest.php
+++ /dev/null
@@ -1,154 +0,0 @@
-viesService = Mockery::mock(ViesValidationService::class);
- $this->repository = Mockery::mock(AccountVatSettingRepositoryInterface::class);
- $this->logger = Mockery::mock(LoggerInterface::class);
- $this->logger->shouldReceive('info')->byDefault();
- $this->logger->shouldReceive('warning')->byDefault();
- }
-
- public function testJobUpdatesSettingsOnSuccessfulValidation(): void
- {
- $accountVatSettingId = 123;
- $vatNumber = 'IE1234567A';
-
- $job = new ValidateVatNumberJob($accountVatSettingId, $vatNumber);
-
- $validationResponse = new ViesValidationResponseDTO(
- valid: true,
- businessName: 'Test Company Ltd',
- businessAddress: '123 Test Street',
- countryCode: 'IE',
- vatNumber: '1234567A',
- isTransientError: false,
- );
-
- $domainObject = Mockery::mock(AccountVatSettingDomainObject::class);
-
- $this->viesService
- ->shouldReceive('validateVatNumber')
- ->with($vatNumber)
- ->once()
- ->andReturn($validationResponse);
-
- $this->repository
- ->shouldReceive('updateFromArray')
- ->twice()
- ->withArgs(function ($id, $data) use ($accountVatSettingId) {
- if ($id !== $accountVatSettingId) {
- return false;
- }
-
- if (isset($data['vat_validation_status']) && $data['vat_validation_status'] === VatValidationStatus::VALIDATING->value) {
- return true;
- }
-
- if (isset($data['vat_validated']) && $data['vat_validated'] === true) {
- return $data['vat_validation_status'] === VatValidationStatus::VALID->value
- && $data['business_name'] === 'Test Company Ltd';
- }
-
- return false;
- })
- ->andReturn($domainObject);
-
- $job->handle($this->viesService, $this->repository, $this->logger);
-
- $this->assertTrue(true);
- }
-
- public function testJobUpdatesSettingsOnInvalidVatNumber(): void
- {
- $accountVatSettingId = 123;
- $vatNumber = 'IE9999999ZZ';
-
- $job = new ValidateVatNumberJob($accountVatSettingId, $vatNumber);
-
- $validationResponse = new ViesValidationResponseDTO(
- valid: false,
- countryCode: 'IE',
- vatNumber: '9999999ZZ',
- isTransientError: false,
- errorMessage: 'VAT number is not valid',
- );
-
- $domainObject = Mockery::mock(AccountVatSettingDomainObject::class);
-
- $this->viesService
- ->shouldReceive('validateVatNumber')
- ->with($vatNumber)
- ->once()
- ->andReturn($validationResponse);
-
- $this->repository
- ->shouldReceive('updateFromArray')
- ->twice()
- ->withArgs(function ($id, $data) use ($accountVatSettingId) {
- if ($id !== $accountVatSettingId) {
- return false;
- }
-
- if (isset($data['vat_validation_status']) && $data['vat_validation_status'] === VatValidationStatus::VALIDATING->value) {
- return true;
- }
-
- if (isset($data['vat_validated']) && $data['vat_validated'] === false) {
- return $data['vat_validation_status'] === VatValidationStatus::INVALID->value;
- }
-
- return false;
- })
- ->andReturn($domainObject);
-
- $job->handle($this->viesService, $this->repository, $this->logger);
-
- $this->assertTrue(true);
- }
-
- public function testJobHasCorrectRetryConfiguration(): void
- {
- $job = new ValidateVatNumberJob(1, 'IE1234567A');
-
- $this->assertEquals(15, $job->tries);
- $this->assertEquals(15, $job->maxExceptions);
- $this->assertEquals(15, $job->timeout);
- }
-
- public function testJobBackoffConfiguration(): void
- {
- $job = new ValidateVatNumberJob(1, 'IE1234567A');
-
- $backoffs = $job->backoff();
-
- $this->assertCount(15, $backoffs);
- $this->assertEquals(10, $backoffs[0]);
- $this->assertEquals(1800, $backoffs[14]);
- }
-
- protected function tearDown(): void
- {
- Mockery::close();
- parent::tearDown();
- }
-}
diff --git a/backend/tests/Unit/Jobs/Waitlist/ProcessExpiredWaitlistOffersJobTest.php b/backend/tests/Unit/Jobs/Waitlist/ProcessExpiredWaitlistOffersJobTest.php
index 1bc129d44e..bcc42e84f7 100644
--- a/backend/tests/Unit/Jobs/Waitlist/ProcessExpiredWaitlistOffersJobTest.php
+++ b/backend/tests/Unit/Jobs/Waitlist/ProcessExpiredWaitlistOffersJobTest.php
@@ -2,15 +2,16 @@
namespace Tests\Unit\Jobs\Waitlist;
+use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\DomainObjects\Status\OrderStatus;
use HiEvents\DomainObjects\Status\WaitlistEntryStatus;
use HiEvents\DomainObjects\WaitlistEntryDomainObject;
use HiEvents\Events\CapacityChangedEvent;
use HiEvents\Jobs\Waitlist\ProcessExpiredWaitlistOffersJob;
use HiEvents\Jobs\Waitlist\SendWaitlistOfferExpiredEmailJob;
-use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface;
+use HiEvents\Repository\Interfaces\StripePaymentsRepositoryInterface;
use HiEvents\Repository\Interfaces\WaitlistEntryRepositoryInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Collection;
@@ -23,10 +24,15 @@
class ProcessExpiredWaitlistOffersJobTest extends TestCase
{
private WaitlistEntryRepositoryInterface $repository;
+
private OrderRepositoryInterface $orderRepository;
+
private ProductPriceRepositoryInterface $productPriceRepository;
+
private DatabaseManager $databaseManager;
+ private StripePaymentsRepositoryInterface $stripePaymentsRepository;
+
protected function setUp(): void
{
parent::setUp();
@@ -34,6 +40,9 @@ protected function setUp(): void
$this->orderRepository = m::mock(OrderRepositoryInterface::class);
$this->productPriceRepository = m::mock(ProductPriceRepositoryInterface::class);
$this->databaseManager = m::mock(DatabaseManager::class);
+ $this->stripePaymentsRepository = m::mock(StripePaymentsRepositoryInterface::class);
+
+ $this->stripePaymentsRepository->shouldReceive('countWhere')->andReturn(0)->byDefault();
$this->databaseManager
->shouldReceive('transaction')
@@ -41,7 +50,7 @@ protected function setUp(): void
return $callback();
});
- $productPrice = new ProductPriceDomainObject();
+ $productPrice = new ProductPriceDomainObject;
$productPrice->setId(20);
$productPrice->setProductId(99);
@@ -51,12 +60,12 @@ protected function setUp(): void
->andReturn($productPrice);
}
- public function testProcessesExpiredOffersAndDispatchesEmailAndEvent(): void
+ public function test_processes_expired_offers_and_dispatches_email_and_event(): void
{
Bus::fake();
Event::fake();
- $entry = new WaitlistEntryDomainObject();
+ $entry = new WaitlistEntryDomainObject;
$entry->setId(1);
$entry->setEventId(10);
$entry->setProductPriceId(20);
@@ -96,7 +105,7 @@ public function testProcessesExpiredOffersAndDispatchesEmailAndEvent(): void
['id' => 1],
);
- $expiredEntry = new WaitlistEntryDomainObject();
+ $expiredEntry = new WaitlistEntryDomainObject;
$expiredEntry->setId(1);
$expiredEntry->setEventId(10);
$expiredEntry->setProductPriceId(20);
@@ -108,8 +117,8 @@ public function testProcessesExpiredOffersAndDispatchesEmailAndEvent(): void
->with(1)
->andReturn($expiredEntry);
- $job = new ProcessExpiredWaitlistOffersJob();
- $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository, $this->databaseManager);
+ $job = new ProcessExpiredWaitlistOffersJob;
+ $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository, $this->databaseManager, $this->stripePaymentsRepository);
Bus::assertDispatched(SendWaitlistOfferExpiredEmailJob::class);
Event::assertDispatched(CapacityChangedEvent::class, function ($event) {
@@ -117,12 +126,56 @@ public function testProcessesExpiredOffersAndDispatchesEmailAndEvent(): void
});
}
- public function testSkipsOrderDeletionWhenNoOrderId(): void
+ public function test_marks_order_abandoned_when_a_stripe_payment_exists(): void
{
Bus::fake();
Event::fake();
- $entry = new WaitlistEntryDomainObject();
+ $entry = new WaitlistEntryDomainObject;
+ $entry->setId(1);
+ $entry->setEventId(10);
+ $entry->setProductPriceId(20);
+ $entry->setOrderId(100);
+ $entry->setStatus(WaitlistEntryStatus::OFFERED->name);
+
+ $this->repository->shouldReceive('findWhere')->once()->andReturn(new Collection([$entry]));
+ $this->repository->shouldReceive('findByIdLocked')->once()->with(1)->andReturn($entry);
+
+ $this->stripePaymentsRepository
+ ->shouldReceive('countWhere')
+ ->with(['order_id' => 100])
+ ->andReturn(1);
+
+ $this->orderRepository->shouldNotReceive('deleteWhere');
+ $this->orderRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ ['status' => OrderStatus::ABANDONED->name],
+ ['id' => 100, 'status' => OrderStatus::RESERVED->name],
+ );
+
+ $this->repository->shouldReceive('updateWhere')->once();
+
+ $expiredEntry = new WaitlistEntryDomainObject;
+ $expiredEntry->setId(1);
+ $expiredEntry->setEventId(10);
+ $expiredEntry->setProductPriceId(20);
+ $expiredEntry->setStatus(WaitlistEntryStatus::OFFER_EXPIRED->name);
+ $this->repository->shouldReceive('findById')->once()->with(1)->andReturn($expiredEntry);
+
+ $job = new ProcessExpiredWaitlistOffersJob;
+ $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository, $this->databaseManager, $this->stripePaymentsRepository);
+
+ Bus::assertDispatched(SendWaitlistOfferExpiredEmailJob::class);
+ }
+
+ public function test_skips_order_deletion_when_no_order_id(): void
+ {
+ Bus::fake();
+ Event::fake();
+
+ $entry = new WaitlistEntryDomainObject;
$entry->setId(2);
$entry->setEventId(10);
$entry->setProductPriceId(20);
@@ -146,7 +199,7 @@ public function testSkipsOrderDeletionWhenNoOrderId(): void
->shouldReceive('updateWhere')
->once();
- $expiredEntry = new WaitlistEntryDomainObject();
+ $expiredEntry = new WaitlistEntryDomainObject;
$expiredEntry->setId(2);
$expiredEntry->setEventId(10);
$expiredEntry->setProductPriceId(20);
@@ -158,14 +211,14 @@ public function testSkipsOrderDeletionWhenNoOrderId(): void
->with(2)
->andReturn($expiredEntry);
- $job = new ProcessExpiredWaitlistOffersJob();
- $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository, $this->databaseManager);
+ $job = new ProcessExpiredWaitlistOffersJob;
+ $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository, $this->databaseManager, $this->stripePaymentsRepository);
Bus::assertDispatched(SendWaitlistOfferExpiredEmailJob::class);
Event::assertDispatched(CapacityChangedEvent::class);
}
- public function testDoesNothingWhenNoExpiredEntries(): void
+ public function test_does_nothing_when_no_expired_entries(): void
{
Bus::fake();
Event::fake();
@@ -173,16 +226,16 @@ public function testDoesNothingWhenNoExpiredEntries(): void
$this->repository
->shouldReceive('findWhere')
->once()
- ->andReturn(new Collection());
+ ->andReturn(new Collection);
- $job = new ProcessExpiredWaitlistOffersJob();
- $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository, $this->databaseManager);
+ $job = new ProcessExpiredWaitlistOffersJob;
+ $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository, $this->databaseManager, $this->stripePaymentsRepository);
Bus::assertNotDispatched(SendWaitlistOfferExpiredEmailJob::class);
Event::assertNotDispatched(CapacityChangedEvent::class);
}
- public function testCatchesExceptionAndLogsError(): void
+ public function test_catches_exception_and_logs_error(): void
{
Event::fake();
Bus::fake();
@@ -192,10 +245,11 @@ public function testCatchesExceptionAndLogsError(): void
->once()
->with('Failed to process expired waitlist offer', m::on(function ($context) use (&$logged) {
$logged = true;
+
return $context['entry_id'] === 1 && isset($context['error']);
}));
- $entry = new WaitlistEntryDomainObject();
+ $entry = new WaitlistEntryDomainObject;
$entry->setId(1);
$entry->setEventId(10);
$entry->setProductPriceId(20);
@@ -213,20 +267,20 @@ public function testCatchesExceptionAndLogsError(): void
->with(1)
->andThrow(new \RuntimeException('DB connection lost'));
- $job = new ProcessExpiredWaitlistOffersJob();
- $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository, $this->databaseManager);
+ $job = new ProcessExpiredWaitlistOffersJob;
+ $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository, $this->databaseManager, $this->stripePaymentsRepository);
$this->assertTrue($logged, 'Error was logged for failed expired offer processing');
Bus::assertNotDispatched(SendWaitlistOfferExpiredEmailJob::class);
Event::assertNotDispatched(CapacityChangedEvent::class);
}
- public function testSkipsEntryWhenStatusChangedBeforeLock(): void
+ public function test_skips_entry_when_status_changed_before_lock(): void
{
Bus::fake();
Event::fake();
- $entry = new WaitlistEntryDomainObject();
+ $entry = new WaitlistEntryDomainObject;
$entry->setId(1);
$entry->setEventId(10);
$entry->setProductPriceId(20);
@@ -238,7 +292,7 @@ public function testSkipsEntryWhenStatusChangedBeforeLock(): void
->once()
->andReturn(new Collection([$entry]));
- $cancelledEntry = new WaitlistEntryDomainObject();
+ $cancelledEntry = new WaitlistEntryDomainObject;
$cancelledEntry->setId(1);
$cancelledEntry->setStatus(WaitlistEntryStatus::CANCELLED->name);
@@ -257,8 +311,8 @@ public function testSkipsEntryWhenStatusChangedBeforeLock(): void
->with(1)
->andReturn($cancelledEntry);
- $job = new ProcessExpiredWaitlistOffersJob();
- $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository, $this->databaseManager);
+ $job = new ProcessExpiredWaitlistOffersJob;
+ $job->handle($this->repository, $this->orderRepository, $this->productPriceRepository, $this->databaseManager, $this->stripePaymentsRepository);
Bus::assertNotDispatched(SendWaitlistOfferExpiredEmailJob::class);
Event::assertNotDispatched(CapacityChangedEvent::class);
diff --git a/backend/tests/Unit/Listeners/Occurrence/RefundOccurrenceOrdersListenerTest.php b/backend/tests/Unit/Listeners/Occurrence/RefundOccurrenceOrdersListenerTest.php
new file mode 100644
index 0000000000..fedee293b6
--- /dev/null
+++ b/backend/tests/Unit/Listeners/Occurrence/RefundOccurrenceOrdersListenerTest.php
@@ -0,0 +1,41 @@
+handle(new OccurrenceCancelledEvent(
+ eventId: 5,
+ occurrenceId: 42,
+ refundOrders: true,
+ ));
+
+ Bus::assertDispatched(
+ RefundOccurrenceOrdersJob::class,
+ fn (RefundOccurrenceOrdersJob $job) => $job->eventId === 5 && $job->occurrenceId === 42,
+ );
+ }
+
+ public function test_does_not_dispatch_when_refund_orders_false(): void
+ {
+ Bus::fake();
+
+ (new RefundOccurrenceOrdersListener)->handle(new OccurrenceCancelledEvent(
+ eventId: 5,
+ occurrenceId: 42,
+ refundOrders: false,
+ ));
+
+ Bus::assertNotDispatched(RefundOccurrenceOrdersJob::class);
+ }
+}
diff --git a/backend/tests/Unit/Listeners/Waitlist/CancelWaitlistEntriesOnOccurrenceCancelledListenerTest.php b/backend/tests/Unit/Listeners/Waitlist/CancelWaitlistEntriesOnOccurrenceCancelledListenerTest.php
new file mode 100644
index 0000000000..31f77c3a55
--- /dev/null
+++ b/backend/tests/Unit/Listeners/Waitlist/CancelWaitlistEntriesOnOccurrenceCancelledListenerTest.php
@@ -0,0 +1,148 @@
+waitlistEntryRepository = Mockery::mock(WaitlistEntryRepositoryInterface::class);
+ $this->cancelWaitlistEntryService = Mockery::mock(CancelWaitlistEntryService::class);
+ $this->listener = new CancelWaitlistEntriesOnOccurrenceCancelledListener(
+ $this->waitlistEntryRepository,
+ $this->cancelWaitlistEntryService,
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ public function test_entry_that_became_uncancellable_does_not_stop_remaining_cancellations(): void
+ {
+ $purchasedEntry = Mockery::mock(WaitlistEntryDomainObject::class);
+ $offeredEntry = Mockery::mock(WaitlistEntryDomainObject::class);
+
+ $this->waitlistEntryRepository->shouldReceive('updateWhere')->once();
+ $this->waitlistEntryRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(collect([$purchasedEntry, $offeredEntry]));
+
+ $this->cancelWaitlistEntryService
+ ->shouldReceive('cancelEntry')
+ ->once()
+ ->with($purchasedEntry)
+ ->andThrow(new ResourceConflictException('This waitlist entry cannot be cancelled'));
+ $this->cancelWaitlistEntryService
+ ->shouldReceive('cancelEntry')
+ ->once()
+ ->with($offeredEntry)
+ ->andReturn($offeredEntry);
+
+ $this->listener->handle(new OccurrenceCancelledEvent(
+ eventId: 1,
+ occurrenceId: 10,
+ refundOrders: true,
+ ));
+
+ $this->assertTrue(true);
+ }
+
+ public function test_bulk_cancels_waiting_entries_only(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $this->waitlistEntryRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ Mockery::on(static function (array $attrs): bool {
+ return ($attrs['status'] ?? null) === WaitlistEntryStatus::CANCELLED->name
+ && array_key_exists('cancelled_at', $attrs);
+ }),
+ [
+ 'event_id' => $eventId,
+ 'event_occurrence_id' => $occurrenceId,
+ 'status' => WaitlistEntryStatus::WAITING->name,
+ ],
+ );
+
+ $this->waitlistEntryRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->with([
+ 'event_id' => $eventId,
+ 'event_occurrence_id' => $occurrenceId,
+ 'status' => WaitlistEntryStatus::OFFERED->name,
+ ])
+ ->andReturn(collect());
+
+ $this->cancelWaitlistEntryService->shouldNotReceive('cancelEntry');
+
+ $this->listener->handle(new OccurrenceCancelledEvent(
+ eventId: $eventId,
+ occurrenceId: $occurrenceId,
+ refundOrders: false,
+ ));
+
+ $this->assertTrue(true);
+ }
+
+ public function test_offered_entries_are_cancelled_through_the_entry_service(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $offeredEntry1 = Mockery::mock(WaitlistEntryDomainObject::class);
+ $offeredEntry2 = Mockery::mock(WaitlistEntryDomainObject::class);
+
+ $this->waitlistEntryRepository->shouldReceive('updateWhere')->once();
+
+ $this->waitlistEntryRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(collect([$offeredEntry1, $offeredEntry2]));
+
+ $this->cancelWaitlistEntryService
+ ->shouldReceive('cancelEntry')
+ ->once()
+ ->with($offeredEntry1)
+ ->andReturn($offeredEntry1);
+ $this->cancelWaitlistEntryService
+ ->shouldReceive('cancelEntry')
+ ->once()
+ ->with($offeredEntry2)
+ ->andReturn($offeredEntry2);
+
+ $this->listener->handle(new OccurrenceCancelledEvent(
+ eventId: $eventId,
+ occurrenceId: $occurrenceId,
+ refundOrders: true,
+ ));
+
+ $this->assertTrue(true);
+ }
+}
diff --git a/backend/tests/Unit/Resources/Attendee/AttendeeWithCheckInPublicResourceTest.php b/backend/tests/Unit/Resources/Attendee/AttendeeWithCheckInPublicResourceTest.php
index 1780da0997..ffc090fe34 100644
--- a/backend/tests/Unit/Resources/Attendee/AttendeeWithCheckInPublicResourceTest.php
+++ b/backend/tests/Unit/Resources/Attendee/AttendeeWithCheckInPublicResourceTest.php
@@ -11,7 +11,7 @@ class AttendeeWithCheckInPublicResourceTest extends TestCase
{
public function test_public_check_in_attendee_resource_excludes_email(): void
{
- $attendee = (new AttendeeDomainObject())
+ $attendee = (new AttendeeDomainObject)
->setId(1)
->setOrderId(10)
->setProductId(20)
diff --git a/backend/tests/Unit/Resources/Event/EventResourcePublicTest.php b/backend/tests/Unit/Resources/Event/EventResourcePublicTest.php
new file mode 100644
index 0000000000..c0031e7f03
--- /dev/null
+++ b/backend/tests/Unit/Resources/Event/EventResourcePublicTest.php
@@ -0,0 +1,83 @@
+map(fn (int $id) => $this->makeOccurrence($id))
+ ->push($this->makeOccurrence(250));
+
+ $event = (new EventDomainObject)
+ ->setId(1)
+ ->setAccountId(1)
+ ->setUserId(1)
+ ->setTitle('Recurring show')
+ ->setShortId('event')
+ ->setType(EventType::RECURRING->name)
+ ->setCurrency('USD')
+ ->setTimezone('UTC')
+ ->setCreatedAt('2026-01-01 00:00:00')
+ ->setUpdatedAt('2026-01-01 00:00:00')
+ ->setEventOccurrences($occurrences);
+
+ $payload = (new EventResourcePublic($event))->toArray(new Request);
+ $resolvedOccurrences = $payload['occurrences']
+ ->map(fn ($occurrence) => $occurrence->resolve(new Request))
+ ->all();
+
+ $this->assertCount(201, $resolvedOccurrences);
+ $this->assertTrue(
+ collect($resolvedOccurrences)->contains(fn (array $occurrence) => $occurrence['id'] === 250)
+ );
+ }
+
+ public function test_public_resource_keeps_past_hidden_occurrence_for_single_event(): void
+ {
+ $pastOccurrence = $this->makeOccurrence(10)
+ ->setStartDate('2026-01-01 10:00:00')
+ ->setEndDate('2026-01-01 11:00:00');
+
+ $event = (new EventDomainObject)
+ ->setId(1)
+ ->setAccountId(1)
+ ->setUserId(1)
+ ->setTitle('Single show')
+ ->setShortId('event')
+ ->setType(EventType::SINGLE->name)
+ ->setCurrency('USD')
+ ->setTimezone('UTC')
+ ->setCreatedAt('2026-01-01 00:00:00')
+ ->setUpdatedAt('2026-01-01 00:00:00')
+ ->setEventOccurrences(collect([$pastOccurrence]));
+
+ $payload = (new EventResourcePublic($event))->toArray(new Request);
+ $resolvedOccurrences = $payload['occurrences']
+ ->map(fn ($occurrence) => $occurrence->resolve(new Request))
+ ->all();
+
+ $this->assertCount(1, $resolvedOccurrences);
+ $this->assertSame(10, $resolvedOccurrences[0]['id']);
+ }
+
+ private function makeOccurrence(int $id): EventOccurrenceDomainObject
+ {
+ return (new EventOccurrenceDomainObject)
+ ->setId($id)
+ ->setEventId(1)
+ ->setShortId((string) $id)
+ ->setStartDate('2027-01-01 10:00:00')
+ ->setEndDate('2027-01-01 11:00:00')
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name);
+ }
+}
diff --git a/backend/tests/Unit/Resources/Event/EventSettingsResourcePublicTest.php b/backend/tests/Unit/Resources/Event/EventSettingsResourcePublicTest.php
index 96fa0dccaa..fb1fe3146e 100644
--- a/backend/tests/Unit/Resources/Event/EventSettingsResourcePublicTest.php
+++ b/backend/tests/Unit/Resources/Event/EventSettingsResourcePublicTest.php
@@ -11,7 +11,7 @@ class EventSettingsResourcePublicTest extends TestCase
{
public function test_public_resource_exposes_allow_copy_details_when_enabled(): void
{
- $settings = (new EventSettingDomainObject())
+ $settings = (new EventSettingDomainObject)
->setAllowCopyDetailsToAllAttendees(true);
$resource = (new EventSettingsResourcePublic($settings))->toArray(Request::create('/'));
@@ -24,7 +24,7 @@ public function test_public_resource_exposes_allow_copy_details_when_enabled():
public function test_public_resource_exposes_allow_copy_details_when_disabled(): void
{
- $settings = (new EventSettingDomainObject())
+ $settings = (new EventSettingDomainObject)
->setAllowCopyDetailsToAllAttendees(false);
$resource = (new EventSettingsResourcePublic($settings))->toArray(Request::create('/'));
diff --git a/backend/tests/Unit/Resources/EventOccurrence/EventOccurrenceResourcePublicTest.php b/backend/tests/Unit/Resources/EventOccurrence/EventOccurrenceResourcePublicTest.php
new file mode 100644
index 0000000000..290732b9d9
--- /dev/null
+++ b/backend/tests/Unit/Resources/EventOccurrence/EventOccurrenceResourcePublicTest.php
@@ -0,0 +1,152 @@
+setId(1);
+ $occurrence->setEventId(1);
+ $occurrence->setStartDate('2026-07-10 10:00:00');
+ $occurrence->setStatus('ACTIVE');
+ $occurrence->setCapacity(100);
+ $occurrence->setUsedCapacity(10);
+ $occurrence->setShowAvailableCapacity($override);
+
+ return $occurrence;
+ }
+
+ private function serialize(EventOccurrenceDomainObject $occurrence, ?bool $eventDefault): array
+ {
+ $resource = $eventDefault === null
+ ? new EventOccurrenceResourcePublic($occurrence)
+ : new EventOccurrenceResourcePublic($occurrence, $eventDefault);
+
+ return $resource->resolve(Request::create('/'));
+ }
+
+ public function test_legacy_consumer_without_event_context_hides_capacity(): void
+ {
+ $result = $this->serialize($this->occurrence(null), null);
+
+ $this->assertArrayNotHasKey('available_capacity', $result);
+ $this->assertArrayNotHasKey('capacity', $result);
+ }
+
+ public function test_override_show_without_event_context_shows_capacity(): void
+ {
+ $result = $this->serialize($this->occurrence(true), null);
+
+ $this->assertArrayHasKey('available_capacity', $result);
+ $this->assertSame(90, $result['available_capacity']);
+ }
+
+ public function test_event_default_hidden_omits_capacity(): void
+ {
+ $result = $this->serialize($this->occurrence(null), false);
+
+ $this->assertArrayNotHasKey('available_capacity', $result);
+ $this->assertArrayNotHasKey('capacity', $result);
+ }
+
+ public function test_event_default_shown_includes_capacity(): void
+ {
+ $result = $this->serialize($this->occurrence(null), true);
+
+ $this->assertArrayHasKey('available_capacity', $result);
+ $this->assertSame(90, $result['available_capacity']);
+ }
+
+ public function test_occurrence_override_show_wins_over_hidden_default(): void
+ {
+ $result = $this->serialize($this->occurrence(true), false);
+
+ $this->assertArrayHasKey('available_capacity', $result);
+ }
+
+ public function test_occurrence_override_hide_wins_over_shown_default(): void
+ {
+ $result = $this->serialize($this->occurrence(false), true);
+
+ $this->assertArrayNotHasKey('available_capacity', $result);
+ $this->assertArrayNotHasKey('capacity', $result);
+ }
+
+ public function test_status_and_label_are_always_present(): void
+ {
+ $occurrence = $this->occurrence(null);
+ $occurrence->setLabel('Morning');
+
+ $result = $this->serialize($occurrence, false);
+
+ $this->assertSame('ACTIVE', $result['status']);
+ $this->assertSame('Morning', $result['label']);
+ }
+
+ private function onlineLocation(): EventLocationDomainObject
+ {
+ $eventLocation = new EventLocationDomainObject;
+ $eventLocation->setType('ONLINE');
+ $eventLocation->setOnlineEventConnectionDetails('Zoom link
');
+
+ return $eventLocation;
+ }
+
+ public function test_event_location_is_absent_when_not_loaded(): void
+ {
+ $result = $this->serialize($this->occurrence(null), null);
+
+ $this->assertArrayNotHasKey('event_location', $result);
+ }
+
+ public function test_in_person_event_location_includes_venue(): void
+ {
+ $location = new LocationDomainObject;
+ $location->setName('Grand Hall');
+ $location->setStructuredAddress(['venue_name' => 'Grand Hall', 'city' => 'Dublin']);
+
+ $eventLocation = new EventLocationDomainObject;
+ $eventLocation->setType('IN_PERSON');
+ $eventLocation->setLocation($location);
+
+ $occurrence = $this->occurrence(null);
+ $occurrence->setEventLocation($eventLocation);
+
+ $result = json_decode(json_encode($this->serialize($occurrence, null)), true);
+
+ $this->assertSame('IN_PERSON', $result['event_location']['type']);
+ $this->assertSame('Grand Hall', $result['event_location']['location']['name']);
+ $this->assertSame('Dublin', $result['event_location']['location']['structured_address']['city']);
+ }
+
+ public function test_online_connection_details_are_hidden_by_default(): void
+ {
+ $occurrence = $this->occurrence(null);
+ $occurrence->setEventLocation($this->onlineLocation());
+
+ $result = json_decode(json_encode($this->serialize($occurrence, null)), true);
+
+ $this->assertSame('ONLINE', $result['event_location']['type']);
+ $this->assertArrayNotHasKey('online_event_connection_details', $result['event_location']);
+ }
+
+ public function test_online_connection_details_are_included_when_flagged(): void
+ {
+ $occurrence = $this->occurrence(null);
+ $occurrence->setEventLocation($this->onlineLocation());
+
+ $resource = new EventOccurrenceResourcePublic($occurrence, false, true);
+ $result = json_decode(json_encode($resource->resolve(Request::create('/'))), true);
+
+ $this->assertSame('Zoom link
', $result['event_location']['online_event_connection_details']);
+ }
+}
diff --git a/backend/tests/Unit/Resources/Webhook/WebhookResourceTest.php b/backend/tests/Unit/Resources/Webhook/WebhookResourceTest.php
index 19690ceb35..1bc8a449ce 100644
--- a/backend/tests/Unit/Resources/Webhook/WebhookResourceTest.php
+++ b/backend/tests/Unit/Resources/Webhook/WebhookResourceTest.php
@@ -12,7 +12,7 @@ class WebhookResourceTest extends TestCase
{
private function createWebhookDomainObject(): WebhookDomainObject
{
- return (new WebhookDomainObject())
+ return (new WebhookDomainObject)
->setId(1)
->setUrl('https://example.com/webhook')
->setEventTypes(['order.created', 'attendee.created'])
diff --git a/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandlerTest.php
deleted file mode 100644
index 1ac7ca3c33..0000000000
--- a/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/CreateStripeConnectAccountHandlerTest.php
+++ /dev/null
@@ -1,90 +0,0 @@
-config = m::mock(Repository::class);
- $stripeClientFactory = m::mock(StripeClientFactory::class);
- $stripeConfigurationService = m::mock(StripeConfigurationService::class);
- $stripeAccountSyncService = m::mock(StripeAccountSyncService::class);
-
- $this->handler = new CreateStripeConnectAccountHandler(
- $accountRepository,
- $accountStripePlatformRepository,
- $databaseManager,
- $logger,
- $this->config,
- $stripeClientFactory,
- $stripeConfigurationService,
- $stripeAccountSyncService,
- );
- }
-
- public function testHandleThrowsExceptionWhenSaasModeDisabled(): void
- {
- $dto = new CreateStripeConnectAccountDTO(accountId: 1);
-
- $this->config
- ->shouldReceive('get')
- ->with('app.saas_mode_enabled')
- ->andReturn(false);
-
- $this->expectException(SaasModeEnabledException::class);
- $this->expectExceptionMessage('Stripe Connect Account creation is only available in Saas Mode.');
-
- $this->handler->handle($dto);
- }
-
- public function testHandleAllowsExecutionWhenSaasModeEnabled(): void
- {
- $dto = new CreateStripeConnectAccountDTO(accountId: 1);
-
- $this->config
- ->shouldReceive('get')
- ->with('app.saas_mode_enabled')
- ->andReturn(true);
-
- // We expect this to NOT throw the SaasModeEnabledException
- // It will fail later due to missing mocks, but that proves SaaS mode check passed
- try {
- $this->handler->handle($dto);
- } catch (SaasModeEnabledException $e) {
- $this->fail('Should not throw SaasModeEnabledException when saas mode is enabled');
- } catch (\Exception $e) {
- // Expected - will fail on missing mocks, but SaaS check passed
- $this->assertTrue(true);
- }
- }
-
- protected function tearDown(): void
- {
- m::close();
- parent::tearDown();
- }
-}
\ No newline at end of file
diff --git a/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/GetStripeConnectAccountsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/GetStripeConnectAccountsHandlerTest.php
deleted file mode 100644
index aa2f48f223..0000000000
--- a/backend/tests/Unit/Services/Application/Handlers/Account/Payment/Stripe/GetStripeConnectAccountsHandlerTest.php
+++ /dev/null
@@ -1,148 +0,0 @@
-accountRepository = m::mock(AccountRepositoryInterface::class);
- $stripeClientFactory = m::mock(StripeClientFactory::class);
- $stripeAccountSyncService = m::mock(StripeAccountSyncService::class);
- $logger = m::mock(LoggerInterface::class);
-
- $this->handler = new GetStripeConnectAccountsHandler(
- $this->accountRepository,
- $stripeClientFactory,
- $stripeAccountSyncService,
- $logger,
- );
- }
-
- public function testHandleReturnsEmptyCollectionWhenNoStripePlatforms(): void
- {
- $accountId = 1;
- $account = m::mock(AccountDomainObject::class);
-
- $this->accountRepository
- ->shouldReceive('loadRelation')
- ->with(AccountStripePlatformDomainObject::class)
- ->andReturnSelf();
-
- $this->accountRepository
- ->shouldReceive('findById')
- ->with($accountId)
- ->andReturn($account);
-
- $account
- ->shouldReceive('getAccountStripePlatforms')
- ->andReturn(null);
-
- $account
- ->shouldReceive('getActiveStripeAccountId')
- ->andReturn(null);
-
- $account
- ->shouldReceive('isStripeSetupComplete')
- ->andReturn(false);
-
- $result = $this->handler->handle($accountId);
-
- $this->assertSame($account, $result->account);
- $this->assertTrue($result->stripeConnectAccounts->isEmpty());
- $this->assertNull($result->primaryStripeAccountId);
- $this->assertFalse($result->hasCompletedSetup);
- }
-
- public function testHandleReturnsEmptyCollectionWhenStripePlatformsEmpty(): void
- {
- $accountId = 1;
- $account = m::mock(AccountDomainObject::class);
- $emptyCollection = collect([]);
-
- $this->accountRepository
- ->shouldReceive('loadRelation')
- ->with(AccountStripePlatformDomainObject::class)
- ->andReturnSelf();
-
- $this->accountRepository
- ->shouldReceive('findById')
- ->with($accountId)
- ->andReturn($account);
-
- $account
- ->shouldReceive('getAccountStripePlatforms')
- ->andReturn($emptyCollection);
-
- $account
- ->shouldReceive('getActiveStripeAccountId')
- ->andReturn(null);
-
- $account
- ->shouldReceive('isStripeSetupComplete')
- ->andReturn(false);
-
- $result = $this->handler->handle($accountId);
-
- $this->assertTrue($result->stripeConnectAccounts->isEmpty());
- }
-
- public function testHandleSkipsAccountWithoutStripeAccountId(): void
- {
- $accountId = 1;
- $account = m::mock(AccountDomainObject::class);
- $stripePlatform = m::mock(AccountStripePlatformDomainObject::class);
- $stripePlatforms = collect([$stripePlatform]);
-
- $this->accountRepository
- ->shouldReceive('loadRelation')
- ->with(AccountStripePlatformDomainObject::class)
- ->andReturnSelf();
-
- $this->accountRepository
- ->shouldReceive('findById')
- ->with($accountId)
- ->andReturn($account);
-
- $account
- ->shouldReceive('getAccountStripePlatforms')
- ->andReturn($stripePlatforms);
-
- $stripePlatform
- ->shouldReceive('getStripeAccountId')
- ->andReturn(null);
-
- $account
- ->shouldReceive('getActiveStripeAccountId')
- ->andReturn(null);
-
- $account
- ->shouldReceive('isStripeSetupComplete')
- ->andReturn(false);
-
- $result = $this->handler->handle($accountId);
-
- $this->assertTrue($result->stripeConnectAccounts->isEmpty());
- }
-
- protected function tearDown(): void
- {
- m::close();
- parent::tearDown();
- }
-}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Account/Vat/GetAccountVatSettingHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Account/Vat/GetAccountVatSettingHandlerTest.php
deleted file mode 100644
index 5eddde1a39..0000000000
--- a/backend/tests/Unit/Services/Application/Handlers/Account/Vat/GetAccountVatSettingHandlerTest.php
+++ /dev/null
@@ -1,59 +0,0 @@
-repository = Mockery::mock(AccountVatSettingRepositoryInterface::class);
- $this->handler = new GetAccountVatSettingHandler($this->repository);
- }
-
- public function testHandleReturnsVatSetting(): void
- {
- $accountId = 123;
- $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class);
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn($vatSetting);
-
- $result = $this->handler->handle($accountId);
-
- $this->assertSame($vatSetting, $result);
- }
-
- public function testHandleReturnsNullWhenNotFound(): void
- {
- $accountId = 456;
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn(null);
-
- $result = $this->handler->handle($accountId);
-
- $this->assertNull($result);
- }
-
- protected function tearDown(): void
- {
- Mockery::close();
- parent::tearDown();
- }
-}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Account/Vat/UpsertAccountVatSettingHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Account/Vat/UpsertAccountVatSettingHandlerTest.php
deleted file mode 100644
index 30f6932a92..0000000000
--- a/backend/tests/Unit/Services/Application/Handlers/Account/Vat/UpsertAccountVatSettingHandlerTest.php
+++ /dev/null
@@ -1,325 +0,0 @@
-repository = Mockery::mock(AccountVatSettingRepositoryInterface::class);
- $this->viesService = Mockery::mock(ViesValidationService::class);
- $this->logger = Mockery::mock(LoggerInterface::class);
- $this->logger->shouldReceive('info')->byDefault();
- $this->handler = new UpsertAccountVatSettingHandler(
- $this->repository,
- $this->viesService,
- $this->logger
- );
- }
-
- public function testHandleCreatesVatSettingWithSyncValidationSuccess(): void
- {
- $accountId = 123;
- $vatNumber = 'IE1234567A';
- $dto = new UpsertAccountVatSettingDTO(
- accountId: $accountId,
- vatRegistered: true,
- vatNumber: $vatNumber,
- );
-
- $validationResponse = new ViesValidationResponseDTO(
- valid: true,
- businessName: 'Test Company Ltd',
- businessAddress: '123 Test Street',
- countryCode: 'IE',
- vatNumber: '1234567A',
- isTransientError: false,
- );
-
- $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class);
- $vatSetting->shouldReceive('getId')->andReturn(1);
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn(null);
-
- $this->viesService
- ->shouldReceive('validateVatNumber')
- ->with($vatNumber)
- ->once()
- ->andReturn($validationResponse);
-
- $this->repository
- ->shouldReceive('create')
- ->once()
- ->withArgs(function ($data) use ($accountId, $vatNumber) {
- return $data['account_id'] === $accountId
- && $data['vat_registered'] === true
- && $data['vat_number'] === $vatNumber
- && $data['vat_validated'] === true
- && $data['vat_validation_status'] === VatValidationStatus::VALID->value
- && $data['business_name'] === 'Test Company Ltd'
- && $data['vat_country_code'] === 'IE';
- })
- ->andReturn($vatSetting);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($vatSetting, $result);
- Queue::assertNotPushed(ValidateVatNumberJob::class);
- }
-
- public function testHandleQueuesJobOnTransientError(): void
- {
- $accountId = 123;
- $vatNumber = 'IE1234567A';
- $dto = new UpsertAccountVatSettingDTO(
- accountId: $accountId,
- vatRegistered: true,
- vatNumber: $vatNumber,
- );
-
- $validationResponse = new ViesValidationResponseDTO(
- valid: false,
- countryCode: 'IE',
- vatNumber: '1234567A',
- isTransientError: true,
- errorMessage: 'VIES service is temporarily busy',
- );
-
- $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class);
- $vatSetting->shouldReceive('getId')->andReturn(1);
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn(null);
-
- $this->viesService
- ->shouldReceive('validateVatNumber')
- ->with($vatNumber)
- ->once()
- ->andReturn($validationResponse);
-
- $this->repository
- ->shouldReceive('create')
- ->once()
- ->withArgs(function ($data) use ($accountId, $vatNumber) {
- return $data['account_id'] === $accountId
- && $data['vat_registered'] === true
- && $data['vat_number'] === $vatNumber
- && $data['vat_validated'] === false
- && $data['vat_validation_status'] === VatValidationStatus::PENDING->value
- && $data['vat_country_code'] === 'IE';
- })
- ->andReturn($vatSetting);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($vatSetting, $result);
- Queue::assertPushed(ValidateVatNumberJob::class);
- }
-
- public function testHandleDoesNotQueueJobOnInvalidVatNumber(): void
- {
- $accountId = 123;
- $vatNumber = 'IE9999999ZZ';
- $dto = new UpsertAccountVatSettingDTO(
- accountId: $accountId,
- vatRegistered: true,
- vatNumber: $vatNumber,
- );
-
- $validationResponse = new ViesValidationResponseDTO(
- valid: false,
- countryCode: 'IE',
- vatNumber: '9999999ZZ',
- isTransientError: false,
- errorMessage: 'VAT number not found',
- );
-
- $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class);
- $vatSetting->shouldReceive('getId')->andReturn(1);
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn(null);
-
- $this->viesService
- ->shouldReceive('validateVatNumber')
- ->with($vatNumber)
- ->once()
- ->andReturn($validationResponse);
-
- $this->repository
- ->shouldReceive('create')
- ->once()
- ->withArgs(function ($data) use ($accountId, $vatNumber) {
- return $data['account_id'] === $accountId
- && $data['vat_registered'] === true
- && $data['vat_number'] === $vatNumber
- && $data['vat_validated'] === false
- && $data['vat_validation_status'] === VatValidationStatus::INVALID->value;
- })
- ->andReturn($vatSetting);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($vatSetting, $result);
- Queue::assertNotPushed(ValidateVatNumberJob::class);
- }
-
- public function testHandleCreatesVatSettingWithInvalidFormat(): void
- {
- $accountId = 123;
- $vatNumber = 'INVALID';
- $dto = new UpsertAccountVatSettingDTO(
- accountId: $accountId,
- vatRegistered: true,
- vatNumber: $vatNumber,
- );
-
- $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class);
- $vatSetting->shouldReceive('getId')->andReturn(1);
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn(null);
-
- $this->viesService
- ->shouldNotReceive('validateVatNumber');
-
- $this->repository
- ->shouldReceive('create')
- ->once()
- ->withArgs(function ($data) use ($accountId) {
- return $data['account_id'] === $accountId
- && $data['vat_registered'] === true
- && $data['vat_number'] === 'INVALID'
- && $data['vat_validated'] === false
- && $data['vat_validation_status'] === VatValidationStatus::INVALID->value
- && $data['business_name'] === null;
- })
- ->andReturn($vatSetting);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($vatSetting, $result);
- Queue::assertNotPushed(ValidateVatNumberJob::class);
- }
-
- public function testHandleCreatesVatSettingForNonRegistered(): void
- {
- $accountId = 123;
- $dto = new UpsertAccountVatSettingDTO(
- accountId: $accountId,
- vatRegistered: false,
- );
-
- $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class);
- $vatSetting->shouldReceive('getId')->andReturn(1);
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn(null);
-
- $this->viesService
- ->shouldNotReceive('validateVatNumber');
-
- $this->repository
- ->shouldReceive('create')
- ->once()
- ->withArgs(function ($data) use ($accountId) {
- return $data['account_id'] === $accountId
- && $data['vat_registered'] === false
- && $data['vat_number'] === null
- && $data['vat_validated'] === false;
- })
- ->andReturn($vatSetting);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($vatSetting, $result);
- Queue::assertNotPushed(ValidateVatNumberJob::class);
- }
-
- public function testHandleDoesNotValidateIfVatNumberUnchanged(): void
- {
- $accountId = 123;
- $existingId = 456;
- $vatNumber = 'DE123456789';
-
- $existing = Mockery::mock(AccountVatSettingDomainObject::class);
- $existing->shouldReceive('getId')->andReturn($existingId);
- $existing->shouldReceive('getVatNumber')->andReturn($vatNumber);
-
- $dto = new UpsertAccountVatSettingDTO(
- accountId: $accountId,
- vatRegistered: true,
- vatNumber: $vatNumber,
- );
-
- $updated = Mockery::mock(AccountVatSettingDomainObject::class);
- $updated->shouldReceive('getId')->andReturn($existingId);
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn($existing);
-
- $this->viesService
- ->shouldNotReceive('validateVatNumber');
-
- $this->repository
- ->shouldReceive('updateFromArray')
- ->once()
- ->with($existingId, Mockery::on(function ($data) use ($accountId, $vatNumber) {
- return $data['account_id'] === $accountId
- && $data['vat_registered'] === true
- && $data['vat_number'] === $vatNumber
- && !isset($data['vat_validated']);
- }))
- ->andReturn($updated);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($updated, $result);
- Queue::assertNotPushed(ValidateVatNumberJob::class);
- }
-
- protected function tearDown(): void
- {
- Mockery::close();
- parent::tearDown();
- }
-}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Admin/AssignConfigurationHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Admin/AssignConfigurationHandlerTest.php
deleted file mode 100644
index 4752bf23c8..0000000000
--- a/backend/tests/Unit/Services/Application/Handlers/Admin/AssignConfigurationHandlerTest.php
+++ /dev/null
@@ -1,79 +0,0 @@
-accountRepository = Mockery::mock(AccountRepositoryInterface::class);
- $this->configurationRepository = Mockery::mock(AccountConfigurationRepositoryInterface::class);
- $this->handler = new AssignConfigurationHandler(
- $this->accountRepository,
- $this->configurationRepository
- );
- }
-
- public function testHandleSuccessfullyAssignsConfiguration(): void
- {
- $accountId = 123;
- $configurationId = 456;
- $configuration = Mockery::mock(AccountConfigurationDomainObject::class);
- $account = Mockery::mock(AccountDomainObject::class);
-
- $this->configurationRepository
- ->shouldReceive('findById')
- ->with($configurationId)
- ->once()
- ->andReturn($configuration);
-
- $this->accountRepository
- ->shouldReceive('updateFromArray')
- ->with($accountId, ['account_configuration_id' => $configurationId])
- ->once()
- ->andReturn($account);
-
- $this->handler->handle($accountId, $configurationId);
-
- $this->assertTrue(true);
- }
-
- public function testHandleThrowsExceptionWhenConfigurationNotFound(): void
- {
- $accountId = 123;
- $configurationId = 999;
-
- $this->configurationRepository
- ->shouldReceive('findById')
- ->with($configurationId)
- ->once()
- ->andThrow(new ModelNotFoundException());
-
- $this->accountRepository
- ->shouldNotReceive('updateFromArray');
-
- $this->expectException(ModelNotFoundException::class);
-
- $this->handler->handle($accountId, $configurationId);
- }
-
- protected function tearDown(): void
- {
- Mockery::close();
- parent::tearDown();
- }
-}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Admin/DeleteConfigurationHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Admin/DeleteConfigurationHandlerTest.php
deleted file mode 100644
index 8844fb8c13..0000000000
--- a/backend/tests/Unit/Services/Application/Handlers/Admin/DeleteConfigurationHandlerTest.php
+++ /dev/null
@@ -1,95 +0,0 @@
-repository = Mockery::mock(AccountConfigurationRepositoryInterface::class);
- $this->handler = new DeleteConfigurationHandler($this->repository);
- }
-
- public function testHandleSuccessfullyDeletesConfiguration(): void
- {
- $configurationId = 123;
- $configuration = Mockery::mock(AccountConfigurationDomainObject::class);
-
- $configuration
- ->shouldReceive('getIsSystemDefault')
- ->once()
- ->andReturn(false);
-
- $this->repository
- ->shouldReceive('findById')
- ->with($configurationId)
- ->once()
- ->andReturn($configuration);
-
- $this->repository
- ->shouldReceive('deleteById')
- ->with($configurationId)
- ->once();
-
- $this->handler->handle($configurationId);
-
- $this->assertTrue(true);
- }
-
- public function testHandleThrowsExceptionWhenDeletingSystemDefault(): void
- {
- $configurationId = 1;
- $configuration = Mockery::mock(AccountConfigurationDomainObject::class);
-
- $configuration
- ->shouldReceive('getIsSystemDefault')
- ->once()
- ->andReturn(true);
-
- $this->repository
- ->shouldReceive('findById')
- ->with($configurationId)
- ->once()
- ->andReturn($configuration);
-
- $this->repository
- ->shouldNotReceive('deleteById');
-
- $this->expectException(CannotDeleteEntityException::class);
- $this->expectExceptionMessage('The system default configuration cannot be deleted.');
-
- $this->handler->handle($configurationId);
- }
-
- public function testHandleThrowsExceptionWhenConfigurationNotFound(): void
- {
- $configurationId = 999;
-
- $this->repository
- ->shouldReceive('findById')
- ->with($configurationId)
- ->once()
- ->andThrow(new \Illuminate\Database\Eloquent\ModelNotFoundException());
-
- $this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class);
-
- $this->handler->handle($configurationId);
- }
-
- protected function tearDown(): void
- {
- Mockery::close();
- parent::tearDown();
- }
-}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Admin/GetAccountHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Admin/GetAccountHandlerTest.php
deleted file mode 100644
index 343d34549d..0000000000
--- a/backend/tests/Unit/Services/Application/Handlers/Admin/GetAccountHandlerTest.php
+++ /dev/null
@@ -1,59 +0,0 @@
-repository = Mockery::mock(AccountRepositoryInterface::class);
- $this->handler = new GetAccountHandler($this->repository);
- }
-
- public function testHandleReturnsAccountWithDetails(): void
- {
- $accountId = 123;
- $account = Mockery::mock(Account::class);
-
- $this->repository
- ->shouldReceive('getAccountWithDetails')
- ->with($accountId)
- ->once()
- ->andReturn($account);
-
- $result = $this->handler->handle($accountId);
-
- $this->assertSame($account, $result);
- }
-
- public function testHandleThrowsExceptionWhenAccountNotFound(): void
- {
- $accountId = 999;
-
- $this->repository
- ->shouldReceive('getAccountWithDetails')
- ->with($accountId)
- ->once()
- ->andThrow(new \Illuminate\Database\Eloquent\ModelNotFoundException());
-
- $this->expectException(\Illuminate\Database\Eloquent\ModelNotFoundException::class);
-
- $this->handler->handle($accountId);
- }
-
- protected function tearDown(): void
- {
- Mockery::close();
- parent::tearDown();
- }
-}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Admin/GetAllOrdersHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Admin/GetAllOrdersHandlerTest.php
deleted file mode 100644
index 52010d725f..0000000000
--- a/backend/tests/Unit/Services/Application/Handlers/Admin/GetAllOrdersHandlerTest.php
+++ /dev/null
@@ -1,134 +0,0 @@
-repository = Mockery::mock(OrderRepositoryInterface::class);
- $this->handler = new GetAllOrdersHandler($this->repository);
- }
-
- public function testHandleReturnsPaginatedOrders(): void
- {
- $dto = new GetAllOrdersDTO(
- perPage: 20,
- search: null,
- sortBy: 'created_at',
- sortDirection: 'desc',
- );
-
- $paginator = Mockery::mock(LengthAwarePaginator::class);
-
- $this->repository
- ->shouldReceive('getAllOrdersForAdmin')
- ->with(null, 20, 'created_at', 'desc')
- ->once()
- ->andReturn($paginator);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($paginator, $result);
- }
-
- public function testHandleWithSearchQuery(): void
- {
- $dto = new GetAllOrdersDTO(
- perPage: 10,
- search: 'test@example.com',
- sortBy: 'created_at',
- sortDirection: 'desc',
- );
-
- $paginator = Mockery::mock(LengthAwarePaginator::class);
-
- $this->repository
- ->shouldReceive('getAllOrdersForAdmin')
- ->with('test@example.com', 10, 'created_at', 'desc')
- ->once()
- ->andReturn($paginator);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($paginator, $result);
- }
-
- public function testHandleWithCustomSorting(): void
- {
- $dto = new GetAllOrdersDTO(
- perPage: 25,
- search: null,
- sortBy: 'total_gross',
- sortDirection: 'asc',
- );
-
- $paginator = Mockery::mock(LengthAwarePaginator::class);
-
- $this->repository
- ->shouldReceive('getAllOrdersForAdmin')
- ->with(null, 25, 'total_gross', 'asc')
- ->once()
- ->andReturn($paginator);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($paginator, $result);
- }
-
- public function testHandleWithDefaultValues(): void
- {
- $dto = new GetAllOrdersDTO();
-
- $paginator = Mockery::mock(LengthAwarePaginator::class);
-
- $this->repository
- ->shouldReceive('getAllOrdersForAdmin')
- ->with(null, 20, 'created_at', 'desc')
- ->once()
- ->andReturn($paginator);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($paginator, $result);
- }
-
- public function testHandleWithNameSearch(): void
- {
- $dto = new GetAllOrdersDTO(
- perPage: 20,
- search: 'John Doe',
- sortBy: 'first_name',
- sortDirection: 'asc',
- );
-
- $paginator = Mockery::mock(LengthAwarePaginator::class);
-
- $this->repository
- ->shouldReceive('getAllOrdersForAdmin')
- ->with('John Doe', 20, 'first_name', 'asc')
- ->once()
- ->andReturn($paginator);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($paginator, $result);
- }
-
- protected function tearDown(): void
- {
- Mockery::close();
- parent::tearDown();
- }
-}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Admin/UpdateAccountConfigurationHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Admin/UpdateAccountConfigurationHandlerTest.php
deleted file mode 100644
index 2f594daaa7..0000000000
--- a/backend/tests/Unit/Services/Application/Handlers/Admin/UpdateAccountConfigurationHandlerTest.php
+++ /dev/null
@@ -1,170 +0,0 @@
-configurationRepository = Mockery::mock(AccountConfigurationRepositoryInterface::class);
- $this->accountRepository = Mockery::mock(AccountRepositoryInterface::class);
- $this->handler = new UpdateAccountConfigurationHandler(
- $this->configurationRepository,
- $this->accountRepository
- );
- }
-
- public function testHandleUpdatesExistingConfiguration(): void
- {
- $accountId = 123;
- $configurationId = 456;
- $applicationFees = ['fixed' => 100, 'percentage' => 2.5];
-
- $existingConfig = Mockery::mock(AccountConfigurationDomainObject::class);
- $existingConfig->shouldReceive('getId')->andReturn($configurationId);
-
- $account = Mockery::mock(AccountDomainObject::class);
- $account->shouldReceive('getConfiguration')->andReturn($existingConfig);
-
- $updatedConfig = Mockery::mock(AccountConfigurationDomainObject::class);
-
- $dto = new UpdateAccountConfigurationDTO(
- accountId: $accountId,
- applicationFees: $applicationFees,
- );
-
- $this->accountRepository
- ->shouldReceive('loadRelation')
- ->with('configuration')
- ->once()
- ->andReturnSelf();
-
- $this->accountRepository
- ->shouldReceive('findById')
- ->with($accountId)
- ->once()
- ->andReturn($account);
-
- $this->configurationRepository
- ->shouldReceive('updateFromArray')
- ->with($configurationId, ['application_fees' => $applicationFees])
- ->once()
- ->andReturn($updatedConfig);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($updatedConfig, $result);
- }
-
- public function testHandleCreatesNewConfigurationWhenNoneExists(): void
- {
- $accountId = 123;
- $applicationFees = ['fixed' => 50, 'percentage' => 1.5];
-
- $account = Mockery::mock(AccountDomainObject::class);
- $account->shouldReceive('getConfiguration')->andReturn(null);
- $account->shouldReceive('getId')->andReturn($accountId);
-
- $newConfig = Mockery::mock(AccountConfigurationDomainObject::class);
- $newConfig->shouldReceive('getId')->andReturn(789);
-
- $dto = new UpdateAccountConfigurationDTO(
- accountId: $accountId,
- applicationFees: $applicationFees,
- );
-
- $this->accountRepository
- ->shouldReceive('loadRelation')
- ->with('configuration')
- ->once()
- ->andReturnSelf();
-
- $this->accountRepository
- ->shouldReceive('findById')
- ->with($accountId)
- ->once()
- ->andReturn($account);
-
- $this->configurationRepository
- ->shouldReceive('create')
- ->with([
- 'name' => 'Account Configuration',
- 'is_system_default' => false,
- 'application_fees' => $applicationFees,
- ])
- ->once()
- ->andReturn($newConfig);
-
- $this->accountRepository
- ->shouldReceive('updateFromArray')
- ->with($accountId, ['account_configuration_id' => 789])
- ->once()
- ->andReturn($account);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($newConfig, $result);
- }
-
- public function testHandleWithZeroFees(): void
- {
- $accountId = 123;
- $configurationId = 456;
- $applicationFees = ['fixed' => 0, 'percentage' => 0];
-
- $existingConfig = Mockery::mock(AccountConfigurationDomainObject::class);
- $existingConfig->shouldReceive('getId')->andReturn($configurationId);
-
- $account = Mockery::mock(AccountDomainObject::class);
- $account->shouldReceive('getConfiguration')->andReturn($existingConfig);
-
- $updatedConfig = Mockery::mock(AccountConfigurationDomainObject::class);
-
- $dto = new UpdateAccountConfigurationDTO(
- accountId: $accountId,
- applicationFees: $applicationFees,
- );
-
- $this->accountRepository
- ->shouldReceive('loadRelation')
- ->with('configuration')
- ->once()
- ->andReturnSelf();
-
- $this->accountRepository
- ->shouldReceive('findById')
- ->with($accountId)
- ->once()
- ->andReturn($account);
-
- $this->configurationRepository
- ->shouldReceive('updateFromArray')
- ->with($configurationId, ['application_fees' => $applicationFees])
- ->once()
- ->andReturn($updatedConfig);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($updatedConfig, $result);
- }
-
- protected function tearDown(): void
- {
- Mockery::close();
- parent::tearDown();
- }
-}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Admin/UpdateAdminAccountVatSettingHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Admin/UpdateAdminAccountVatSettingHandlerTest.php
deleted file mode 100644
index 5a8e11b0b9..0000000000
--- a/backend/tests/Unit/Services/Application/Handlers/Admin/UpdateAdminAccountVatSettingHandlerTest.php
+++ /dev/null
@@ -1,185 +0,0 @@
-repository = Mockery::mock(AccountVatSettingRepositoryInterface::class);
- $this->handler = new UpdateAdminAccountVatSettingHandler($this->repository);
- }
-
- public function testHandleCreatesNewVatSetting(): void
- {
- $accountId = 123;
- $dto = new UpdateAdminAccountVatSettingDTO(
- accountId: $accountId,
- vatRegistered: true,
- vatNumber: 'DE123456789',
- vatValidated: true,
- businessName: 'Test Company',
- businessAddress: '123 Test St',
- vatCountryCode: 'DE',
- );
-
- $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class);
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn(null);
-
- $this->repository
- ->shouldReceive('create')
- ->once()
- ->withArgs(function ($data) use ($accountId) {
- return $data['account_id'] === $accountId
- && $data['vat_registered'] === true
- && $data['vat_number'] === 'DE123456789'
- && $data['vat_validated'] === true
- && $data['business_name'] === 'Test Company'
- && $data['business_address'] === '123 Test St'
- && $data['vat_country_code'] === 'DE';
- })
- ->andReturn($vatSetting);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($vatSetting, $result);
- }
-
- public function testHandleUpdatesExistingVatSetting(): void
- {
- $accountId = 123;
- $existingId = 456;
-
- $existing = Mockery::mock(AccountVatSettingDomainObject::class);
- $existing->shouldReceive('getId')->andReturn($existingId);
-
- $dto = new UpdateAdminAccountVatSettingDTO(
- accountId: $accountId,
- vatRegistered: true,
- vatNumber: 'IE1234567A',
- vatValidated: false,
- businessName: 'Updated Company',
- businessAddress: '456 New St',
- vatCountryCode: 'IE',
- );
-
- $updated = Mockery::mock(AccountVatSettingDomainObject::class);
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn($existing);
-
- $this->repository
- ->shouldReceive('updateFromArray')
- ->once()
- ->with($existingId, Mockery::on(function ($data) use ($accountId) {
- return $data['account_id'] === $accountId
- && $data['vat_registered'] === true
- && $data['vat_number'] === 'IE1234567A'
- && $data['vat_validated'] === false
- && $data['business_name'] === 'Updated Company'
- && $data['business_address'] === '456 New St'
- && $data['vat_country_code'] === 'IE';
- }))
- ->andReturn($updated);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($updated, $result);
- }
-
- public function testHandleCreatesNonRegisteredVatSetting(): void
- {
- $accountId = 123;
- $dto = new UpdateAdminAccountVatSettingDTO(
- accountId: $accountId,
- vatRegistered: false,
- );
-
- $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class);
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn(null);
-
- $this->repository
- ->shouldReceive('create')
- ->once()
- ->withArgs(function ($data) use ($accountId) {
- return $data['account_id'] === $accountId
- && $data['vat_registered'] === false
- && $data['vat_number'] === null
- && $data['vat_validated'] === false
- && $data['business_name'] === null
- && $data['business_address'] === null
- && $data['vat_country_code'] === null;
- })
- ->andReturn($vatSetting);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($vatSetting, $result);
- }
-
- public function testHandleWithPartialData(): void
- {
- $accountId = 123;
- $dto = new UpdateAdminAccountVatSettingDTO(
- accountId: $accountId,
- vatRegistered: true,
- vatNumber: 'FR12345678901',
- );
-
- $vatSetting = Mockery::mock(AccountVatSettingDomainObject::class);
-
- $this->repository
- ->shouldReceive('findByAccountId')
- ->with($accountId)
- ->once()
- ->andReturn(null);
-
- $this->repository
- ->shouldReceive('create')
- ->once()
- ->withArgs(function ($data) use ($accountId) {
- return $data['account_id'] === $accountId
- && $data['vat_registered'] === true
- && $data['vat_number'] === 'FR12345678901'
- && $data['vat_validated'] === false
- && $data['business_name'] === null
- && $data['business_address'] === null
- && $data['vat_country_code'] === null;
- })
- ->andReturn($vatSetting);
-
- $result = $this->handler->handle($dto);
-
- $this->assertSame($vatSetting, $result);
- }
-
- protected function tearDown(): void
- {
- Mockery::close();
- parent::tearDown();
- }
-}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Affiliate/CreateAffiliateHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Affiliate/CreateAffiliateHandlerTest.php
index 27ee074002..aa364adf3d 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Affiliate/CreateAffiliateHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Affiliate/CreateAffiliateHandlerTest.php
@@ -14,6 +14,7 @@
class CreateAffiliateHandlerTest extends TestCase
{
private AffiliateRepositoryInterface $affiliateRepository;
+
private CreateAffiliateHandler $handler;
protected function setUp(): void
@@ -24,7 +25,7 @@ protected function setUp(): void
$this->handler = new CreateAffiliateHandler($this->affiliateRepository);
}
- public function testHandleSuccessfullyCreatesAffiliate(): void
+ public function test_handle_successfully_creates_affiliate(): void
{
$eventId = 1;
$accountId = 2;
@@ -65,7 +66,7 @@ public function testHandleSuccessfullyCreatesAffiliate(): void
$this->assertSame($expectedAffiliate, $result);
}
- public function testHandleSuccessfullyCreatesAffiliateWithoutEmail(): void
+ public function test_handle_successfully_creates_affiliate_without_email(): void
{
$eventId = 1;
$accountId = 2;
@@ -106,7 +107,7 @@ public function testHandleSuccessfullyCreatesAffiliateWithoutEmail(): void
$this->assertSame($expectedAffiliate, $result);
}
- public function testHandleConvertsCodeToUppercase(): void
+ public function test_handle_converts_code_to_uppercase(): void
{
$eventId = 1;
$accountId = 2;
@@ -147,7 +148,7 @@ public function testHandleConvertsCodeToUppercase(): void
$this->assertSame($expectedAffiliate, $result);
}
- public function testHandleThrowsExceptionWhenAffiliateCodeAlreadyExists(): void
+ public function test_handle_throws_exception_when_affiliate_code_already_exists(): void
{
$eventId = 1;
$accountId = 2;
@@ -184,4 +185,4 @@ protected function tearDown(): void
m::close();
parent::tearDown();
}
-}
\ No newline at end of file
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Affiliate/DeleteAffiliateHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Affiliate/DeleteAffiliateHandlerTest.php
index 29bd353789..cb4ba4edf9 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Affiliate/DeleteAffiliateHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Affiliate/DeleteAffiliateHandlerTest.php
@@ -12,6 +12,7 @@
class DeleteAffiliateHandlerTest extends TestCase
{
private AffiliateRepositoryInterface $affiliateRepository;
+
private DeleteAffiliateHandler $handler;
protected function setUp(): void
@@ -22,7 +23,7 @@ protected function setUp(): void
$this->handler = new DeleteAffiliateHandler($this->affiliateRepository);
}
- public function testHandleSuccessfullyDeletesAffiliate(): void
+ public function test_handle_successfully_deletes_affiliate(): void
{
$affiliateId = 1;
$eventId = 2;
@@ -33,7 +34,7 @@ public function testHandleSuccessfullyDeletesAffiliate(): void
->once()
->with([
'id' => $affiliateId,
- 'event_id' => $eventId
+ 'event_id' => $eventId,
])
->andReturn($existingAffiliate);
@@ -48,7 +49,7 @@ public function testHandleSuccessfullyDeletesAffiliate(): void
$this->assertTrue(true);
}
- public function testHandleThrowsExceptionWhenAffiliateNotFound(): void
+ public function test_handle_throws_exception_when_affiliate_not_found(): void
{
$affiliateId = 1;
$eventId = 2;
@@ -58,7 +59,7 @@ public function testHandleThrowsExceptionWhenAffiliateNotFound(): void
->once()
->with([
'id' => $affiliateId,
- 'event_id' => $eventId
+ 'event_id' => $eventId,
])
->andReturn(null);
@@ -71,7 +72,7 @@ public function testHandleThrowsExceptionWhenAffiliateNotFound(): void
$this->handler->handle($affiliateId, $eventId);
}
- public function testHandleChecksCorrectEventId(): void
+ public function test_handle_checks_correct_event_id(): void
{
$affiliateId = 1;
$eventId = 2;
@@ -81,7 +82,7 @@ public function testHandleChecksCorrectEventId(): void
->once()
->with([
'id' => $affiliateId,
- 'event_id' => $eventId
+ 'event_id' => $eventId,
])
->andReturn(null);
@@ -91,7 +92,7 @@ public function testHandleChecksCorrectEventId(): void
$this->handler->handle($affiliateId, $eventId);
}
- public function testHandleValidatesAffiliateExistsBeforeDeleting(): void
+ public function test_handle_validates_affiliate_exists_before_deleting(): void
{
$affiliateId = 1;
$eventId = 2;
@@ -102,7 +103,7 @@ public function testHandleValidatesAffiliateExistsBeforeDeleting(): void
->once()
->with([
'id' => $affiliateId,
- 'event_id' => $eventId
+ 'event_id' => $eventId,
])
->andReturn($existingAffiliate);
@@ -117,7 +118,7 @@ public function testHandleValidatesAffiliateExistsBeforeDeleting(): void
$this->assertTrue(true);
}
- public function testHandleOnlyDeletesAffiliateFromCorrectEvent(): void
+ public function test_handle_only_deletes_affiliate_from_correct_event(): void
{
$affiliateId = 1;
$eventId = 2;
@@ -128,7 +129,7 @@ public function testHandleOnlyDeletesAffiliateFromCorrectEvent(): void
->once()
->with([
'id' => $affiliateId,
- 'event_id' => $eventId
+ 'event_id' => $eventId,
])
->andReturn(null);
diff --git a/backend/tests/Unit/Services/Application/Handlers/Affiliate/UpdateAffiliateHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Affiliate/UpdateAffiliateHandlerTest.php
index 6e85cf8cc7..5b4ab3cc95 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Affiliate/UpdateAffiliateHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Affiliate/UpdateAffiliateHandlerTest.php
@@ -5,8 +5,8 @@
use HiEvents\DomainObjects\AffiliateDomainObject;
use HiEvents\DomainObjects\Status\AffiliateStatus;
use HiEvents\Repository\Interfaces\AffiliateRepositoryInterface;
-use HiEvents\Services\Application\Handlers\Affiliate\UpdateAffiliateHandler;
use HiEvents\Services\Application\Handlers\Affiliate\DTO\UpsertAffiliateDTO;
+use HiEvents\Services\Application\Handlers\Affiliate\UpdateAffiliateHandler;
use Mockery as m;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Tests\TestCase;
@@ -14,6 +14,7 @@
class UpdateAffiliateHandlerTest extends TestCase
{
private AffiliateRepositoryInterface $affiliateRepository;
+
private UpdateAffiliateHandler $handler;
protected function setUp(): void
@@ -24,7 +25,7 @@ protected function setUp(): void
$this->handler = new UpdateAffiliateHandler($this->affiliateRepository);
}
- public function testHandleSuccessfullyUpdatesAffiliate(): void
+ public function test_handle_successfully_updates_affiliate(): void
{
$affiliateId = 1;
$eventId = 2;
@@ -43,7 +44,7 @@ public function testHandleSuccessfullyUpdatesAffiliate(): void
->once()
->with([
'id' => $affiliateId,
- 'event_id' => $eventId
+ 'event_id' => $eventId,
])
->andReturn($existingAffiliate);
@@ -62,7 +63,7 @@ public function testHandleSuccessfullyUpdatesAffiliate(): void
$this->assertSame($updatedAffiliate, $result);
}
- public function testHandleSuccessfullyUpdatesAffiliateWithNullEmail(): void
+ public function test_handle_successfully_updates_affiliate_with_null_email(): void
{
$affiliateId = 1;
$eventId = 2;
@@ -81,7 +82,7 @@ public function testHandleSuccessfullyUpdatesAffiliateWithNullEmail(): void
->once()
->with([
'id' => $affiliateId,
- 'event_id' => $eventId
+ 'event_id' => $eventId,
])
->andReturn($existingAffiliate);
@@ -99,7 +100,7 @@ public function testHandleSuccessfullyUpdatesAffiliateWithNullEmail(): void
$this->assertSame($updatedAffiliate, $result);
}
- public function testHandleFiltersOutNullValues(): void
+ public function test_handle_filters_out_null_values(): void
{
$affiliateId = 1;
$eventId = 2;
@@ -118,7 +119,7 @@ public function testHandleFiltersOutNullValues(): void
->once()
->with([
'id' => $affiliateId,
- 'event_id' => $eventId
+ 'event_id' => $eventId,
])
->andReturn($existingAffiliate);
@@ -136,7 +137,7 @@ public function testHandleFiltersOutNullValues(): void
$this->assertSame($updatedAffiliate, $result);
}
- public function testHandleThrowsExceptionWhenAffiliateNotFound(): void
+ public function test_handle_throws_exception_when_affiliate_not_found(): void
{
$affiliateId = 1;
$eventId = 2;
@@ -152,7 +153,7 @@ public function testHandleThrowsExceptionWhenAffiliateNotFound(): void
->once()
->with([
'id' => $affiliateId,
- 'event_id' => $eventId
+ 'event_id' => $eventId,
])
->andReturn(null);
@@ -165,7 +166,7 @@ public function testHandleThrowsExceptionWhenAffiliateNotFound(): void
$this->handler->handle($affiliateId, $eventId, $dto);
}
- public function testHandleChecksCorrectEventId(): void
+ public function test_handle_checks_correct_event_id(): void
{
$affiliateId = 1;
$eventId = 2;
@@ -181,7 +182,7 @@ public function testHandleChecksCorrectEventId(): void
->once()
->with([
'id' => $affiliateId,
- 'event_id' => $eventId
+ 'event_id' => $eventId,
])
->andReturn(null);
@@ -196,4 +197,4 @@ protected function tearDown(): void
m::close();
parent::tearDown();
}
-}
\ No newline at end of file
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeeDetailPublicHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeeDetailPublicHandlerTest.php
new file mode 100644
index 0000000000..f0d5f0d8a3
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeeDetailPublicHandlerTest.php
@@ -0,0 +1,179 @@
+checkInListRepository = m::mock(CheckInListRepositoryInterface::class);
+ $this->attendeeRepository = m::mock(AttendeeRepositoryInterface::class);
+
+ $this->handler = new GetCheckInListAttendeeDetailPublicHandler(
+ $this->attendeeRepository,
+ $this->checkInListRepository,
+ new CheckInListActivityValidator,
+ );
+ }
+
+ public function test_handle_throws_not_found_if_check_in_list_missing(): void
+ {
+ $this->checkInListRepository
+ ->shouldReceive('loadRelation')->andReturnSelf();
+ $this->checkInListRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturnNull();
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle('short-id', 'A-123', null);
+ }
+
+ public function test_handle_throws_not_found_if_attendee_missing(): void
+ {
+ $checkInList = $this->buildList(eventId: 5);
+
+ $this->checkInListRepository
+ ->shouldReceive('loadRelation')->andReturnSelf();
+ $this->checkInListRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn($checkInList);
+
+ $this->attendeeRepository
+ ->shouldReceive('loadRelation')->andReturnSelf();
+ $this->attendeeRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['public_id' => 'A-123', 'event_id' => 5])
+ ->andReturnNull();
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle('short-id', 'A-123', null);
+ }
+
+ public function test_anonymous_request_respects_list_visibility_flags(): void
+ {
+ $checkInList = $this->buildList(
+ eventId: 5,
+ accountId: 77,
+ showNotes: false,
+ showQuestions: true,
+ showOrderDetails: false,
+ );
+ $attendee = m::mock(AttendeeDomainObject::class);
+ $attendee->shouldReceive('getCheckIns')->andReturn(null);
+
+ $this->setupRepos($checkInList, $attendee);
+
+ $result = $this->handler->handle('short-id', 'A-123', null);
+
+ $this->assertFalse($result->showNotes);
+ $this->assertTrue($result->showQuestionAnswers);
+ $this->assertFalse($result->showOrderDetails);
+ }
+
+ public function test_authenticated_staff_bypasses_visibility_flags(): void
+ {
+ $checkInList = $this->buildList(
+ eventId: 5,
+ accountId: 77,
+ showNotes: false,
+ showQuestions: false,
+ showOrderDetails: false,
+ );
+ $attendee = m::mock(AttendeeDomainObject::class);
+ $attendee->shouldReceive('getCheckIns')->andReturn(null);
+
+ $this->setupRepos($checkInList, $attendee);
+
+ $result = $this->handler->handle('short-id', 'A-123', staffAccountId: 77);
+
+ $this->assertTrue($result->showNotes);
+ $this->assertTrue($result->showQuestionAnswers);
+ $this->assertTrue($result->showOrderDetails);
+ }
+
+ public function test_authenticated_user_from_different_account_still_filtered(): void
+ {
+ $checkInList = $this->buildList(
+ eventId: 5,
+ accountId: 77,
+ showNotes: false,
+ showQuestions: false,
+ showOrderDetails: false,
+ );
+ $attendee = m::mock(AttendeeDomainObject::class);
+ $attendee->shouldReceive('getCheckIns')->andReturn(null);
+
+ $this->setupRepos($checkInList, $attendee);
+
+ $result = $this->handler->handle('short-id', 'A-123', staffAccountId: 88);
+
+ $this->assertFalse($result->showNotes);
+ $this->assertFalse($result->showQuestionAnswers);
+ $this->assertFalse($result->showOrderDetails);
+ }
+
+ private function buildList(
+ int $eventId = 5,
+ int $accountId = 1,
+ bool $showNotes = true,
+ bool $showQuestions = true,
+ bool $showOrderDetails = true,
+ ): CheckInListDomainObject {
+ $event = m::mock(EventDomainObject::class);
+ $event->shouldReceive('getAccountId')->andReturn($accountId);
+
+ $checkInList = m::mock(CheckInListDomainObject::class);
+ $checkInList->shouldReceive('getId')->andReturn(1);
+ $checkInList->shouldReceive('getEventId')->andReturn($eventId);
+ $checkInList->shouldReceive('getEvent')->andReturn($event);
+ $checkInList->shouldReceive('getExpiresAt')->andReturn(null);
+ $checkInList->shouldReceive('getActivatesAt')->andReturn(null);
+ $checkInList->shouldReceive('getProducts')->andReturn(new Collection);
+ $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(null);
+ $checkInList->shouldReceive('getPublicShowAttendeeNotes')->andReturn($showNotes);
+ $checkInList->shouldReceive('getPublicShowQuestionAnswers')->andReturn($showQuestions);
+ $checkInList->shouldReceive('getPublicShowOrderDetails')->andReturn($showOrderDetails);
+
+ return $checkInList;
+ }
+
+ private function setupRepos(CheckInListDomainObject $checkInList, $attendee): void
+ {
+ $this->checkInListRepository
+ ->shouldReceive('loadRelation')->andReturnSelf();
+ $this->checkInListRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn($checkInList);
+
+ $this->attendeeRepository
+ ->shouldReceive('loadRelation')->andReturnSelf();
+ $this->attendeeRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn($attendee);
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeePublicHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeePublicHandlerTest.php
index d07cf0bae8..78b5e9fde6 100644
--- a/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeePublicHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeePublicHandlerTest.php
@@ -8,6 +8,8 @@
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface;
use HiEvents\Services\Application\Handlers\CheckInList\Public\GetCheckInListAttendeePublicHandler;
+use HiEvents\Services\Domain\CheckInList\CheckInListActivityValidator;
+use Illuminate\Support\Collection;
use Mockery as m;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Tests\TestCase;
@@ -15,7 +17,9 @@
class GetCheckInListAttendeePublicHandlerTest extends TestCase
{
private CheckInListRepositoryInterface $checkInListRepository;
+
private AttendeeRepositoryInterface $attendeeRepository;
+
private GetCheckInListAttendeePublicHandler $handler;
protected function setUp(): void
@@ -27,11 +31,12 @@ protected function setUp(): void
$this->handler = new GetCheckInListAttendeePublicHandler(
$this->attendeeRepository,
- $this->checkInListRepository
+ $this->checkInListRepository,
+ new CheckInListActivityValidator,
);
}
- public function testHandleThrowsNotFoundIfCheckInListMissing(): void
+ public function test_handle_throws_not_found_if_check_in_list_missing(): void
{
$this->checkInListRepository
->shouldReceive('loadRelation')
@@ -48,7 +53,7 @@ public function testHandleThrowsNotFoundIfCheckInListMissing(): void
$this->handler->handle('short-id', 'attendee-public-id');
}
- public function testHandleThrowsCannotCheckInIfListExpired(): void
+ public function test_handle_throws_cannot_check_in_if_list_expired(): void
{
$checkInList = m::mock(CheckInListDomainObject::class);
$checkInList->shouldReceive('getExpiresAt')->twice()->andReturn(now()->subMinute());
@@ -68,7 +73,7 @@ public function testHandleThrowsCannotCheckInIfListExpired(): void
$this->handler->handle('short-id', 'attendee-public-id');
}
- public function testHandleThrowsCannotCheckInIfListNotActiveYet(): void
+ public function test_handle_throws_cannot_check_in_if_list_not_active_yet(): void
{
$checkInList = m::mock(CheckInListDomainObject::class);
$checkInList->shouldReceive('getExpiresAt')->once()->andReturn(null);
@@ -89,12 +94,14 @@ public function testHandleThrowsCannotCheckInIfListNotActiveYet(): void
$this->handler->handle('short-id', 'attendee-public-id');
}
- public function testHandleReturnsAttendeeSuccessfully(): void
+ public function test_handle_returns_attendee_successfully(): void
{
$checkInList = m::mock(CheckInListDomainObject::class);
$checkInList->shouldReceive('getExpiresAt')->once()->andReturn(null);
$checkInList->shouldReceive('getActivatesAt')->once()->andReturn(null);
$checkInList->shouldReceive('getEventId')->once()->andReturn(123);
+ $checkInList->shouldReceive('getProducts')->once()->andReturn(new Collection);
+ $checkInList->shouldReceive('getEventOccurrenceId')->once()->andReturn(null);
$attendee = m::mock(AttendeeDomainObject::class);
diff --git a/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandlerTest.php
new file mode 100644
index 0000000000..3610d69e29
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListAttendeesPublicHandlerTest.php
@@ -0,0 +1,150 @@
+checkInListRepository = m::mock(CheckInListRepositoryInterface::class);
+ $this->attendeeRepository = m::mock(AttendeeRepositoryInterface::class);
+
+ $this->handler = new GetCheckInListAttendeesPublicHandler(
+ $this->attendeeRepository,
+ $this->checkInListRepository,
+ new CheckInListActivityValidator,
+ );
+ }
+
+ private function buildCheckInList(?int $occurrenceId): CheckInListDomainObject
+ {
+ $list = m::mock(CheckInListDomainObject::class);
+ $list->shouldReceive('getId')->andReturn(1);
+ $list->shouldReceive('getEventOccurrenceId')->andReturn($occurrenceId);
+ $list->shouldReceive('getExpiresAt')->andReturn(null);
+ $list->shouldReceive('getActivatesAt')->andReturn(null);
+
+ return $list;
+ }
+
+ private function expectCheckInListLoaded(CheckInListDomainObject $list): void
+ {
+ $this->checkInListRepository
+ ->shouldReceive('loadRelation')->andReturnSelf();
+ $this->checkInListRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn($list);
+ }
+
+ private function emptyAttendeePaginator(): Paginator
+ {
+ return new Paginator(collect([]), 10);
+ }
+
+ public function test_injects_check_in_list_occurrence_filter_when_list_is_scoped(): void
+ {
+ $this->expectCheckInListLoaded($this->buildCheckInList(occurrenceId: 42));
+
+ $capturedParams = null;
+ $this->attendeeRepository
+ ->shouldReceive('getAttendeesByCheckInShortId')
+ ->once()
+ ->with('short-id', m::on(function (QueryParamsDTO $params) use (&$capturedParams) {
+ $capturedParams = $params;
+
+ return true;
+ }))
+ ->andReturn($this->emptyAttendeePaginator());
+
+ $this->handler->handle('short-id', new QueryParamsDTO);
+
+ $this->assertNotNull($capturedParams);
+ $filter = $capturedParams->filter_fields->firstWhere('field', 'event_occurrence_id');
+ $this->assertNotNull($filter, 'expected event_occurrence_id filter to be injected');
+ $this->assertSame('42', $filter->value);
+ }
+
+ public function test_overrides_client_supplied_occurrence_filter_for_scoped_list(): void
+ {
+ $this->expectCheckInListLoaded($this->buildCheckInList(occurrenceId: 42));
+
+ $capturedParams = null;
+ $this->attendeeRepository
+ ->shouldReceive('getAttendeesByCheckInShortId')
+ ->once()
+ ->with('short-id', m::on(function (QueryParamsDTO $params) use (&$capturedParams) {
+ $capturedParams = $params;
+
+ return true;
+ }))
+ ->andReturn($this->emptyAttendeePaginator());
+
+ $clientParams = new QueryParamsDTO(filter_fields: collect([
+ new FilterFieldDTO(field: 'event_occurrence_id', operator: 'eq', value: '99'),
+ new FilterFieldDTO(field: 'status', operator: 'eq', value: 'ACTIVE'),
+ ]));
+
+ $this->handler->handle('short-id', $clientParams);
+
+ /** @var Collection $filters */
+ $filters = $capturedParams->filter_fields;
+ $occurrenceFilters = $filters->where('field', 'event_occurrence_id');
+ $this->assertCount(1, $occurrenceFilters, 'client filter must be replaced, not appended');
+ $this->assertSame('42', $occurrenceFilters->first()->value);
+
+ $this->assertCount(1, $filters->where('field', 'status'));
+ }
+
+ public function test_leaves_client_supplied_occurrence_filter_alone_when_list_is_not_scoped(): void
+ {
+ $this->expectCheckInListLoaded($this->buildCheckInList(occurrenceId: null));
+
+ $capturedParams = null;
+ $this->attendeeRepository
+ ->shouldReceive('getAttendeesByCheckInShortId')
+ ->once()
+ ->with('short-id', m::on(function (QueryParamsDTO $params) use (&$capturedParams) {
+ $capturedParams = $params;
+
+ return true;
+ }))
+ ->andReturn($this->emptyAttendeePaginator());
+
+ $clientParams = new QueryParamsDTO(filter_fields: collect([
+ new FilterFieldDTO(field: 'event_occurrence_id', operator: 'eq', value: '77'),
+ ]));
+
+ $this->handler->handle('short-id', $clientParams);
+
+ $filter = $capturedParams->filter_fields->firstWhere('field', 'event_occurrence_id');
+ $this->assertNotNull($filter);
+ $this->assertSame('77', $filter->value);
+ }
+
+ protected function tearDown(): void
+ {
+ m::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListStatsPublicHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListStatsPublicHandlerTest.php
new file mode 100644
index 0000000000..260ebfde33
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/CheckInList/Public/GetCheckInListStatsPublicHandlerTest.php
@@ -0,0 +1,221 @@
+checkInListRepository = m::mock(CheckInListRepositoryInterface::class);
+
+ $this->handler = new GetCheckInListStatsPublicHandler(
+ $this->checkInListRepository,
+ new CheckInListActivityValidator,
+ );
+ }
+
+ public function test_handle_throws_not_found_if_check_in_list_missing(): void
+ {
+ $this->checkInListRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['short_id' => 'short-id'])
+ ->andReturnNull();
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle('short-id');
+ }
+
+ public function test_handle_returns_stats_dto(): void
+ {
+ $checkInList = m::mock(CheckInListDomainObject::class);
+ $checkInList->shouldReceive('getId')->andReturn(42);
+ $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(null);
+ $checkInList->shouldReceive('getExpiresAt')->andReturn(null);
+ $checkInList->shouldReceive('getActivatesAt')->andReturn(null);
+
+ $this->checkInListRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['short_id' => 'short-id'])
+ ->andReturn($checkInList);
+
+ $this->checkInListRepository
+ ->shouldReceive('getCheckedInAttendeeCountById')
+ ->once()
+ ->with(42, null)
+ ->andReturn(new CheckedInAttendeesCountDTO(
+ checkInListId: 42,
+ checkedInCount: 5,
+ totalAttendeesCount: 20,
+ ));
+
+ $productStats = collect([
+ new CheckInListProductStatDTO(
+ productId: 1,
+ productTitle: 'VIP',
+ totalAttendees: 10,
+ checkedInAttendees: 3,
+ ),
+ new CheckInListProductStatDTO(
+ productId: 2,
+ productTitle: 'General',
+ totalAttendees: 10,
+ checkedInAttendees: 2,
+ ),
+ ]);
+
+ $this->checkInListRepository
+ ->shouldReceive('getPerProductCheckInStatsById')
+ ->once()
+ ->with(42, null)
+ ->andReturn($productStats);
+
+ $recentCheckIns = collect([
+ new CheckInListRecentCheckInDTO(
+ attendeePublicId: 'A-AAAAAAAA',
+ firstName: 'Alice',
+ lastName: 'Smith',
+ productTitle: 'VIP',
+ checkedInAt: '2026-04-20T10:00:00Z',
+ ),
+ ]);
+
+ $this->checkInListRepository
+ ->shouldReceive('getRecentCheckInsById')
+ ->once()
+ ->with(42, 20, null)
+ ->andReturn($recentCheckIns);
+
+ $stats = $this->handler->handle('short-id');
+
+ $this->assertSame(20, $stats->totalAttendees);
+ $this->assertSame(5, $stats->checkedInAttendees);
+ $this->assertCount(2, $stats->perProduct);
+ $this->assertSame('VIP', $stats->perProduct[0]->productTitle);
+ $this->assertSame(3, $stats->perProduct[0]->checkedInAttendees);
+ $this->assertCount(1, $stats->recentCheckIns);
+ $this->assertSame('Alice', $stats->recentCheckIns[0]->firstName);
+ }
+
+ public function test_scoped_list_ignores_client_occurrence_filter(): void
+ {
+ $checkInList = m::mock(CheckInListDomainObject::class);
+ $checkInList->shouldReceive('getId')->andReturn(42);
+ $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(99);
+ $checkInList->shouldReceive('getExpiresAt')->andReturn(null);
+ $checkInList->shouldReceive('getActivatesAt')->andReturn(null);
+
+ $this->checkInListRepository
+ ->shouldReceive('findFirstWhere')->once()
+ ->andReturn($checkInList);
+
+ $this->checkInListRepository->shouldReceive('getCheckedInAttendeeCountById')
+ ->once()->with(42, null)
+ ->andReturn(new CheckedInAttendeesCountDTO(checkInListId: 42, checkedInCount: 0, totalAttendeesCount: 0));
+
+ $this->checkInListRepository->shouldReceive('getPerProductCheckInStatsById')
+ ->once()->with(42, null)
+ ->andReturn(collect());
+
+ $this->checkInListRepository->shouldReceive('getRecentCheckInsById')
+ ->once()->with(42, 20, null)
+ ->andReturn(collect());
+
+ $this->handler->handle('short-id', 77);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_unscoped_list_respects_client_occurrence_filter(): void
+ {
+ $checkInList = m::mock(CheckInListDomainObject::class);
+ $checkInList->shouldReceive('getId')->andReturn(42);
+ $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(null);
+ $checkInList->shouldReceive('getExpiresAt')->andReturn(null);
+ $checkInList->shouldReceive('getActivatesAt')->andReturn(null);
+
+ $this->checkInListRepository
+ ->shouldReceive('findFirstWhere')->once()
+ ->andReturn($checkInList);
+
+ $this->checkInListRepository->shouldReceive('getCheckedInAttendeeCountById')
+ ->once()->with(42, 77)
+ ->andReturn(new CheckedInAttendeesCountDTO(checkInListId: 42, checkedInCount: 0, totalAttendeesCount: 0));
+
+ $this->checkInListRepository->shouldReceive('getPerProductCheckInStatsById')
+ ->once()->with(42, 77)
+ ->andReturn(collect());
+
+ $this->checkInListRepository->shouldReceive('getRecentCheckInsById')
+ ->once()->with(42, 20, 77)
+ ->andReturn(collect());
+
+ $this->handler->handle('short-id', 77);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_handle_throws_when_list_is_expired(): void
+ {
+ $checkInList = m::mock(CheckInListDomainObject::class);
+ $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(null);
+ $checkInList->shouldReceive('getExpiresAt')->andReturn('2020-01-01T00:00:00Z');
+ $checkInList->shouldReceive('getActivatesAt')->andReturn(null);
+
+ $this->checkInListRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['short_id' => 'short-id'])
+ ->andReturn($checkInList);
+
+ $this->checkInListRepository->shouldReceive('getCheckedInAttendeeCountById')->never();
+ $this->checkInListRepository->shouldReceive('getPerProductCheckInStatsById')->never();
+ $this->checkInListRepository->shouldReceive('getRecentCheckInsById')->never();
+
+ $this->expectException(CannotCheckInException::class);
+
+ $this->handler->handle('short-id');
+ }
+
+ public function test_handle_throws_when_list_is_not_yet_active(): void
+ {
+ $checkInList = m::mock(CheckInListDomainObject::class);
+ $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(null);
+ $checkInList->shouldReceive('getExpiresAt')->andReturn(null);
+ $checkInList->shouldReceive('getActivatesAt')->andReturn('2100-01-01T00:00:00Z');
+
+ $this->checkInListRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['short_id' => 'short-id'])
+ ->andReturn($checkInList);
+
+ $this->checkInListRepository->shouldReceive('getCheckedInAttendeeCountById')->never();
+ $this->checkInListRepository->shouldReceive('getPerProductCheckInStatsById')->never();
+ $this->checkInListRepository->shouldReceive('getRecentCheckInsById')->never();
+
+ $this->expectException(CannotCheckInException::class);
+
+ $this->handler->handle('short-id');
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Event/DeleteEventHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Event/DeleteEventHandlerTest.php
index 460c8b1cbf..089b2b7a9c 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Event/DeleteEventHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Event/DeleteEventHandlerTest.php
@@ -10,12 +10,13 @@
use HiEvents\Services\Domain\Event\EventDeletionService;
use Illuminate\Database\DatabaseManager;
use Mockery as m;
-use Tests\TestCase;
use Psr\Log\LoggerInterface;
+use Tests\TestCase;
class DeleteEventHandlerTest extends TestCase
{
private EventDeletionService $eventDeletionService;
+
private DeleteEventHandler $handler;
protected function setUp(): void
@@ -28,7 +29,7 @@ protected function setUp(): void
$databaseManager = m::mock(DatabaseManager::class);
$databaseManager->shouldReceive('transaction')
- ->andReturnUsing(fn($callback) => $callback());
+ ->andReturnUsing(fn ($callback) => $callback());
$this->eventDeletionService = new EventDeletionService(
$eventRepository,
@@ -55,9 +56,10 @@ protected function setUp(): void
}
private OrderRepositoryInterface $orderRepository;
+
private EventRepositoryInterface $eventRepository;
- public function testDeleteEventSuccessfully(): void
+ public function test_delete_event_successfully(): void
{
$this->orderRepository->shouldReceive('countWhere')
->once()
@@ -76,7 +78,7 @@ public function testDeleteEventSuccessfully(): void
$this->assertTrue(true);
}
- public function testDeleteEventFailsWithCompletedOrders(): void
+ public function test_delete_event_fails_with_completed_orders(): void
{
$this->orderRepository->shouldReceive('countWhere')
->once()
diff --git a/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php
index 9dffed4623..9dd40136ae 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php
@@ -2,23 +2,41 @@
namespace Tests\Unit\Services\Application\Handlers\Event;
+use Closure;
+use HiEvents\DomainObjects\Enums\EventType;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
+use HiEvents\DomainObjects\EventSettingDomainObject;
+use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract;
+use HiEvents\DomainObjects\ProductCategoryDomainObject;
+use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\PromoCodeDomainObject;
+use HiEvents\DomainObjects\Status\EventOccurrenceStatus;
+use HiEvents\Repository\Eloquent\Value\OrderAndDirection;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface;
use HiEvents\Services\Application\Handlers\Event\DTO\GetPublicEventDTO;
use HiEvents\Services\Application\Handlers\Event\GetPublicEventHandler;
use HiEvents\Services\Domain\Event\EventPageViewIncrementService;
+use HiEvents\Services\Domain\EventOccurrence\PublicOccurrenceVisibilityService;
use HiEvents\Services\Domain\Product\ProductFilterService;
+use Illuminate\Support\Collection;
use Mockery as m;
use Tests\TestCase;
class GetPublicEventHandlerTest extends TestCase
{
private EventRepositoryInterface $eventRepository;
+
+ private EventOccurrenceRepositoryInterface $occurrenceRepository;
+
private PromoCodeRepositoryInterface $promoCodeRepository;
+
private ProductFilterService $ticketFilterService;
+
private EventPageViewIncrementService $eventPageViewIncrementService;
+
private GetPublicEventHandler $handler;
protected function setUp(): void
@@ -26,22 +44,25 @@ protected function setUp(): void
parent::setUp();
$this->eventRepository = m::mock(EventRepositoryInterface::class);
+ $this->occurrenceRepository = m::mock(EventOccurrenceRepositoryInterface::class);
$this->promoCodeRepository = m::mock(PromoCodeRepositoryInterface::class);
$this->ticketFilterService = m::mock(ProductFilterService::class);
$this->eventPageViewIncrementService = m::mock(EventPageViewIncrementService::class);
$this->handler = new GetPublicEventHandler(
$this->eventRepository,
+ $this->occurrenceRepository,
$this->promoCodeRepository,
$this->ticketFilterService,
- $this->eventPageViewIncrementService
+ $this->eventPageViewIncrementService,
+ new PublicOccurrenceVisibilityService,
);
}
- public function testHandleWithoutPromoCodeAndUnauthenticatedUser(): void
+ public function test_handle_without_promo_code_and_unauthenticated_user(): void
{
$data = new GetPublicEventDTO(eventId: 1, isAuthenticated: false, ipAddress: '127.0.0.1', promoCode: null);
- $event = new EventDomainObject();
+ $event = new EventDomainObject;
$event->setProductCategories(collect());
$this->setupEventRepositoryMock($event, $data->eventId);
@@ -52,10 +73,10 @@ public function testHandleWithoutPromoCodeAndUnauthenticatedUser(): void
$this->handler->handle($data);
}
- public function testHandleWithInvalidPromoCode(): void
+ public function test_handle_with_invalid_promo_code(): void
{
$data = new GetPublicEventDTO(eventId: 1, isAuthenticated: false, ipAddress: '127.0.0.1', promoCode: 'INVALID');
- $event = new EventDomainObject();
+ $event = new EventDomainObject;
$event->setProductCategories(collect());
$promoCode = m::mock(PromoCodeDomainObject::class)->makePartial();
$promoCode->shouldReceive('isValid')->andReturn(false);
@@ -68,10 +89,10 @@ public function testHandleWithInvalidPromoCode(): void
$this->handler->handle($data);
}
- public function testHandleWithValidPromoCode(): void
+ public function test_handle_with_valid_promo_code(): void
{
$data = new GetPublicEventDTO(eventId: 1, isAuthenticated: false, ipAddress: '127.0.0.1', promoCode: 'VALID');
- $event = new EventDomainObject();
+ $event = new EventDomainObject;
$event->setProductCategories(collect());
$promoCode = m::mock(PromoCodeDomainObject::class)->makePartial();
$promoCode->shouldReceive('isValid')->andReturn(true);
@@ -84,9 +105,535 @@ public function testHandleWithValidPromoCode(): void
$this->handler->handle($data);
}
+ public function test_handle_loads_single_event_occurrence_without_future_filter(): void
+ {
+ $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: true, ipAddress: '127.0.0.1', promoCode: null);
+ $event = (new EventDomainObject)
+ ->setType(EventType::SINGLE->name)
+ ->setProductCategories(collect());
+ $pastOccurrence = $this->makeOccurrence(10, '2024-01-01 10:00:00');
+
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event);
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->with(
+ m::on(static fn (array $where): bool => ! collect($where)->contains(
+ fn ($condition): bool => $condition instanceof Closure
+ || (is_array($condition) && ($condition[0] ?? null) === EventOccurrenceDomainObjectAbstract::START_DATE)
+ )),
+ m::any(),
+ m::any(),
+ m::any(),
+ )
+ ->andReturn(collect([$pastOccurrence]));
+ $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
+ $this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect());
+ $this->eventPageViewIncrementService->shouldNotReceive('increment');
+
+ $result = $this->handler->handle($data);
+
+ $this->assertTrue($result->getEventOccurrences()->contains(
+ fn (EventOccurrenceDomainObject $occurrence) => $occurrence->getId() === 10
+ ));
+ }
+
+ public function test_handle_ignores_requested_occurrence_when_it_does_not_belong_to_event(): void
+ {
+ $data = new GetPublicEventDTO(
+ eventId: 1,
+ isAuthenticated: true,
+ ipAddress: '127.0.0.1',
+ promoCode: null,
+ eventOccurrenceId: 999,
+ );
+ $event = new EventDomainObject;
+ $event->setProductCategories(collect());
+
+ $this->setupEventRepositoryMock($event, $data->eventId);
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => 999,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => 1,
+ [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name],
+ ])
+ ->andReturnNull();
+ $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
+
+ $capturedOccurrenceId = 'not-called';
+ $this->ticketFilterService
+ ->shouldReceive('filter')
+ ->once()
+ ->andReturnUsing(function (
+ Collection $productsCategories,
+ ?PromoCodeDomainObject $promoCode = null,
+ bool $hideSoldOutProducts = true,
+ ?int $eventOccurrenceId = null,
+ ) use (&$capturedOccurrenceId) {
+ $capturedOccurrenceId = $eventOccurrenceId;
+
+ return collect();
+ });
+ $this->eventPageViewIncrementService->shouldNotReceive('increment');
+
+ $this->handler->handle($data);
+
+ $this->assertNull($capturedOccurrenceId);
+ }
+
+ public function test_handle_keeps_requested_occurrence_outside_public_cap(): void
+ {
+ $linkedOccurrenceId = 5000;
+ $data = new GetPublicEventDTO(
+ eventId: 1,
+ isAuthenticated: true,
+ ipAddress: '127.0.0.1',
+ promoCode: null,
+ eventOccurrenceId: $linkedOccurrenceId,
+ );
+ $event = new EventDomainObject;
+ $event->setProductCategories(collect());
+
+ $loadedLimit = GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1;
+ $occurrences = collect(range(1, $loadedLimit))
+ ->map(fn (int $id) => $this->makeOccurrence($id, '2026-01-01 10:00:00'));
+ $linkedOccurrence = $this->makeOccurrence($linkedOccurrenceId, '2027-01-01 10:00:00');
+
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event);
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->with(m::any(), m::any(), m::any(), m::on(static fn ($limit) => $limit === $loadedLimit))
+ ->andReturn($occurrences);
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => $linkedOccurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => 1,
+ [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name],
+ ])
+ ->andReturn($linkedOccurrence);
+ $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
+
+ $capturedOccurrenceId = null;
+ $this->ticketFilterService
+ ->shouldReceive('filter')
+ ->once()
+ ->andReturnUsing(function (
+ Collection $productsCategories,
+ ?PromoCodeDomainObject $promoCode = null,
+ bool $hideSoldOutProducts = true,
+ ?int $eventOccurrenceId = null,
+ ) use (&$capturedOccurrenceId) {
+ $capturedOccurrenceId = $eventOccurrenceId;
+
+ return collect();
+ });
+ $this->eventPageViewIncrementService->shouldNotReceive('increment');
+
+ $result = $this->handler->handle($data);
+
+ $this->assertSame($linkedOccurrenceId, $capturedOccurrenceId);
+ $this->assertTrue(
+ $result->getEventOccurrences()->contains(
+ fn (EventOccurrenceDomainObject $occurrence) => $occurrence->getId() === $linkedOccurrenceId
+ )
+ );
+ $this->assertCount(GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1, $result->getEventOccurrences());
+ }
+
+ public function test_handle_excludes_sold_out_occurrences_when_recurring_event_hides_them(): void
+ {
+ $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: true, ipAddress: '127.0.0.1', promoCode: null);
+ $event = (new EventDomainObject)
+ ->setType(EventType::RECURRING->name)
+ ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(true))
+ ->setProductCategories(collect());
+
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event);
+ $this->expectEdgeOccurrenceQueries();
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->with(
+ m::on(static fn (array $where): bool => collect($where)->filter(
+ static fn ($condition): bool => $condition instanceof Closure
+ )->count() === 2),
+ m::any(),
+ m::any(),
+ GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1,
+ )
+ ->andReturn(collect());
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
+ $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
+ $this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect());
+ $this->eventPageViewIncrementService->shouldNotReceive('increment');
+
+ $result = $this->handler->handle($data);
+
+ $this->assertFalse($result->getUpcomingOccurrencesSoldOut());
+ }
+
+ public function test_handle_keeps_sold_out_occurrences_when_event_has_waitlist_enabled_products(): void
+ {
+ $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: true, ipAddress: '127.0.0.1', promoCode: null);
+
+ $waitlistCategory = new ProductCategoryDomainObject;
+ $waitlistCategory->setProducts(collect([
+ (new ProductDomainObject)->setWaitlistEnabled(true),
+ ]));
+
+ $event = (new EventDomainObject)
+ ->setType(EventType::RECURRING->name)
+ ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(true))
+ ->setProductCategories(collect([$waitlistCategory]));
+
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event);
+ $this->expectEdgeOccurrenceQueries();
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->with(
+ m::on(static fn (array $where): bool => collect($where)->filter(
+ static fn ($condition): bool => $condition instanceof Closure
+ )->count() === 1),
+ m::any(),
+ m::any(),
+ GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1,
+ )
+ ->andReturn(collect());
+ $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
+ $this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect());
+ $this->eventPageViewIncrementService->shouldNotReceive('increment');
+
+ $result = $this->handler->handle($data);
+
+ $this->assertFalse($result->getUpcomingOccurrencesSoldOut());
+ }
+
+ public function test_handle_keeps_sold_out_occurrences_when_hiding_disabled(): void
+ {
+ $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: true, ipAddress: '127.0.0.1', promoCode: null);
+ $event = (new EventDomainObject)
+ ->setType(EventType::RECURRING->name)
+ ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(false))
+ ->setProductCategories(collect());
+
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event);
+ $this->expectEdgeOccurrenceQueries();
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->with(
+ m::on(static fn (array $where): bool => collect($where)->filter(
+ static fn ($condition): bool => $condition instanceof Closure
+ )->count() === 1
+ && collect($where)->contains(
+ static fn ($condition): bool => is_array($condition)
+ && ($condition[0] ?? null) === EventOccurrenceDomainObjectAbstract::START_DATE
+ )),
+ m::any(),
+ m::any(),
+ GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1,
+ )
+ ->andReturn(collect());
+ $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
+ $this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect());
+ $this->eventPageViewIncrementService->shouldNotReceive('increment');
+
+ $result = $this->handler->handle($data);
+
+ $this->assertFalse($result->getUpcomingOccurrencesSoldOut());
+ }
+
+ public function test_handle_ignores_requested_sold_out_occurrence_when_hidden(): void
+ {
+ $data = new GetPublicEventDTO(
+ eventId: 1,
+ isAuthenticated: true,
+ ipAddress: '127.0.0.1',
+ promoCode: null,
+ eventOccurrenceId: 50,
+ );
+ $event = (new EventDomainObject)
+ ->setType(EventType::RECURRING->name)
+ ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(true))
+ ->setProductCategories(collect());
+
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event);
+ $this->occurrenceRepository->shouldReceive('findWhere')->andReturn(collect());
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(m::on(static fn (array $where): bool => ($where[EventOccurrenceDomainObjectAbstract::ID] ?? null) === 50
+ && ($where[EventOccurrenceDomainObjectAbstract::EVENT_ID] ?? null) === 1
+ && collect($where)->contains(static fn ($condition): bool => $condition instanceof Closure)))
+ ->andReturnNull();
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(m::on(static fn (array $where): bool => ! array_key_exists(EventOccurrenceDomainObjectAbstract::ID, $where)
+ && collect($where)->contains(static fn ($condition): bool => $condition instanceof Closure)))
+ ->andReturn(
+ $this->makeOccurrence(50, '2027-01-01 10:00:00')->setCapacity(10)->setUsedCapacity(10)
+ );
+ $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
+
+ $capturedOccurrenceId = 'not-called';
+ $this->ticketFilterService
+ ->shouldReceive('filter')
+ ->once()
+ ->andReturnUsing(function (
+ Collection $productsCategories,
+ ?PromoCodeDomainObject $promoCode = null,
+ bool $hideSoldOutProducts = true,
+ ?int $eventOccurrenceId = null,
+ ) use (&$capturedOccurrenceId) {
+ $capturedOccurrenceId = $eventOccurrenceId;
+
+ return collect();
+ });
+ $this->eventPageViewIncrementService->shouldNotReceive('increment');
+
+ $result = $this->handler->handle($data);
+
+ $this->assertNull($capturedOccurrenceId);
+ $this->assertTrue($result->getUpcomingOccurrencesSoldOut());
+ }
+
+ public function test_handle_flags_upcoming_occurrences_sold_out_when_all_hidden(): void
+ {
+ $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: true, ipAddress: '127.0.0.1', promoCode: null);
+ $event = (new EventDomainObject)
+ ->setType(EventType::RECURRING->name)
+ ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(true))
+ ->setProductCategories(collect());
+
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event);
+ $this->occurrenceRepository->shouldReceive('findWhere')->andReturn(collect());
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(m::on(static fn (array $where): bool => ($where[EventOccurrenceDomainObjectAbstract::EVENT_ID] ?? null) === 1
+ && collect($where)->contains(
+ static fn ($condition): bool => is_array($condition)
+ && ($condition[0] ?? null) === EventOccurrenceDomainObjectAbstract::STATUS
+ && ($condition[2] ?? null) === EventOccurrenceStatus::CANCELLED->name
+ )
+ && collect($where)->filter(static fn ($condition): bool => $condition instanceof Closure)->count() === 2))
+ ->andReturn(
+ $this->makeOccurrence(7, '2027-01-01 10:00:00')->setCapacity(10)->setUsedCapacity(10)
+ );
+ $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
+ $this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect());
+ $this->eventPageViewIncrementService->shouldNotReceive('increment');
+
+ $result = $this->handler->handle($data);
+
+ $this->assertTrue($result->getUpcomingOccurrencesSoldOut());
+ }
+
+ public function test_handle_windows_recurring_occurrences_to_anchor_month_in_event_timezone(): void
+ {
+ $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: true, ipAddress: '127.0.0.1', promoCode: null);
+ $event = (new EventDomainObject)
+ ->setType(EventType::RECURRING->name)
+ ->setTimezone('America/New_York')
+ ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(false))
+ ->setProductCategories(collect());
+
+ $nextBookable = $this->makeOccurrence(1, '2026-08-01 02:00:00');
+ $lastOccurrence = $this->makeOccurrence(99, '2027-06-30 22:00:00');
+ $windowOccurrence = $this->makeOccurrence(2, '2026-08-01 03:00:00');
+
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event);
+ $this->expectEdgeOccurrenceQueries($nextBookable, $lastOccurrence);
+
+ $capturedWindowWhere = null;
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->with(m::any(), m::any(), m::any(), GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1)
+ ->andReturnUsing(static function (array $where) use (&$capturedWindowWhere, $windowOccurrence) {
+ $capturedWindowWhere = $where;
+
+ return collect([$windowOccurrence]);
+ });
+
+ $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
+ $this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect());
+ $this->eventPageViewIncrementService->shouldNotReceive('increment');
+
+ $result = $this->handler->handle($data);
+
+ $bounds = collect($capturedWindowWhere)
+ ->filter(static fn ($condition): bool => is_array($condition)
+ && ($condition[0] ?? null) === EventOccurrenceDomainObjectAbstract::START_DATE)
+ ->values();
+
+ $this->assertSame('>=', $bounds[0][1]);
+ $this->assertSame('2026-07-01 04:00:00', $bounds[0][2]);
+ $this->assertSame('<=', $bounds[1][1]);
+ $this->assertSame('2026-08-01 03:59:59', $bounds[1][2]);
+ $this->assertSame('2026-07', $result->getOccurrencesMonth());
+ $this->assertSame('2026-08-01 02:00:00', $result->getNextOccurrenceStartDate());
+ $this->assertSame('2027-06-30 22:00:00', $result->getLastOccurrenceStartDate());
+ }
+
+ public function test_handle_anchors_window_on_deep_linked_occurrence_month(): void
+ {
+ $linkedOccurrenceId = 42;
+ $data = new GetPublicEventDTO(
+ eventId: 1,
+ isAuthenticated: true,
+ ipAddress: '127.0.0.1',
+ promoCode: null,
+ eventOccurrenceId: $linkedOccurrenceId,
+ );
+ $event = (new EventDomainObject)
+ ->setType(EventType::RECURRING->name)
+ ->setTimezone('UTC')
+ ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(false))
+ ->setProductCategories(collect());
+
+ $linkedOccurrence = $this->makeOccurrence($linkedOccurrenceId, '2027-03-15 10:00:00');
+ $nextBookable = $this->makeOccurrence(1, '2026-08-02 10:00:00');
+
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event);
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(m::on(static fn (array $where): bool => ($where[EventOccurrenceDomainObjectAbstract::ID] ?? null) === $linkedOccurrenceId))
+ ->andReturn($linkedOccurrence);
+ $this->expectEdgeOccurrenceQueries($nextBookable, $linkedOccurrence);
+
+ $capturedWindowWhere = null;
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->with(m::any(), m::any(), m::any(), GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1)
+ ->andReturnUsing(static function (array $where) use (&$capturedWindowWhere, $linkedOccurrence) {
+ $capturedWindowWhere = $where;
+
+ return collect([$linkedOccurrence]);
+ });
+
+ $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
+ $this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect());
+ $this->eventPageViewIncrementService->shouldNotReceive('increment');
+
+ $result = $this->handler->handle($data);
+
+ $bounds = collect($capturedWindowWhere)
+ ->filter(static fn ($condition): bool => is_array($condition)
+ && ($condition[0] ?? null) === EventOccurrenceDomainObjectAbstract::START_DATE)
+ ->values();
+
+ $this->assertSame('2027-03-01 00:00:00', $bounds[0][2]);
+ $this->assertSame('2027-03-31 23:59:59', $bounds[1][2]);
+ $this->assertSame('2027-03', $result->getOccurrencesMonth());
+ $this->assertSame('2026-08-02 10:00:00', $result->getNextOccurrenceStartDate());
+ $this->assertTrue($result->getEventOccurrences()->contains(
+ fn (EventOccurrenceDomainObject $occurrence) => $occurrence->getId() === $linkedOccurrenceId
+ ));
+ }
+
+ public function test_handle_sets_null_occurrences_month_when_anchor_month_truncated(): void
+ {
+ $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: true, ipAddress: '127.0.0.1', promoCode: null);
+ $event = (new EventDomainObject)
+ ->setType(EventType::RECURRING->name)
+ ->setTimezone('UTC')
+ ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(false))
+ ->setProductCategories(collect());
+
+ $nextBookable = $this->makeOccurrence(1, '2026-08-01 10:00:00');
+ $windowOccurrences = collect(range(1, GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1))
+ ->map(fn (int $id) => $this->makeOccurrence($id, '2026-08-01 10:00:00'));
+
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event);
+ $this->expectEdgeOccurrenceQueries($nextBookable, $nextBookable);
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->with(m::any(), m::any(), m::any(), GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1)
+ ->andReturn($windowOccurrences);
+
+ $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
+ $this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect());
+ $this->eventPageViewIncrementService->shouldNotReceive('increment');
+
+ $result = $this->handler->handle($data);
+
+ $this->assertNull($result->getOccurrencesMonth());
+ $this->assertCount(GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES, $result->getEventOccurrences());
+ }
+
+ private function expectEdgeOccurrenceQueries(
+ ?EventOccurrenceDomainObject $nextBookable = null,
+ ?EventOccurrenceDomainObject $lastOccurrence = null,
+ ): void {
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->with(
+ m::any(),
+ m::any(),
+ m::on(static fn (array $orders): bool => ($orders[0] ?? null) instanceof OrderAndDirection
+ && $orders[0]->getDirection() === OrderAndDirection::DIRECTION_ASC),
+ 1,
+ )
+ ->andReturn(collect(array_filter([$nextBookable])));
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->with(
+ m::any(),
+ m::any(),
+ m::on(static fn (array $orders): bool => ($orders[0] ?? null) instanceof OrderAndDirection
+ && $orders[0]->getDirection() === OrderAndDirection::DIRECTION_DESC),
+ 1,
+ )
+ ->andReturn(collect(array_filter([$lastOccurrence])));
+ }
+
private function setupEventRepositoryMock($event, $eventId): void
{
- $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf()->times(4);
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->eventRepository->shouldReceive('findById')->with($eventId)->andReturn($event);
+ $this->occurrenceRepository->shouldReceive('findWhere')->andReturn(collect());
+ }
+
+ private function makeOccurrence(int $id, string $startDate): EventOccurrenceDomainObject
+ {
+ return (new EventOccurrenceDomainObject)
+ ->setId($id)
+ ->setEventId(1)
+ ->setShortId((string) $id)
+ ->setStartDate($startDate)
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name);
}
}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventHandlerTest.php
new file mode 100644
index 0000000000..d2548b0955
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventHandlerTest.php
@@ -0,0 +1,176 @@
+eventRepository = Mockery::mock(EventRepositoryInterface::class);
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class);
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->purifier = Mockery::mock(HtmlPurifierService::class);
+ $this->dispatcher = Mockery::mock(Dispatcher::class);
+
+ $databaseManager = Mockery::mock(DatabaseManager::class);
+ $databaseManager->shouldReceive('transaction')->andReturnUsing(fn ($cb) => $cb());
+
+ $this->handler = new UpdateEventHandler(
+ $this->eventRepository,
+ $this->dispatcher,
+ $databaseManager,
+ $this->orderRepository,
+ $this->purifier,
+ $this->occurrenceRepository,
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ public function test_throws_when_changing_currency_with_completed_orders(): void
+ {
+ $existing = Mockery::mock(EventDomainObject::class);
+ $existing->shouldReceive('getCurrency')->andReturn('USD');
+
+ $this->eventRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn($existing);
+
+ $completedOrder = Mockery::mock(OrderDomainObject::class);
+ $this->orderRepository
+ ->shouldReceive('findWhere')
+ ->with([
+ 'event_id' => 1,
+ 'status' => OrderStatus::COMPLETED->name,
+ ])
+ ->andReturn(new Collection([$completedOrder]));
+
+ $this->expectException(CannotChangeCurrencyException::class);
+
+ $this->handler->handle(new UpdateEventDTO(
+ title: 'Event',
+ category: null,
+ account_id: 5,
+ id: 1,
+ currency: 'EUR',
+ ));
+ }
+
+ public function test_allows_currency_change_when_no_completed_orders(): void
+ {
+ $existing = Mockery::mock(EventDomainObject::class);
+ $existing->shouldReceive('getCurrency')->andReturn('USD');
+ $existing->shouldReceive('getCategory')->andReturn('OTHER');
+ $existing->shouldReceive('getTimezone')->andReturn('UTC');
+ $existing->shouldReceive('getType')->andReturn('OTHER_NOT_SINGLE');
+
+ $reloaded = Mockery::mock(EventDomainObject::class);
+ $reloaded->shouldReceive('getId')->andReturn(1);
+
+ $this->eventRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn($existing, $reloaded);
+
+ $this->orderRepository
+ ->shouldReceive('findWhere')
+ ->with([
+ 'event_id' => 1,
+ 'status' => OrderStatus::COMPLETED->name,
+ ])
+ ->andReturn(new Collection);
+
+ $this->purifier->shouldReceive('purify')->andReturn(null);
+
+ $this->eventRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ Mockery::on(fn ($attrs) => ($attrs['currency'] ?? null) === 'EUR'),
+ ['id' => 1, 'account_id' => 5],
+ );
+
+ $this->dispatcher->shouldReceive('dispatchEvent')->once();
+
+ $result = $this->handler->handle(new UpdateEventDTO(
+ title: 'Event',
+ category: null,
+ account_id: 5,
+ id: 1,
+ currency: 'EUR',
+ ));
+
+ $this->assertSame($reloaded, $result);
+ }
+
+ public function test_skips_completed_orders_check_when_currency_unchanged(): void
+ {
+ $existing = Mockery::mock(EventDomainObject::class);
+ $existing->shouldReceive('getCurrency')->andReturn('USD');
+ $existing->shouldReceive('getCategory')->andReturn('OTHER');
+ $existing->shouldReceive('getTimezone')->andReturn('UTC');
+ $existing->shouldReceive('getType')->andReturn('OTHER_NOT_SINGLE');
+
+ $reloaded = Mockery::mock(EventDomainObject::class);
+ $reloaded->shouldReceive('getId')->andReturn(1);
+
+ $this->eventRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn($existing, $reloaded);
+
+ $this->orderRepository->shouldNotReceive('findWhere');
+ $this->purifier->shouldReceive('purify')->andReturn(null);
+ $this->eventRepository->shouldReceive('updateWhere')->once();
+ $this->dispatcher->shouldReceive('dispatchEvent')->once();
+
+ $result = $this->handler->handle(new UpdateEventDTO(
+ title: 'Event',
+ category: null,
+ account_id: 5,
+ id: 1,
+ currency: 'USD',
+ ));
+
+ $this->assertSame($reloaded, $result);
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventLocationHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventLocationHandlerTest.php
new file mode 100644
index 0000000000..6394c244ab
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/Event/UpdateEventLocationHandlerTest.php
@@ -0,0 +1,172 @@
+eventRepository = Mockery::mock(EventRepositoryInterface::class);
+ $this->eventRepository->shouldReceive('loadRelation')->zeroOrMoreTimes()->andReturnSelf()->byDefault();
+ $this->eventLocationUpserter = Mockery::mock(EventLocationUpserter::class);
+ $this->eventLocationCleaner = Mockery::mock(EventLocationCleaner::class);
+
+ $databaseManager = Mockery::mock(DatabaseManager::class);
+ $databaseManager->shouldReceive('transaction')->andReturnUsing(fn ($callback) => $callback());
+
+ $this->handler = new UpdateEventLocationHandler(
+ $this->eventRepository,
+ $this->eventLocationUpserter,
+ $this->eventLocationCleaner,
+ $databaseManager,
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ public function test_creates_event_location_when_event_has_none(): void
+ {
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getEventLocationId')->andReturn(null);
+
+ $reloaded = Mockery::mock(EventDomainObject::class);
+
+ $this->eventRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn($event, $reloaded);
+
+ $created = Mockery::mock(EventLocationDomainObject::class);
+ $created->shouldReceive('getId')->andReturn(42);
+
+ $data = new EventLocationData(type: LocationType::IN_PERSON, location_id: 7);
+
+ $this->eventLocationUpserter
+ ->shouldReceive('createForEvent')
+ ->once()
+ ->with(1, 5, $data)
+ ->andReturn($created);
+
+ $this->eventRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(['event_location_id' => 42], ['id' => 1, 'account_id' => 5]);
+
+ $this->eventLocationCleaner->shouldNotReceive('deleteIfOrphaned');
+
+ $dto = new UpdateEventLocationDTO(event_id: 1, account_id: 5, event_location: $data);
+
+ $this->assertSame($reloaded, $this->handler->handle($dto));
+ }
+
+ public function test_updates_existing_event_location_in_place(): void
+ {
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getEventLocationId')->andReturn(99);
+
+ $reloaded = Mockery::mock(EventDomainObject::class);
+
+ $this->eventRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn($event, $reloaded);
+
+ $data = new EventLocationData(type: LocationType::IN_PERSON, location_id: 7);
+
+ $this->eventLocationUpserter
+ ->shouldReceive('updateInPlace')
+ ->once()
+ ->with(99, 1, 5, $data)
+ ->andReturn(Mockery::mock(EventLocationDomainObject::class));
+
+ $this->eventRepository->shouldNotReceive('updateWhere');
+ $this->eventLocationCleaner->shouldNotReceive('deleteIfOrphaned');
+
+ $dto = new UpdateEventLocationDTO(event_id: 1, account_id: 5, event_location: $data);
+
+ $this->assertSame($reloaded, $this->handler->handle($dto));
+ }
+
+ public function test_clear_nulls_fk_and_cleans_orphan(): void
+ {
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getEventLocationId')->andReturn(99);
+
+ $reloaded = Mockery::mock(EventDomainObject::class);
+
+ $this->eventRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn($event, $reloaded);
+
+ $this->eventRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(['event_location_id' => null], ['id' => 1, 'account_id' => 5]);
+
+ $this->eventLocationCleaner
+ ->shouldReceive('deleteIfOrphaned')
+ ->once()
+ ->with(99);
+
+ $dto = new UpdateEventLocationDTO(event_id: 1, account_id: 5, clear_event_location: true);
+
+ $this->assertSame($reloaded, $this->handler->handle($dto));
+ }
+
+ public function test_clear_noop_when_event_has_no_location(): void
+ {
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getEventLocationId')->andReturn(null);
+
+ $this->eventRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn($event, $event);
+
+ $this->eventRepository->shouldNotReceive('updateWhere');
+ $this->eventLocationCleaner->shouldNotReceive('deleteIfOrphaned');
+ $this->eventLocationUpserter->shouldNotReceive('createForEvent');
+ $this->eventLocationUpserter->shouldNotReceive('updateInPlace');
+
+ $dto = new UpdateEventLocationDTO(event_id: 1, account_id: 5, clear_event_location: true);
+
+ $this->assertSame($event, $this->handler->handle($dto));
+ }
+
+ public function test_throws_when_event_not_found(): void
+ {
+ $this->eventRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn(null);
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle(new UpdateEventLocationDTO(event_id: 999, account_id: 5));
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandlerTest.php
new file mode 100644
index 0000000000..bc895a6f62
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/BulkUpdateOccurrencesHandlerTest.php
@@ -0,0 +1,855 @@
+occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->eventRepository = Mockery::mock(EventRepositoryInterface::class);
+ $this->orderItemRepository = Mockery::mock(OrderItemRepositoryInterface::class);
+ $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class);
+ $this->waitlistEntryRepository = Mockery::mock(WaitlistEntryRepositoryInterface::class);
+ $this->exclusionService = Mockery::mock(RecurrenceRuleExclusionService::class);
+ $this->eventLocationUpserter = Mockery::mock(EventLocationUpserter::class);
+ $this->eventLocationCleaner = Mockery::mock(EventLocationCleaner::class);
+ $this->databaseManager = Mockery::mock(DatabaseManager::class);
+
+ $this->event = Mockery::mock(EventDomainObject::class);
+ $this->event->shouldReceive('getAccountId')->andReturn(7)->byDefault();
+
+ $this->eventRepository
+ ->shouldReceive('findById')
+ ->andReturn($this->event)
+ ->byDefault();
+
+ $this->attendeeRepository
+ ->shouldReceive('findWhereIn')
+ ->byDefault()
+ ->andReturn(new Collection);
+
+ $this->databaseManager->shouldReceive('transaction')
+ ->andReturnUsing(fn ($callback) => $callback());
+
+ $this->exclusionService->shouldReceive('addExclusions')->byDefault();
+
+ $this->waitlistEntryRepository
+ ->shouldReceive('updateWhere')
+ ->byDefault()
+ ->andReturn(0);
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->with(
+ Mockery::on(fn (array $attrs) => array_keys($attrs) === [EventOccurrenceDomainObjectAbstract::STATUS]),
+ Mockery::any(),
+ )
+ ->zeroOrMoreTimes()
+ ->byDefault();
+
+ $this->handler = new BulkUpdateOccurrencesHandler(
+ $this->occurrenceRepository,
+ $this->eventRepository,
+ $this->orderItemRepository,
+ $this->attendeeRepository,
+ $this->waitlistEntryRepository,
+ $this->exclusionService,
+ $this->eventLocationUpserter,
+ $this->eventLocationCleaner,
+ $this->databaseManager,
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ public function test_handle_updates_capacity_for_future_non_overridden_occurrences(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'America/New_York',
+ capacity: 500,
+ future_only: true,
+ skip_overridden: true,
+ apply_to_all: true,
+ );
+
+ $futureOccurrence = $this->createOccurrenceMock(10, false, false);
+ $pastOccurrence = $this->createOccurrenceMock(11, true, false);
+ $overriddenOccurrence = $this->createOccurrenceMock(12, false, true);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$futureOccurrence, $pastOccurrence, $overriddenOccurrence]));
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ [
+ EventOccurrenceDomainObjectAbstract::CAPACITY => 500,
+ EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => true,
+ ],
+ [[EventOccurrenceDomainObjectAbstract::ID, 'in', [10]]]
+ );
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(1, $result->updated_count);
+ }
+
+ public function test_handle_shifts_time_by_minutes(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'America/New_York',
+ start_time_shift: 60,
+ end_time_shift: 60,
+ future_only: false,
+ skip_overridden: false,
+ apply_to_all: true,
+ );
+
+ $occurrence = $this->createOccurrenceMock(10, false, false, '2026-03-01 14:00:00', '2026-03-01 16:00:00');
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occurrence]));
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ Mockery::on(function ($attributes) {
+ return $attributes[EventOccurrenceDomainObjectAbstract::START_DATE] === '2026-03-01 15:00:00'
+ && $attributes[EventOccurrenceDomainObjectAbstract::END_DATE] === '2026-03-01 17:00:00';
+ }),
+ [EventOccurrenceDomainObjectAbstract::ID => 10]
+ );
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(1, $result->updated_count);
+ }
+
+ public function test_time_shift_pins_as_overridden_so_regenerate_doesnt_revert_it(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'UTC',
+ start_time_shift: 60,
+ future_only: false,
+ skip_overridden: false,
+ apply_to_all: true,
+ );
+
+ $occurrence = $this->createOccurrenceMock(10, false, false, '2026-03-01 14:00:00', null);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occurrence]));
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true),
+ Mockery::any(),
+ );
+
+ $result = $this->handler->handle($dto);
+ $this->assertEquals(1, $result->updated_count);
+ }
+
+ public function test_handle_shifts_time_backwards(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'UTC',
+ start_time_shift: -30,
+ end_time_shift: -30,
+ future_only: false,
+ skip_overridden: false,
+ apply_to_all: true,
+ );
+
+ $occurrence = $this->createOccurrenceMock(10, false, false, '2026-03-01 14:00:00', '2026-03-01 16:00:00');
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occurrence]));
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ Mockery::on(function ($attributes) {
+ return $attributes[EventOccurrenceDomainObjectAbstract::START_DATE] === '2026-03-01 13:30:00'
+ && $attributes[EventOccurrenceDomainObjectAbstract::END_DATE] === '2026-03-01 15:30:00';
+ }),
+ [EventOccurrenceDomainObjectAbstract::ID => 10]
+ );
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(1, $result->updated_count);
+ }
+
+ public function test_handle_shifts_only_start_time_when_end_time_shift_is_null(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'UTC',
+ start_time_shift: 90,
+ future_only: false,
+ skip_overridden: false,
+ apply_to_all: true,
+ );
+
+ $occurrence = $this->createOccurrenceMock(10, false, false, '2026-03-01 09:00:00', '2026-03-01 11:00:00');
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occurrence]));
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ Mockery::on(function ($attributes) {
+ return $attributes[EventOccurrenceDomainObjectAbstract::START_DATE] === '2026-03-01 10:30:00'
+ && ! array_key_exists(EventOccurrenceDomainObjectAbstract::END_DATE, $attributes);
+ }),
+ [EventOccurrenceDomainObjectAbstract::ID => 10]
+ );
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(1, $result->updated_count);
+ }
+
+ public function test_handle_shift_times_does_not_add_end_date_when_null(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'UTC',
+ start_time_shift: 60,
+ end_time_shift: 60,
+ future_only: false,
+ skip_overridden: false,
+ apply_to_all: true,
+ );
+
+ $occurrence = $this->createOccurrenceMock(10, false, false, '2026-03-01 14:00:00', null);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occurrence]));
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ Mockery::on(function ($attributes) {
+ return $attributes[EventOccurrenceDomainObjectAbstract::START_DATE] === '2026-03-01 15:00:00'
+ && ! array_key_exists(EventOccurrenceDomainObjectAbstract::END_DATE, $attributes);
+ }),
+ [EventOccurrenceDomainObjectAbstract::ID => 10]
+ );
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(1, $result->updated_count);
+ }
+
+ public function test_handle_sets_end_date_from_duration_minutes(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'UTC',
+ future_only: false,
+ skip_overridden: false,
+ apply_to_all: true,
+ duration_minutes: 2880,
+ );
+
+ $occurrence = $this->createOccurrenceMock(10, false, false, '2026-03-01 14:00:00', '2026-03-01 16:00:00');
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occurrence]));
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ Mockery::on(function ($attributes) {
+ return $attributes[EventOccurrenceDomainObjectAbstract::END_DATE] === '2026-03-03 14:00:00'
+ && ! array_key_exists(EventOccurrenceDomainObjectAbstract::START_DATE, $attributes)
+ && $attributes[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true;
+ }),
+ [EventOccurrenceDomainObjectAbstract::ID => 10]
+ );
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(1, $result->updated_count);
+ }
+
+ public function test_handle_rejects_shift_that_inverts_dates(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'UTC',
+ end_time_shift: -180,
+ future_only: false,
+ skip_overridden: false,
+ apply_to_all: true,
+ );
+
+ $occurrence = $this->createOccurrenceMock(10, false, false, '2026-03-01 14:00:00', '2026-03-01 16:00:00');
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occurrence]));
+
+ $this->occurrenceRepository->shouldNotReceive('updateWhere');
+
+ $this->expectException(InvalidOccurrenceDatesException::class);
+
+ $this->handler->handle($dto);
+ }
+
+ public function test_handle_cancels_all_future_occurrences_via_job(): void
+ {
+ Bus::fake([BulkCancelOccurrencesJob::class]);
+
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::CANCEL,
+ timezone: 'UTC',
+ future_only: true,
+ skip_overridden: false,
+ refund_orders: true,
+ apply_to_all: true,
+ );
+
+ $futureOccurrence1 = $this->createOccurrenceMock(10, false, false, '2026-03-15 09:00:00');
+ $futureOccurrence2 = $this->createOccurrenceMock(11, false, true, '2026-03-22 09:00:00');
+ $pastOccurrence = $this->createOccurrenceMock(12, true, false);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$futureOccurrence1, $futureOccurrence2, $pastOccurrence]));
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(2, $result->updated_count);
+
+ Bus::assertDispatched(BulkCancelOccurrencesJob::class, function (BulkCancelOccurrencesJob $job) {
+ return $job->eventId === 1
+ && $job->occurrenceIds === [10, 11]
+ && $job->refundOrders === true;
+ });
+ }
+
+ public function test_handle_skips_cancelled_occurrences(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'UTC',
+ capacity: 100,
+ future_only: false,
+ skip_overridden: false,
+ apply_to_all: true,
+ );
+
+ $activeOccurrence = $this->createOccurrenceMock(10, false, false, '2026-03-01 09:00:00', null, EventOccurrenceStatus::ACTIVE->name);
+ $cancelledOccurrence = $this->createOccurrenceMock(11, false, false, '2026-03-02 09:00:00', null, EventOccurrenceStatus::CANCELLED->name);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$activeOccurrence, $cancelledOccurrence]));
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ [
+ EventOccurrenceDomainObjectAbstract::CAPACITY => 100,
+ EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => true,
+ ],
+ [[EventOccurrenceDomainObjectAbstract::ID, 'in', [10]]]
+ );
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(1, $result->updated_count);
+ }
+
+ public function test_handle_returns_zero_when_no_fields_to_update(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'UTC',
+ future_only: false,
+ skip_overridden: false,
+ apply_to_all: true,
+ );
+
+ $occurrence = $this->createOccurrenceMock(10, false, false);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occurrence]));
+
+ $this->occurrenceRepository
+ ->shouldNotReceive('updateWhere');
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(0, $result->updated_count);
+ }
+
+ public function test_handle_clears_capacity(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'UTC',
+ clear_capacity: true,
+ future_only: false,
+ skip_overridden: false,
+ apply_to_all: true,
+ );
+
+ $occurrence = $this->createOccurrenceMock(10, false, false);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occurrence]));
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ [
+ EventOccurrenceDomainObjectAbstract::CAPACITY => null,
+ EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => true,
+ ],
+ [[EventOccurrenceDomainObjectAbstract::ID, 'in', [10]]]
+ );
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(1, $result->updated_count);
+ }
+
+ public function test_handle_filters_to_specific_occurrence_ids(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'UTC',
+ capacity: 200,
+ future_only: false,
+ skip_overridden: false,
+ occurrence_ids: [10, 12],
+ );
+
+ $occ10 = $this->createOccurrenceMock(10, false, false);
+ $occ11 = $this->createOccurrenceMock(11, false, false);
+ $occ12 = $this->createOccurrenceMock(12, false, false);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occ10, $occ11, $occ12]));
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ [
+ EventOccurrenceDomainObjectAbstract::CAPACITY => 200,
+ EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => true,
+ ],
+ [[EventOccurrenceDomainObjectAbstract::ID, 'in', [10, 12]]]
+ );
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(2, $result->updated_count);
+ }
+
+ public function test_handle_deletes_occurrences_without_orders(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::DELETE,
+ timezone: 'UTC',
+ future_only: false,
+ skip_overridden: false,
+ occurrence_ids: [10, 11],
+ );
+
+ $occNoOrders = $this->createOccurrenceMock(10, false, false, '2026-03-01 09:00:00');
+ $occWithOrders = $this->createOccurrenceMock(11, false, false, '2026-03-08 09:00:00');
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occNoOrders, $occWithOrders]));
+
+ $orderItem11 = Mockery::mock(OrderItemDomainObject::class);
+ $orderItem11->shouldReceive('getEventOccurrenceId')->andReturn(11);
+
+ $this->orderItemRepository
+ ->shouldReceive('findWhereIn')
+ ->once()
+ ->withArgs(function ($field, $values, $additionalWhere = [], $columns = []) {
+ return $field === OrderItemDomainObjectAbstract::EVENT_OCCURRENCE_ID
+ && $values === [10, 11]
+ && $columns === [OrderItemDomainObjectAbstract::EVENT_OCCURRENCE_ID];
+ })
+ ->andReturn(new Collection([$orderItem11]));
+
+ $this->occurrenceRepository
+ ->shouldReceive('deleteWhere')
+ ->once()
+ ->with([[EventOccurrenceDomainObjectAbstract::ID, 'in', [10]]]);
+
+ $this->exclusionService
+ ->shouldReceive('addExclusions')
+ ->once()
+ ->with(1, ['2026-03-01 09:00:00']);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(1, $result->updated_count);
+ }
+
+ public function test_handle_skips_deletion_for_occurrences_with_attendees_but_no_orders(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::DELETE,
+ timezone: 'UTC',
+ future_only: false,
+ skip_overridden: false,
+ occurrence_ids: [10, 11],
+ );
+
+ $occClean = $this->createOccurrenceMock(10, false, false, '2026-03-01 09:00:00');
+ $occWithAttendees = $this->createOccurrenceMock(11, false, false, '2026-03-08 09:00:00');
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occClean, $occWithAttendees]));
+
+ $this->orderItemRepository
+ ->shouldReceive('findWhereIn')
+ ->once()
+ ->withArgs(function ($field, $values, $additionalWhere = [], $columns = []) {
+ return $field === OrderItemDomainObjectAbstract::EVENT_OCCURRENCE_ID
+ && $values === [10, 11]
+ && $columns === [OrderItemDomainObjectAbstract::EVENT_OCCURRENCE_ID];
+ })
+ ->andReturn(new Collection);
+
+ $attendee11 = Mockery::mock(AttendeeDomainObject::class);
+ $attendee11->shouldReceive('getEventOccurrenceId')->andReturn(11);
+
+ $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class);
+ $this->attendeeRepository
+ ->shouldReceive('findWhereIn')
+ ->once()
+ ->withArgs(function ($field, $values, $additionalWhere = [], $columns = []) {
+ return $field === AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID
+ && $values === [10, 11]
+ && $columns === [AttendeeDomainObjectAbstract::EVENT_OCCURRENCE_ID];
+ })
+ ->andReturn(new Collection([$attendee11]));
+
+ $this->handler = new BulkUpdateOccurrencesHandler(
+ $this->occurrenceRepository,
+ $this->eventRepository,
+ $this->orderItemRepository,
+ $this->attendeeRepository,
+ $this->waitlistEntryRepository,
+ $this->exclusionService,
+ $this->eventLocationUpserter,
+ $this->eventLocationCleaner,
+ $this->databaseManager,
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('deleteWhere')
+ ->once()
+ ->with([[EventOccurrenceDomainObjectAbstract::ID, 'in', [10]]]);
+
+ $this->exclusionService
+ ->shouldReceive('addExclusions')
+ ->once()
+ ->with(1, ['2026-03-01 09:00:00']);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(1, $result->updated_count);
+ }
+
+ public function test_per_occurrence_fork_when_event_location_supplied(): void
+ {
+ $locationData = new EventLocationData(
+ type: LocationType::IN_PERSON,
+ location_id: 42,
+ );
+
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'UTC',
+ future_only: false,
+ skip_overridden: false,
+ apply_to_all: true,
+ event_location: $locationData,
+ );
+
+ $occ1 = $this->createOccurrenceMock(10, false, false);
+ $occ2 = $this->createOccurrenceMock(11, false, false, eventLocationId: 200);
+ $occ3 = $this->createOccurrenceMock(12, false, false);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occ1, $occ2, $occ3]));
+
+ $newLocations = [
+ $this->makeEventLocationMock(501),
+ $this->makeEventLocationMock(502),
+ $this->makeEventLocationMock(503),
+ ];
+
+ $this->eventLocationUpserter
+ ->shouldReceive('createForEvent')
+ ->times(3)
+ ->with(1, 7, $locationData)
+ ->andReturn($newLocations[0], $newLocations[1], $newLocations[2]);
+
+ $this->eventLocationUpserter->shouldNotReceive('updateInPlace');
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->times(3)
+ ->with(
+ Mockery::on(fn (array $attrs) => array_key_exists(EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID, $attrs)
+ && $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true),
+ Mockery::any(),
+ );
+
+ $this->eventLocationCleaner
+ ->shouldReceive('deleteIfOrphaned')
+ ->once()
+ ->with(200);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(3, $result->updated_count);
+ }
+
+ public function test_clears_overrides_and_cleans_up_orphans(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'UTC',
+ future_only: false,
+ skip_overridden: false,
+ apply_to_all: true,
+ clear_event_location: true,
+ );
+
+ $occWithFk1 = $this->createOccurrenceMock(10, false, false, eventLocationId: 200);
+ $occWithFk2 = $this->createOccurrenceMock(11, false, false, eventLocationId: 201);
+ $occNoFk = $this->createOccurrenceMock(12, false, false, eventLocationId: null);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occWithFk1, $occWithFk2, $occNoFk]));
+
+ $this->eventLocationUpserter->shouldNotReceive('createForEvent');
+ $this->eventLocationUpserter->shouldNotReceive('updateInPlace');
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->times(2)
+ ->with(
+ Mockery::on(fn (array $attrs) => array_key_exists(EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID, $attrs)
+ && $attrs[EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID] === null
+ && $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true),
+ Mockery::any(),
+ );
+
+ $this->eventLocationCleaner
+ ->shouldReceive('deleteIfOrphaned')
+ ->twice()
+ ->with(Mockery::on(fn ($id) => $id === 200 || $id === 201));
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(2, $result->updated_count);
+ }
+
+ public function test_no_op_when_no_location_keys_in_payload(): void
+ {
+ $dto = new BulkUpdateOccurrencesDTO(
+ event_id: 1,
+ action: BulkOccurrenceAction::UPDATE,
+ timezone: 'UTC',
+ capacity: 50,
+ future_only: false,
+ skip_overridden: false,
+ apply_to_all: true,
+ );
+
+ $occurrence = $this->createOccurrenceMock(10, false, false);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection([$occurrence]));
+
+ $this->eventLocationUpserter->shouldNotReceive('createForEvent');
+ $this->eventLocationUpserter->shouldNotReceive('updateInPlace');
+ $this->eventLocationCleaner->shouldNotReceive('deleteIfOrphaned');
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ [
+ EventOccurrenceDomainObjectAbstract::CAPACITY => 50,
+ EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => true,
+ ],
+ [[EventOccurrenceDomainObjectAbstract::ID, 'in', [10]]],
+ );
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertEquals(1, $result->updated_count);
+ }
+
+ private function makeEventLocationMock(int $id): EventLocationDomainObject|MockInterface
+ {
+ $mock = Mockery::mock(EventLocationDomainObject::class);
+ $mock->shouldReceive('getId')->andReturn($id);
+
+ return $mock;
+ }
+
+ private function createOccurrenceMock(
+ int $id,
+ bool $isPast,
+ bool $isOverridden,
+ string $startDate = '2026-03-01 09:00:00',
+ ?string $endDate = '2026-03-01 11:00:00',
+ string $status = 'ACTIVE',
+ ?int $eventLocationId = null,
+ ): EventOccurrenceDomainObject|MockInterface {
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('isPast')->andReturn($isPast);
+ $occurrence->shouldReceive('getIsOverridden')->andReturn($isOverridden);
+ $occurrence->shouldReceive('getId')->andReturn($id);
+ $occurrence->shouldReceive('getStatus')->andReturn($status);
+ $occurrence->shouldReceive('getStartDate')->andReturn($startDate);
+ $occurrence->shouldReceive('getEndDate')->andReturn($endDate);
+ $occurrence->shouldReceive('getEventLocationId')->andReturn($eventLocationId);
+ $occurrence->shouldReceive('getUsedCapacity')->andReturn(0)->byDefault();
+ $occurrence->shouldReceive('getCapacity')->andReturn(null)->byDefault();
+
+ return $occurrence;
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/CancelOccurrenceHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/CancelOccurrenceHandlerTest.php
new file mode 100644
index 0000000000..fa1137c91b
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/CancelOccurrenceHandlerTest.php
@@ -0,0 +1,296 @@
+occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->exclusionService = Mockery::mock(RecurrenceRuleExclusionService::class);
+ $this->cancelAttendeesService = Mockery::mock(CancelOccurrenceAttendeesService::class);
+ $this->databaseManager = Mockery::mock(DatabaseManager::class);
+
+ $this->databaseManager->shouldReceive('transaction')
+ ->andReturnUsing(fn ($callback) => $callback());
+
+ $this->handler = new CancelOccurrenceHandler(
+ $this->occurrenceRepository,
+ $this->exclusionService,
+ $this->cancelAttendeesService,
+ $this->databaseManager,
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ private function expectAttendeeCancelCalled(int $eventId, int $occurrenceId, array $cancelledAttendeeIds = [], int $salesBackedCount = 0): void
+ {
+ $this->cancelAttendeesService
+ ->shouldReceive('cancelForOccurrence')
+ ->once()
+ ->with($eventId, $occurrenceId)
+ ->andReturn([
+ 'attendee_ids' => $cancelledAttendeeIds,
+ 'sales_backed_count' => $salesBackedCount,
+ ]);
+ }
+
+ public function test_handle_sets_status_to_cancelled(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00');
+ $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name);
+ $occurrence->shouldReceive('getEventId')->andReturn($eventId);
+
+ $updatedOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn($occurrence);
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with($occurrenceId, [
+ EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::CANCELLED->name,
+ EventOccurrenceDomainObjectAbstract::CANCELLED_ATTENDEES_COUNT => 5,
+ ])
+ ->andReturn($updatedOccurrence);
+ $this->expectAttendeeCancelCalled($eventId, $occurrenceId, [101, 102], salesBackedCount: 5);
+ $this->exclusionService
+ ->shouldReceive('addExclusions')
+ ->once()
+ ->with($eventId, ['2026-06-15 10:00:00']);
+
+ $result = $this->handler->handle($eventId, $occurrenceId);
+
+ $this->assertSame($updatedOccurrence, $result);
+
+ Event::assertDispatched(OccurrenceCancelledEvent::class, function ($e) use ($eventId, $occurrenceId) {
+ return $e->eventId === $eventId
+ && $e->occurrenceId === $occurrenceId;
+ });
+
+ Bus::assertDispatched(SendOccurrenceCancellationEmailJob::class, function (SendOccurrenceCancellationEmailJob $job) use ($eventId, $occurrenceId) {
+ return $job->eventId === $eventId
+ && $job->occurrenceId === $occurrenceId
+ && $job->attendeeIds === [101, 102]
+ && $job->refundOrders === false;
+ });
+ }
+
+ public function test_handle_delegates_recurrence_exclusion_with_occurrence_start_date(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-07-20 14:00:00');
+ $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name);
+ $occurrence->shouldReceive('getEventId')->andReturn($eventId);
+
+ $updatedOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn($occurrence);
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')->once()->andReturn($updatedOccurrence);
+ $this->expectAttendeeCancelCalled($eventId, $occurrenceId);
+ $this->exclusionService
+ ->shouldReceive('addExclusions')
+ ->once()
+ ->with($eventId, ['2026-07-20 14:00:00']);
+
+ $result = $this->handler->handle($eventId, $occurrenceId);
+
+ $this->assertSame($updatedOccurrence, $result);
+ }
+
+ public function test_handle_throws_exception_when_occurrence_not_found(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 999;
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn(null);
+
+ $this->occurrenceRepository->shouldNotReceive('updateFromArray');
+ $this->exclusionService->shouldNotReceive('addExclusions');
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle($eventId, $occurrenceId);
+
+ Event::assertNotDispatched(OccurrenceCancelledEvent::class);
+ }
+
+ public function test_handle_throws_when_occurrence_belongs_to_different_event(): void
+ {
+ $requestedEventId = 1;
+ $foreignEventId = 999;
+ $occurrenceId = 10;
+
+ $foreignOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $foreignOccurrence->shouldReceive('getEventId')->andReturn($foreignEventId);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn($foreignOccurrence);
+
+ $this->occurrenceRepository->shouldNotReceive('updateFromArray');
+ $this->exclusionService->shouldNotReceive('addExclusions');
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle($requestedEventId, $occurrenceId);
+
+ Event::assertNotDispatched(OccurrenceCancelledEvent::class);
+ }
+
+ public function test_handle_dispatches_event_with_refund_flag_true(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00');
+ $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name);
+ $occurrence->shouldReceive('getEventId')->andReturn($eventId);
+
+ $updatedOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn($occurrence);
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')->once()->andReturn($updatedOccurrence);
+ $this->expectAttendeeCancelCalled($eventId, $occurrenceId);
+ $this->exclusionService->shouldReceive('addExclusions')->once();
+
+ $this->handler->handle($eventId, $occurrenceId, refundOrders: true);
+
+ Event::assertDispatched(OccurrenceCancelledEvent::class, function ($e) use ($eventId, $occurrenceId) {
+ return $e->eventId === $eventId
+ && $e->occurrenceId === $occurrenceId
+ && $e->refundOrders === true;
+ });
+ }
+
+ public function test_handle_dispatches_event_with_refund_flag_false(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00');
+ $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name);
+ $occurrence->shouldReceive('getEventId')->andReturn($eventId);
+
+ $updatedOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn($occurrence);
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')->once()->andReturn($updatedOccurrence);
+ $this->expectAttendeeCancelCalled($eventId, $occurrenceId);
+ $this->exclusionService->shouldReceive('addExclusions')->once();
+
+ $this->handler->handle($eventId, $occurrenceId, refundOrders: false);
+
+ Event::assertDispatched(OccurrenceCancelledEvent::class, function ($e) {
+ return $e->refundOrders === false;
+ });
+ }
+
+ public function test_it_returns_early_if_occurrence_already_cancelled(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::CANCELLED->name);
+ $occurrence->shouldReceive('getEventId')->andReturn($eventId);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn($occurrence);
+
+ $this->occurrenceRepository->shouldNotReceive('updateFromArray');
+ $this->cancelAttendeesService->shouldNotReceive('cancelForOccurrence');
+ $this->exclusionService->shouldNotReceive('addExclusions');
+
+ $result = $this->handler->handle($eventId, $occurrenceId, refundOrders: true);
+
+ $this->assertSame($occurrence, $result);
+
+ Event::assertNotDispatched(OccurrenceCancelledEvent::class);
+ Bus::assertNotDispatched(SendOccurrenceCancellationEmailJob::class);
+ }
+
+ public function test_delegates_attendee_cancellation_to_service(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00');
+ $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name);
+ $occurrence->shouldReceive('getEventId')->andReturn($eventId);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->andReturn($occurrence);
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')->once()->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $this->cancelAttendeesService
+ ->shouldReceive('cancelForOccurrence')
+ ->once()
+ ->with($eventId, $occurrenceId)
+ ->andReturn([
+ 'attendee_ids' => [],
+ 'sales_backed_count' => 0,
+ ]);
+
+ $this->exclusionService->shouldReceive('addExclusions')->once();
+
+ $result = $this->handler->handle($eventId, $occurrenceId);
+ $this->assertNotNull($result);
+
+ Bus::assertNotDispatched(SendOccurrenceCancellationEmailJob::class);
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandlerTest.php
new file mode 100644
index 0000000000..2776af2071
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/CreateEventOccurrenceHandlerTest.php
@@ -0,0 +1,265 @@
+occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->eventRepository = Mockery::mock(EventRepositoryInterface::class);
+ $this->eventLocationUpserter = Mockery::mock(EventLocationUpserter::class);
+ $this->databaseManager = Mockery::mock(DatabaseManager::class);
+
+ $this->databaseManager->shouldReceive('transaction')
+ ->andReturnUsing(fn ($callback) => $callback());
+
+ $this->handler = new CreateEventOccurrenceHandler(
+ $this->occurrenceRepository,
+ $this->eventRepository,
+ $this->eventLocationUpserter,
+ $this->databaseManager,
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ public function test_creates_occurrence_inheriting_event_location_when_no_event_location_payload(): void
+ {
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: 1,
+ start_date: '2026-06-01 10:00:00',
+ end_date: '2026-06-01 18:00:00',
+ capacity: 100,
+ label: 'Morning Session',
+ );
+
+ $expectedOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->eventLocationUpserter->shouldNotReceive('createForEvent');
+ $this->eventRepository->shouldNotReceive('findById');
+
+ $this->occurrenceRepository
+ ->shouldReceive('countWhere')
+ ->once()
+ ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1])
+ ->andReturn(5);
+
+ $this->occurrenceRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with(Mockery::on(function (array $attrs) {
+ return $attrs[EventOccurrenceDomainObjectAbstract::EVENT_ID] === 1
+ && $attrs[EventOccurrenceDomainObjectAbstract::START_DATE] === '2026-06-01 10:00:00'
+ && $attrs[EventOccurrenceDomainObjectAbstract::END_DATE] === '2026-06-01 18:00:00'
+ && $attrs[EventOccurrenceDomainObjectAbstract::STATUS] === EventOccurrenceStatus::ACTIVE->name
+ && $attrs[EventOccurrenceDomainObjectAbstract::CAPACITY] === 100
+ && $attrs[EventOccurrenceDomainObjectAbstract::USED_CAPACITY] === 0
+ && $attrs[EventOccurrenceDomainObjectAbstract::LABEL] === 'Morning Session'
+ && $attrs[EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID] === null
+ && str_starts_with($attrs[EventOccurrenceDomainObjectAbstract::SHORT_ID], 'oc_');
+ }))
+ ->andReturn($expectedOccurrence);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertSame($expectedOccurrence, $result);
+ }
+
+ public function test_creates_occurrence_with_in_person_override(): void
+ {
+ $locationData = new EventLocationData(
+ type: LocationType::IN_PERSON,
+ location_id: 42,
+ );
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: 1,
+ start_date: '2026-06-01 10:00:00',
+ event_location: $locationData,
+ );
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getAccountId')->andReturn(7);
+
+ $createdEventLocation = Mockery::mock(EventLocationDomainObject::class);
+ $createdEventLocation->shouldReceive('getId')->andReturn(99);
+
+ $this->occurrenceRepository
+ ->shouldReceive('countWhere')
+ ->once()
+ ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1])
+ ->andReturn(5);
+
+ $this->eventRepository
+ ->shouldReceive('findById')
+ ->once()
+ ->with(1)
+ ->andReturn($event);
+
+ $this->eventLocationUpserter
+ ->shouldReceive('createForEvent')
+ ->once()
+ ->with(1, 7, $locationData)
+ ->andReturn($createdEventLocation);
+
+ $expectedOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with(Mockery::on(function (array $attrs) {
+ return $attrs[EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID] === 99
+ && $attrs[EventOccurrenceDomainObjectAbstract::EVENT_ID] === 1;
+ }))
+ ->andReturn($expectedOccurrence);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertSame($expectedOccurrence, $result);
+ }
+
+ public function test_creates_occurrence_with_online_override(): void
+ {
+ $locationData = new EventLocationData(
+ type: LocationType::ONLINE,
+ online_event_connection_details: 'Zoom link
',
+ );
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: 1,
+ start_date: '2026-06-01 10:00:00',
+ event_location: $locationData,
+ );
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getAccountId')->andReturn(7);
+
+ $createdEventLocation = Mockery::mock(EventLocationDomainObject::class);
+ $createdEventLocation->shouldReceive('getId')->andReturn(123);
+
+ $this->occurrenceRepository
+ ->shouldReceive('countWhere')
+ ->once()
+ ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1])
+ ->andReturn(5);
+
+ $this->eventRepository
+ ->shouldReceive('findById')
+ ->once()
+ ->with(1)
+ ->andReturn($event);
+
+ $this->eventLocationUpserter
+ ->shouldReceive('createForEvent')
+ ->once()
+ ->with(1, 7, $locationData)
+ ->andReturn($createdEventLocation);
+
+ $expectedOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with(Mockery::on(function (array $attrs) {
+ return $attrs[EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID] === 123;
+ }))
+ ->andReturn($expectedOccurrence);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertSame($expectedOccurrence, $result);
+ }
+
+ public function test_throws_when_event_not_found_for_override(): void
+ {
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: 999,
+ start_date: '2026-06-01 10:00:00',
+ event_location: new EventLocationData(
+ type: LocationType::IN_PERSON,
+ location_id: 42,
+ ),
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('countWhere')
+ ->once()
+ ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 999])
+ ->andReturn(5);
+
+ $this->eventRepository
+ ->shouldReceive('findById')
+ ->once()
+ ->with(999)
+ ->andThrow(new ModelNotFoundException);
+
+ $this->eventLocationUpserter->shouldNotReceive('createForEvent');
+ $this->occurrenceRepository->shouldNotReceive('create');
+
+ $this->expectException(ModelNotFoundException::class);
+
+ $this->handler->handle($dto);
+ }
+
+ public function test_throws_and_does_not_create_when_occurrence_cap_reached(): void
+ {
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: 1,
+ start_date: '2026-06-01 10:00:00',
+ end_date: '2026-06-01 18:00:00',
+ capacity: 100,
+ label: 'Morning Session',
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('countWhere')
+ ->once()
+ ->with([EventOccurrenceDomainObjectAbstract::EVENT_ID => 1])
+ ->andReturn(RecurrenceRuleParserService::MAX_OCCURRENCES);
+
+ $this->occurrenceRepository->shouldNotReceive('create');
+ $this->eventRepository->shouldNotReceive('findById');
+ $this->eventLocationUpserter->shouldNotReceive('createForEvent');
+
+ $this->expectException(ValidationException::class);
+
+ $this->handler->handle($dto);
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/DeleteEventOccurrenceHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/DeleteEventOccurrenceHandlerTest.php
new file mode 100644
index 0000000000..8ce80ca8b1
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/DeleteEventOccurrenceHandlerTest.php
@@ -0,0 +1,294 @@
+occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->eventRepository = Mockery::mock(EventRepositoryInterface::class);
+ $this->orderItemRepository = Mockery::mock(OrderItemRepositoryInterface::class);
+ $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class);
+ $this->waitlistEntryRepository = Mockery::mock(WaitlistEntryRepositoryInterface::class);
+ $this->databaseManager = Mockery::mock(DatabaseManager::class);
+
+ $this->waitlistEntryRepository
+ ->shouldReceive('updateWhere')
+ ->byDefault()
+ ->andReturn(0);
+
+ $this->databaseManager->shouldReceive('transaction')
+ ->andReturnUsing(fn ($callback) => $callback());
+
+ $this->handler = new DeleteEventOccurrenceHandler(
+ $this->occurrenceRepository,
+ $this->eventRepository,
+ $this->orderItemRepository,
+ $this->attendeeRepository,
+ $this->waitlistEntryRepository,
+ $this->databaseManager,
+ );
+ }
+
+ public function test_handle_successfully_deletes_occurrence_with_no_orders(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00');
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => $occurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
+ ])
+ ->andReturn($occurrence);
+
+ $this->orderItemRepository
+ ->shouldReceive('countWhere')
+ ->once()
+ ->with(['event_occurrence_id' => $occurrenceId])
+ ->andReturn(0);
+
+ $this->attendeeRepository
+ ->shouldReceive('countWhere')
+ ->once()
+ ->with(['event_occurrence_id' => $occurrenceId])
+ ->andReturn(0);
+
+ $this->occurrenceRepository
+ ->shouldReceive('deleteWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => $occurrenceId,
+ ]);
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getType')->andReturn(EventType::SINGLE->name);
+ $this->eventRepository
+ ->shouldReceive('findByIdLocked')->once()->andReturn($event);
+ $this->eventRepository->shouldNotReceive('updateFromArray');
+
+ $this->handler->handle($eventId, $occurrenceId);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_delete_adds_date_to_recurrence_excluded_dates_for_recurring_event(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00');
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($occurrence);
+ $this->orderItemRepository->shouldReceive('countWhere')->once()->andReturn(0);
+ $this->attendeeRepository->shouldReceive('countWhere')->once()->andReturn(0);
+ $this->occurrenceRepository->shouldReceive('deleteWhere')->once();
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getType')->andReturn(EventType::RECURRING->name);
+ $event->shouldReceive('getTimezone')->andReturn('UTC');
+ $event->shouldReceive('getRecurrenceRule')->andReturn([
+ 'excluded_dates' => ['2026-05-01'],
+ ]);
+
+ $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event);
+ $this->eventRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with($eventId, Mockery::on(static function (array $attrs): bool {
+ $rule = $attrs[EventDomainObjectAbstract::RECURRENCE_RULE] ?? null;
+
+ return is_array($rule)
+ && $rule['excluded_dates'] === ['2026-05-01']
+ && $rule['excluded_occurrences'] === ['2026-06-15 10:00'];
+ }))
+ ->andReturn(Mockery::mock(EventDomainObject::class));
+
+ $this->handler->handle($eventId, $occurrenceId);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_delete_does_not_duplicate_existing_excluded_date(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00');
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($occurrence);
+ $this->orderItemRepository->shouldReceive('countWhere')->once()->andReturn(0);
+ $this->attendeeRepository->shouldReceive('countWhere')->once()->andReturn(0);
+ $this->occurrenceRepository->shouldReceive('deleteWhere')->once();
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getType')->andReturn(EventType::RECURRING->name);
+ $event->shouldReceive('getTimezone')->andReturn('UTC');
+ $event->shouldReceive('getRecurrenceRule')->andReturn([
+ 'excluded_dates' => ['2026-06-15'],
+ 'excluded_occurrences' => ['2026-06-15 10:00'],
+ ]);
+
+ $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event);
+ $this->eventRepository->shouldNotReceive('updateFromArray');
+
+ $this->handler->handle($eventId, $occurrenceId);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_handle_throws_validation_exception_when_occurrence_has_orders(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => $occurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
+ ])
+ ->andReturn($occurrence);
+
+ $this->orderItemRepository
+ ->shouldReceive('countWhere')
+ ->once()
+ ->with(['event_occurrence_id' => $occurrenceId])
+ ->andReturn(5);
+
+ $this->occurrenceRepository
+ ->shouldNotReceive('deleteWhere');
+
+ $this->expectException(ValidationException::class);
+
+ $this->handler->handle($eventId, $occurrenceId);
+ }
+
+ public function test_handle_throws_exception_when_occurrence_not_found(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 999;
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => $occurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
+ ])
+ ->andReturn(null);
+
+ $this->orderItemRepository
+ ->shouldNotReceive('countWhere');
+
+ $this->occurrenceRepository
+ ->shouldNotReceive('deleteWhere');
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle($eventId, $occurrenceId);
+ }
+
+ public function test_delete_cancels_waiting_and_offered_waitlist_entries_for_occurrence(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-15 10:00:00');
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($occurrence);
+ $this->orderItemRepository->shouldReceive('countWhere')->once()->andReturn(0);
+ $this->attendeeRepository->shouldReceive('countWhere')->once()->andReturn(0);
+ $this->occurrenceRepository->shouldReceive('deleteWhere')->once();
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getType')->andReturn(EventType::SINGLE->name);
+ $this->eventRepository->shouldReceive('findByIdLocked')->once()->andReturn($event);
+ $this->eventRepository->shouldNotReceive('updateFromArray');
+
+ $this->waitlistEntryRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ Mockery::on(static function (array $attrs): bool {
+ return $attrs === ['status' => WaitlistEntryStatus::CANCELLED->name];
+ }),
+ Mockery::on(static function (array $where) use ($eventId, $occurrenceId): bool {
+ if (($where['event_id'] ?? null) !== $eventId) {
+ return false;
+ }
+ if (($where['event_occurrence_id'] ?? null) !== $occurrenceId) {
+ return false;
+ }
+ foreach ($where as $clause) {
+ if (is_array($clause) && $clause[0] === 'status' && $clause[1] === 'in') {
+ return $clause[2] === [
+ WaitlistEntryStatus::WAITING->name,
+ WaitlistEntryStatus::OFFERED->name,
+ ];
+ }
+ }
+
+ return false;
+ }),
+ )
+ ->andReturn(0);
+
+ $this->handler->handle($eventId, $occurrenceId);
+
+ $this->assertTrue(true);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandlerTest.php
new file mode 100644
index 0000000000..7ad40b9af0
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GenerateOccurrencesFromRuleHandlerTest.php
@@ -0,0 +1,138 @@
+generatorService = Mockery::mock(EventOccurrenceGeneratorService::class);
+ $this->eventRepository = Mockery::mock(EventRepositoryInterface::class);
+ $this->ruleParserService = Mockery::mock(RecurrenceRuleParserService::class);
+ $this->databaseManager = Mockery::mock(DatabaseManager::class);
+
+ $this->databaseManager->shouldReceive('transaction')
+ ->andReturnUsing(fn ($callback) => $callback());
+
+ $this->handler = new GenerateOccurrencesFromRuleHandler(
+ $this->generatorService,
+ $this->eventRepository,
+ $this->ruleParserService,
+ $this->databaseManager,
+ );
+ }
+
+ public function test_handle_generates_occurrences_and_updates_event_type(): void
+ {
+ $rule = ['frequency' => 'weekly', 'range' => ['type' => 'count', 'count' => 10]];
+ $dto = new GenerateOccurrencesDTO(event_id: 1, recurrence_rule: $rule);
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getTimezone')->andReturn('America/New_York');
+ $event->shouldReceive('getId')->andReturn(1);
+ $event->shouldReceive('setRecurrenceRule')->once()->with($rule);
+
+ $this->eventRepository->shouldReceive('findById')->with(1)->once()->andReturn($event);
+
+ $this->ruleParserService->shouldReceive('parse')
+ ->with($rule, 'America/New_York')
+ ->once()
+ ->andReturn(collect(range(1, 10)));
+
+ $this->eventRepository->shouldReceive('updateFromArray')
+ ->once()
+ ->with(1, [
+ EventDomainObjectAbstract::RECURRENCE_RULE => $rule,
+ EventDomainObjectAbstract::TYPE => EventType::RECURRING->name,
+ ]);
+
+ $generatedOccurrences = collect(['occ1', 'occ2']);
+ $this->generatorService->shouldReceive('generate')
+ ->once()
+ ->with($event, $rule)
+ ->andReturn($generatedOccurrences);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertSame($generatedOccurrences, $result);
+ }
+
+ public function test_handle_throws_validation_exception_when_too_many_occurrences(): void
+ {
+ $rule = ['frequency' => 'daily', 'range' => ['type' => 'count', 'count' => 2000]];
+ $dto = new GenerateOccurrencesDTO(event_id: 1, recurrence_rule: $rule);
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getTimezone')->andReturn('UTC');
+
+ $this->eventRepository->shouldReceive('findById')->with(1)->once()->andReturn($event);
+
+ $this->ruleParserService->shouldReceive('parse')
+ ->with($rule, 'UTC')
+ ->once()
+ ->andReturn(collect(range(1, RecurrenceRuleParserService::MAX_OCCURRENCES + 1)));
+
+ $this->generatorService->shouldNotReceive('generate');
+
+ $this->expectException(ValidationException::class);
+
+ $this->handler->handle($dto);
+ }
+
+ public function test_handle_uses_utc_when_event_has_no_timezone(): void
+ {
+ $rule = ['frequency' => 'weekly'];
+ $dto = new GenerateOccurrencesDTO(event_id: 1, recurrence_rule: $rule);
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getTimezone')->andReturn(null);
+ $event->shouldReceive('getId')->andReturn(1);
+ $event->shouldReceive('setRecurrenceRule')->once();
+
+ $this->eventRepository->shouldReceive('findById')->once()->andReturn($event);
+
+ $this->ruleParserService->shouldReceive('parse')
+ ->with($rule, 'UTC')
+ ->once()
+ ->andReturn(collect(range(1, 5)));
+
+ $this->eventRepository->shouldReceive('updateFromArray')->once();
+ $this->generatorService->shouldReceive('generate')->once()->andReturn(collect());
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertInstanceOf(Collection::class, $result);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandlerTest.php
new file mode 100644
index 0000000000..11f3c29038
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandlerTest.php
@@ -0,0 +1,70 @@
+occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->handler = new GetEventOccurrenceHandler($this->occurrenceRepository);
+ }
+
+ public function test_handle_returns_occurrence_with_stats(): void
+ {
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('loadRelation')
+ ->andReturnSelf();
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => 10,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => 1,
+ ])
+ ->andReturn($occurrence);
+
+ $result = $this->handler->handle(1, 10);
+
+ $this->assertSame($occurrence, $result);
+ }
+
+ public function test_handle_throws_when_occurrence_not_found(): void
+ {
+ $this->occurrenceRepository
+ ->shouldReceive('loadRelation')
+ ->andReturnSelf();
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn(null);
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle(1, 999);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandlerTest.php
new file mode 100644
index 0000000000..e7e2f3ef1b
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandlerTest.php
@@ -0,0 +1,51 @@
+occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->handler = new GetEventOccurrencesHandler($this->occurrenceRepository);
+ }
+
+ public function test_handle_returns_paginated_occurrences_with_stats(): void
+ {
+ $queryParams = Mockery::mock(QueryParamsDTO::class);
+ $paginator = Mockery::mock(LengthAwarePaginator::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('loadRelation')
+ ->andReturnSelf();
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByEventId')
+ ->once()
+ ->with(1, $queryParams)
+ ->andReturn($paginator);
+
+ $result = $this->handler->handle(1, $queryParams);
+
+ $this->assertSame($paginator, $result);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandlerTest.php
new file mode 100644
index 0000000000..4e15821055
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetProductVisibilityHandlerTest.php
@@ -0,0 +1,70 @@
+visibilityRepository = Mockery::mock(ProductOccurrenceVisibilityRepositoryInterface::class);
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->handler = new GetProductVisibilityHandler($this->visibilityRepository, $this->occurrenceRepository);
+ }
+
+ public function test_handle_returns_visibility_records(): void
+ {
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => 10,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => 1,
+ ])
+ ->andReturn($occurrence);
+
+ $records = collect([Mockery::mock(ProductOccurrenceVisibilityDomainObject::class)]);
+ $this->visibilityRepository->shouldReceive('findWhere')
+ ->once()
+ ->with([ProductOccurrenceVisibilityDomainObjectAbstract::EVENT_OCCURRENCE_ID => 10])
+ ->andReturn($records);
+
+ $result = $this->handler->handle(1, 10);
+
+ $this->assertCount(1, $result);
+ }
+
+ public function test_handle_throws_when_occurrence_not_found(): void
+ {
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn(null);
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle(1, 999);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetPublicEventOccurrencesHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetPublicEventOccurrencesHandlerTest.php
new file mode 100644
index 0000000000..b0d431cb76
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetPublicEventOccurrencesHandlerTest.php
@@ -0,0 +1,172 @@
+eventRepository = m::mock(EventRepositoryInterface::class);
+ $this->occurrenceRepository = m::mock(EventOccurrenceRepositoryInterface::class);
+
+ $this->handler = new GetPublicEventOccurrencesHandler(
+ $this->eventRepository,
+ $this->occurrenceRepository,
+ new PublicOccurrenceVisibilityService,
+ );
+ }
+
+ public function test_handle_throws_when_range_is_missing(): void
+ {
+ $this->expectException(InvalidOccurrenceDatesException::class);
+
+ $this->handler->handle(new GetPublicEventOccurrencesDTO(
+ eventId: 1,
+ startDateFrom: '2026-08-01 00:00:00',
+ startDateTo: null,
+ ));
+ }
+
+ public function test_handle_throws_when_range_is_unparseable(): void
+ {
+ $this->expectException(InvalidOccurrenceDatesException::class);
+
+ $this->handler->handle(new GetPublicEventOccurrencesDTO(
+ eventId: 1,
+ startDateFrom: 'not-a-date',
+ startDateTo: '2026-08-31 23:59:59',
+ ));
+ }
+
+ public function test_handle_throws_when_range_is_inverted(): void
+ {
+ $this->expectException(InvalidOccurrenceDatesException::class);
+
+ $this->handler->handle(new GetPublicEventOccurrencesDTO(
+ eventId: 1,
+ startDateFrom: '2026-09-01 00:00:00',
+ startDateTo: '2026-08-01 00:00:00',
+ ));
+ }
+
+ public function test_handle_throws_when_range_exceeds_maximum_span(): void
+ {
+ $this->expectException(InvalidOccurrenceDatesException::class);
+
+ $this->handler->handle(new GetPublicEventOccurrencesDTO(
+ eventId: 1,
+ startDateFrom: '2026-08-01 00:00:00',
+ startDateTo: '2026-10-01 00:00:00',
+ ));
+ }
+
+ public function test_handle_returns_occurrences_within_range(): void
+ {
+ $event = (new EventDomainObject)
+ ->setId(1)
+ ->setType(EventType::RECURRING->name)
+ ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(false))
+ ->setProductCategories(collect());
+
+ $occurrence = (new EventOccurrenceDomainObject)
+ ->setId(10)
+ ->setEventId(1)
+ ->setStartDate('2026-08-10 10:00:00')
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name);
+
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->eventRepository->shouldReceive('findById')->with(1)->andReturn($event);
+
+ $capturedWhere = null;
+ $capturedLimit = null;
+ $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturnUsing(static function (array $where, $columns = null, $orders = null, $limit = null) use (&$capturedWhere, &$capturedLimit, $occurrence) {
+ $capturedWhere = $where;
+ $capturedLimit = $limit;
+
+ return collect([$occurrence]);
+ });
+
+ $result = $this->handler->handle(new GetPublicEventOccurrencesDTO(
+ eventId: 1,
+ startDateFrom: '2026-08-01 00:00:00',
+ startDateTo: '2026-08-31 23:59:59',
+ ));
+
+ $this->assertSame($event, $result->event);
+ $this->assertTrue($result->occurrences->contains(
+ fn (EventOccurrenceDomainObject $o) => $o->getId() === 10
+ ));
+ $this->assertSame(GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES, $capturedLimit);
+
+ $bounds = collect($capturedWhere)
+ ->filter(static fn ($condition): bool => is_array($condition)
+ && ($condition[0] ?? null) === EventOccurrenceDomainObjectAbstract::START_DATE)
+ ->values();
+
+ $this->assertSame(['>=', '2026-08-01 00:00:00'], [$bounds[0][1], $bounds[0][2]]);
+ $this->assertSame(['<=', '2026-08-31 23:59:59'], [$bounds[1][1], $bounds[1][2]]);
+ }
+
+ public function test_handle_applies_sold_out_filter_when_event_hides_sold_out_occurrences(): void
+ {
+ $event = (new EventDomainObject)
+ ->setId(1)
+ ->setType(EventType::RECURRING->name)
+ ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(true))
+ ->setProductCategories(collect());
+
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->eventRepository->shouldReceive('findById')->with(1)->andReturn($event);
+
+ $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->with(
+ m::on(static fn (array $where): bool => collect($where)->filter(
+ static fn ($condition): bool => $condition instanceof Closure
+ )->count() === 2),
+ m::any(),
+ m::any(),
+ m::any(),
+ )
+ ->andReturn(collect());
+
+ $result = $this->handler->handle(new GetPublicEventOccurrencesDTO(
+ eventId: 1,
+ startDateFrom: '2026-08-01 00:00:00',
+ startDateTo: '2026-08-31 23:59:59',
+ ));
+
+ $this->assertCount(0, $result->occurrences);
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/DeletePriceOverrideHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/DeletePriceOverrideHandlerTest.php
new file mode 100644
index 0000000000..f2f96a5b0c
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/DeletePriceOverrideHandlerTest.php
@@ -0,0 +1,200 @@
+overrideRepository = Mockery::mock(ProductPriceOccurrenceOverrideRepositoryInterface::class);
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->databaseManager = Mockery::mock(DatabaseManager::class);
+
+ $this->databaseManager->shouldReceive('transaction')
+ ->andReturnUsing(fn ($callback) => $callback());
+
+ $this->handler = new DeletePriceOverrideHandler(
+ $this->overrideRepository,
+ $this->occurrenceRepository,
+ $this->databaseManager,
+ );
+ }
+
+ public function test_handle_successfully_deletes_override_scoped_to_occurrence(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+ $overrideId = 5;
+
+ $existingOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $existingOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => $occurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
+ ])
+ ->andReturn($existingOccurrence);
+
+ $this->overrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::ID => $overrideId,
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrenceId,
+ ])
+ ->andReturn($existingOverride);
+
+ $this->overrideRepository
+ ->shouldReceive('deleteWhere')
+ ->once()
+ ->with([
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::ID => $overrideId,
+ ]);
+
+ $this->handler->handle($eventId, $occurrenceId, $overrideId);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_handle_throws_exception_when_occurrence_does_not_belong_to_event(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+ $overrideId = 5;
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => $occurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
+ ])
+ ->andReturn(null);
+
+ $this->overrideRepository->shouldNotReceive('findFirstWhere');
+ $this->overrideRepository->shouldNotReceive('deleteWhere');
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle($eventId, $occurrenceId, $overrideId);
+ }
+
+ public function test_handle_throws_exception_when_override_not_found(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+ $overrideId = 999;
+
+ $existingOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn($existingOccurrence);
+
+ $this->overrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::ID => $overrideId,
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrenceId,
+ ])
+ ->andReturn(null);
+
+ $this->overrideRepository->shouldNotReceive('deleteWhere');
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle($eventId, $occurrenceId, $overrideId);
+ }
+
+ public function test_handle_scopes_lookup_to_occurrence_id(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 42;
+ $overrideId = 7;
+
+ $existingOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn($existingOccurrence);
+
+ $this->overrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(Mockery::on(function ($arg) use ($occurrenceId, $overrideId) {
+ return $arg[ProductPriceOccurrenceOverrideDomainObjectAbstract::ID] === $overrideId
+ && $arg[ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID] === $occurrenceId;
+ }))
+ ->andReturn(null);
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle($eventId, $occurrenceId, $overrideId);
+ }
+
+ public function test_handle_deletes_only_the_specified_override(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+ $overrideId = 3;
+
+ $existingOccurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $existingOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn($existingOccurrence);
+
+ $this->overrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn($existingOverride);
+
+ $this->overrideRepository
+ ->shouldReceive('deleteWhere')
+ ->once()
+ ->with([
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::ID => $overrideId,
+ ]);
+
+ $this->handler->handle($eventId, $occurrenceId, $overrideId);
+
+ $this->assertTrue(true);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandlerTest.php
new file mode 100644
index 0000000000..db79fa1471
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/GetPriceOverridesHandlerTest.php
@@ -0,0 +1,91 @@
+overrideRepository = Mockery::mock(ProductPriceOccurrenceOverrideRepositoryInterface::class);
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->handler = new GetPriceOverridesHandler($this->overrideRepository, $this->occurrenceRepository);
+ }
+
+ private function mockOccurrenceOwnership(): void
+ {
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+ }
+
+ public function test_handle_returns_collection_of_overrides_for_occurrence(): void
+ {
+ $this->mockOccurrenceOwnership();
+
+ $override1 = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+ $override2 = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+ $expectedCollection = new Collection([$override1, $override2]);
+
+ $this->overrideRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->with([ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => 10])
+ ->andReturn($expectedCollection);
+
+ $result = $this->handler->handle(1, 10);
+
+ $this->assertCount(2, $result);
+ $this->assertSame($expectedCollection, $result);
+ }
+
+ public function test_handle_returns_empty_collection_when_none_exist(): void
+ {
+ $this->mockOccurrenceOwnership();
+
+ $this->overrideRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(new Collection);
+
+ $result = $this->handler->handle(1, 99);
+
+ $this->assertTrue($result->isEmpty());
+ }
+
+ public function test_handle_throws_when_occurrence_does_not_belong_to_event(): void
+ {
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn(null);
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle(1, 999);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandlerTest.php
new file mode 100644
index 0000000000..72a90ddaa4
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandlerTest.php
@@ -0,0 +1,334 @@
+overrideRepository = Mockery::mock(ProductPriceOccurrenceOverrideRepositoryInterface::class);
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->productPriceRepository = Mockery::mock(ProductPriceRepositoryInterface::class);
+ $this->productRepository = Mockery::mock(ProductRepositoryInterface::class);
+ $this->databaseManager = Mockery::mock(DatabaseManager::class);
+
+ $this->databaseManager->shouldReceive('transaction')
+ ->andReturnUsing(fn ($callback) => $callback());
+
+ $this->handler = new UpsertPriceOverrideHandler(
+ $this->overrideRepository,
+ $this->occurrenceRepository,
+ $this->productPriceRepository,
+ $this->productRepository,
+ $this->databaseManager,
+ );
+ }
+
+ private function mockOwnershipChecks(): void
+ {
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $priceMock = Mockery::mock(ProductPriceDomainObject::class);
+ $priceMock->shouldReceive('getProductId')->andReturn(5);
+ $this->productPriceRepository
+ ->shouldReceive('findFirst')
+ ->andReturn($priceMock);
+
+ $this->productRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn(Mockery::mock(ProductDomainObject::class));
+ }
+
+ public function test_handle_creates_new_override_when_none_exists(): void
+ {
+ $this->mockOwnershipChecks();
+
+ $dto = new UpsertPriceOverrideDTO(
+ event_id: 1,
+ event_occurrence_id: 10,
+ product_price_id: 20,
+ price: 99.99,
+ );
+
+ $expectedOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+
+ $this->overrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => 10,
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::PRODUCT_PRICE_ID => 20,
+ ])
+ ->andReturn(null);
+
+ $this->overrideRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with([
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => 10,
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::PRODUCT_PRICE_ID => 20,
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::PRICE => 99.99,
+ ])
+ ->andReturn($expectedOverride);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertSame($expectedOverride, $result);
+ }
+
+ public function test_handle_updates_existing_override(): void
+ {
+ $this->mockOwnershipChecks();
+
+ $dto = new UpsertPriceOverrideDTO(
+ event_id: 1,
+ event_occurrence_id: 10,
+ product_price_id: 20,
+ price: 149.99,
+ );
+
+ $existingOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+ $existingOverride->shouldReceive('getId')->andReturn(5);
+
+ $updatedOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+
+ $this->overrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => 10,
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::PRODUCT_PRICE_ID => 20,
+ ])
+ ->andReturn($existingOverride);
+
+ $this->overrideRepository
+ ->shouldNotReceive('create');
+
+ $this->overrideRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(5, [
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::PRICE => 149.99,
+ ])
+ ->andReturn($updatedOverride);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertSame($updatedOverride, $result);
+ }
+
+ public function test_handle_passes_correct_event_occurrence_id(): void
+ {
+ $occurrenceId = 42;
+ $this->mockOwnershipChecks();
+
+ $dto = new UpsertPriceOverrideDTO(
+ event_id: 1,
+ event_occurrence_id: $occurrenceId,
+ product_price_id: 1,
+ price: 50.00,
+ );
+
+ $expectedOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+
+ $this->overrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(Mockery::on(function ($arg) use ($occurrenceId) {
+ return $arg[ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID] === $occurrenceId;
+ }))
+ ->andReturn(null);
+
+ $this->overrideRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with(Mockery::on(function ($arg) use ($occurrenceId) {
+ return $arg[ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID] === $occurrenceId;
+ }))
+ ->andReturn($expectedOverride);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertSame($expectedOverride, $result);
+ }
+
+ public function test_handle_passes_correct_product_price_id(): void
+ {
+ $priceId = 77;
+ $this->mockOwnershipChecks();
+
+ $dto = new UpsertPriceOverrideDTO(
+ event_id: 1,
+ event_occurrence_id: 1,
+ product_price_id: $priceId,
+ price: 25.00,
+ );
+
+ $expectedOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+
+ $this->overrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(Mockery::on(function ($arg) use ($priceId) {
+ return $arg[ProductPriceOccurrenceOverrideDomainObjectAbstract::PRODUCT_PRICE_ID] === $priceId;
+ }))
+ ->andReturn(null);
+
+ $this->overrideRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with(Mockery::on(function ($arg) use ($priceId) {
+ return $arg[ProductPriceOccurrenceOverrideDomainObjectAbstract::PRODUCT_PRICE_ID] === $priceId;
+ }))
+ ->andReturn($expectedOverride);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertSame($expectedOverride, $result);
+ }
+
+ public function test_handle_passes_correct_price(): void
+ {
+ $price = 199.50;
+ $this->mockOwnershipChecks();
+
+ $dto = new UpsertPriceOverrideDTO(
+ event_id: 1,
+ event_occurrence_id: 1,
+ product_price_id: 2,
+ price: $price,
+ );
+
+ $expectedOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+
+ $this->overrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn(null);
+
+ $this->overrideRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with(Mockery::on(function ($arg) use ($price) {
+ return $arg[ProductPriceOccurrenceOverrideDomainObjectAbstract::PRICE] === $price;
+ }))
+ ->andReturn($expectedOverride);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertSame($expectedOverride, $result);
+ }
+
+ public function test_it_throws_when_occurrence_not_found_for_event(): void
+ {
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn(null);
+
+ $dto = new UpsertPriceOverrideDTO(
+ event_id: 1,
+ event_occurrence_id: 99,
+ product_price_id: 20,
+ price: 49.99,
+ );
+
+ $this->handler->handle($dto);
+ }
+
+ public function test_it_throws_when_product_price_not_found(): void
+ {
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $this->productPriceRepository
+ ->shouldReceive('findFirst')
+ ->once()
+ ->andReturn(null);
+
+ $dto = new UpsertPriceOverrideDTO(
+ event_id: 1,
+ event_occurrence_id: 10,
+ product_price_id: 99,
+ price: 49.99,
+ );
+
+ $this->handler->handle($dto);
+ }
+
+ public function test_it_throws_when_product_not_belonging_to_event(): void
+ {
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $priceMock = Mockery::mock(ProductPriceDomainObject::class);
+ $priceMock->shouldReceive('getProductId')->andReturn(5);
+ $this->productPriceRepository
+ ->shouldReceive('findFirst')
+ ->once()
+ ->andReturn($priceMock);
+
+ $this->productRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn(null);
+
+ $dto = new UpsertPriceOverrideDTO(
+ event_id: 1,
+ event_occurrence_id: 10,
+ product_price_id: 20,
+ price: 49.99,
+ );
+
+ $this->handler->handle($dto);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/ReactivateOccurrenceHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/ReactivateOccurrenceHandlerTest.php
new file mode 100644
index 0000000000..575aa2bb99
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/ReactivateOccurrenceHandlerTest.php
@@ -0,0 +1,174 @@
+occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->exclusionService = Mockery::mock(RecurrenceRuleExclusionService::class);
+ $this->databaseManager = Mockery::mock(DatabaseManager::class);
+
+ $this->databaseManager->shouldReceive('transaction')
+ ->andReturnUsing(fn ($callback) => $callback());
+
+ $this->handler = new ReactivateOccurrenceHandler(
+ $this->occurrenceRepository,
+ $this->exclusionService,
+ $this->databaseManager,
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ public function test_reactivates_cancelled_occurrence_and_clears_cancelled_attendees_count(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getEventId')->andReturn($eventId);
+ $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::CANCELLED->name);
+ $occurrence->shouldReceive('getCancelledAttendeesCount')->andReturn(0);
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-01 10:00:00');
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn($occurrence);
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with($occurrenceId, [
+ EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::ACTIVE->name,
+ EventOccurrenceDomainObjectAbstract::CANCELLED_ATTENDEES_COUNT => null,
+ ])
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $this->exclusionService
+ ->shouldReceive('removeExclusion')
+ ->once()
+ ->with($eventId, '2026-06-01 10:00:00');
+
+ $result = $this->handler->handle($eventId, $occurrenceId);
+
+ $this->assertNotNull($result);
+ }
+
+ public function test_reactivates_occurrence_cancelled_before_count_tracking_existed(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getEventId')->andReturn($eventId);
+ $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::CANCELLED->name);
+ $occurrence->shouldReceive('getCancelledAttendeesCount')->andReturnNull();
+ $occurrence->shouldReceive('getStartDate')->andReturn('2026-06-01 10:00:00');
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn($occurrence);
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $this->exclusionService->shouldReceive('removeExclusion')->once();
+
+ $result = $this->handler->handle($eventId, $occurrenceId);
+
+ $this->assertNotNull($result);
+ }
+
+ public function test_rejects_reactivation_of_non_cancelled_occurrence(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getEventId')->andReturn($eventId);
+ $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::ACTIVE->name);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->andReturn($occurrence);
+
+ $this->occurrenceRepository->shouldNotReceive('updateFromArray');
+ $this->exclusionService->shouldNotReceive('removeExclusion');
+
+ $this->expectException(ValidationException::class);
+
+ $this->handler->handle($eventId, $occurrenceId);
+ }
+
+ public function test_blocks_reactivation_when_occurrence_cancellation_cancelled_attendees(): void
+ {
+ $eventId = 1;
+ $occurrenceId = 10;
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getEventId')->andReturn($eventId);
+ $occurrence->shouldReceive('getStatus')->andReturn(EventOccurrenceStatus::CANCELLED->name);
+ $occurrence->shouldReceive('getCancelledAttendeesCount')->andReturn(3);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->with($occurrenceId)->andReturn($occurrence);
+
+ $this->occurrenceRepository->shouldNotReceive('updateFromArray');
+ $this->exclusionService->shouldNotReceive('removeExclusion');
+
+ $this->expectException(ValidationException::class);
+
+ $this->handler->handle($eventId, $occurrenceId);
+ }
+
+ public function test_throws_when_occurrence_not_found_for_event(): void
+ {
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->andReturn(null);
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle(eventId: 1, occurrenceId: 99);
+ }
+
+ public function test_throws_when_occurrence_belongs_to_different_event(): void
+ {
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('getEventId')->andReturn(2);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findByIdLocked')->once()->andReturn($occurrence);
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle(eventId: 1, occurrenceId: 10);
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandlerTest.php
new file mode 100644
index 0000000000..6167f633e4
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateEventOccurrenceHandlerTest.php
@@ -0,0 +1,613 @@
+occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->eventRepository = Mockery::mock(EventRepositoryInterface::class);
+ $this->eventLocationUpserter = Mockery::mock(EventLocationUpserter::class);
+ $this->eventLocationCleaner = Mockery::mock(EventLocationCleaner::class);
+ $this->exclusionService = Mockery::mock(RecurrenceRuleExclusionService::class);
+ $this->databaseManager = Mockery::mock(DatabaseManager::class);
+
+ $this->databaseManager->shouldReceive('transaction')
+ ->andReturnUsing(fn ($callback) => $callback());
+
+ $this->handler = new UpdateEventOccurrenceHandler(
+ $this->occurrenceRepository,
+ $this->eventRepository,
+ $this->eventLocationUpserter,
+ $this->eventLocationCleaner,
+ $this->exclusionService,
+ $this->databaseManager,
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ private function existingOccurrence(
+ int $id = 10,
+ string $startDate = '2026-06-01 10:00:00',
+ ?string $endDate = '2026-06-01 18:00:00',
+ ?int $capacity = 100,
+ bool $isOverridden = false,
+ string $status = EventOccurrenceStatus::ACTIVE->name,
+ int $usedCapacity = 0,
+ ?int $eventLocationId = null,
+ ): MockInterface {
+ $occ = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occ->shouldReceive('getId')->andReturn($id);
+ $occ->shouldReceive('getStartDate')->andReturn($startDate);
+ $occ->shouldReceive('getEndDate')->andReturn($endDate);
+ $occ->shouldReceive('getCapacity')->andReturn($capacity);
+ $occ->shouldReceive('getIsOverridden')->andReturn($isOverridden);
+ $occ->shouldReceive('getStatus')->andReturn($status);
+ $occ->shouldReceive('getUsedCapacity')->andReturn($usedCapacity);
+ $occ->shouldReceive('getEventLocationId')->andReturn($eventLocationId);
+
+ return $occ;
+ }
+
+ public function test_flags_as_overridden_when_start_date_changes(): void
+ {
+ $occurrenceId = 10;
+ $eventId = 1;
+
+ $existing = $this->existingOccurrence(id: $occurrenceId);
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-02 10:00:00',
+ end_date: '2026-06-01 18:00:00',
+ capacity: 100,
+ label: 'Same label',
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with($occurrenceId, Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true))
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $this->exclusionService
+ ->shouldReceive('addExclusions')
+ ->once()
+ ->with($eventId, ['2026-06-01 10:00:00']);
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_flags_as_overridden_when_end_date_changes(): void
+ {
+ $occurrenceId = 10;
+ $eventId = 1;
+
+ $existing = $this->existingOccurrence(id: $occurrenceId);
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-01 10:00:00',
+ end_date: '2026-06-01 20:00:00',
+ capacity: 100,
+ label: null,
+ );
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->exclusionService->shouldNotReceive('addExclusions');
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with($occurrenceId, Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true))
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_flags_as_overridden_when_capacity_changes(): void
+ {
+ $occurrenceId = 10;
+ $eventId = 1;
+
+ $existing = $this->existingOccurrence(id: $occurrenceId);
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-01 10:00:00',
+ end_date: '2026-06-01 18:00:00',
+ capacity: 200,
+ label: null,
+ );
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->exclusionService->shouldNotReceive('addExclusions');
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with($occurrenceId, Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true))
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_does_not_flag_as_overridden_for_label_only_change(): void
+ {
+ $occurrenceId = 10;
+ $eventId = 1;
+
+ $existing = $this->existingOccurrence(id: $occurrenceId, isOverridden: false);
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-01 10:00:00',
+ end_date: '2026-06-01 18:00:00',
+ capacity: 100,
+ label: 'Brand new label',
+ );
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->exclusionService->shouldNotReceive('addExclusions');
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with($occurrenceId, Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === false))
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_preserves_override_flag_when_already_overridden(): void
+ {
+ $occurrenceId = 10;
+ $eventId = 1;
+
+ $existing = $this->existingOccurrence(id: $occurrenceId, isOverridden: true);
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-01 10:00:00',
+ end_date: '2026-06-01 18:00:00',
+ capacity: 100,
+ label: 'Label change only',
+ );
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->exclusionService->shouldNotReceive('addExclusions');
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with($occurrenceId, Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true))
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_does_not_flag_as_overridden_when_dates_are_same_instant_different_format(): void
+ {
+ $occurrenceId = 10;
+ $eventId = 1;
+
+ $existing = $this->existingOccurrence(
+ id: $occurrenceId,
+ startDate: '2026-06-01 10:00:00',
+ endDate: '2026-06-01 18:00:00',
+ isOverridden: false,
+ );
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: 'Mon Jun 01 2026 10:00:00 GMT+0000',
+ end_date: 'Mon Jun 01 2026 18:00:00 GMT+0000',
+ capacity: 100,
+ label: 'New label',
+ );
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->exclusionService->shouldNotReceive('addExclusions');
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with($occurrenceId, Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === false))
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_handle_does_not_write_status_when_capacity_unchanged_and_status_already_correct(): void
+ {
+ $occurrenceId = 10;
+ $eventId = 1;
+
+ $existing = $this->existingOccurrence(
+ id: $occurrenceId,
+ capacity: 100,
+ status: EventOccurrenceStatus::ACTIVE->name,
+ usedCapacity: 10,
+ );
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-01 10:00:00',
+ end_date: '2026-06-01 18:00:00',
+ capacity: 100,
+ label: 'New label',
+ );
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->exclusionService->shouldNotReceive('addExclusions');
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(
+ $occurrenceId,
+ Mockery::on(fn (array $attrs) => ! array_key_exists(EventOccurrenceDomainObjectAbstract::STATUS, $attrs)),
+ )
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_handle_never_writes_status_when_capacity_crosses_used(): void
+ {
+ $occurrenceId = 10;
+ $eventId = 1;
+
+ $existing = $this->existingOccurrence(
+ id: $occurrenceId,
+ capacity: 100,
+ status: EventOccurrenceStatus::ACTIVE->name,
+ usedCapacity: 80,
+ );
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-01 10:00:00',
+ end_date: '2026-06-01 18:00:00',
+ capacity: 50,
+ );
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->exclusionService->shouldNotReceive('addExclusions');
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(
+ $occurrenceId,
+ Mockery::on(fn (array $attrs) => ! array_key_exists(EventOccurrenceDomainObjectAbstract::STATUS, $attrs)
+ && $attrs[EventOccurrenceDomainObjectAbstract::CAPACITY] === 50),
+ )
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_handle_does_not_write_status_for_cancelled_occurrence_even_when_capacity_changes(): void
+ {
+ $occurrenceId = 10;
+ $eventId = 1;
+
+ $existing = $this->existingOccurrence(
+ id: $occurrenceId,
+ capacity: 100,
+ status: EventOccurrenceStatus::CANCELLED->name,
+ usedCapacity: 0,
+ );
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-01 10:00:00',
+ end_date: '2026-06-01 18:00:00',
+ capacity: 200,
+ );
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->exclusionService->shouldNotReceive('addExclusions');
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(
+ $occurrenceId,
+ Mockery::on(fn (array $attrs) => ! array_key_exists(EventOccurrenceDomainObjectAbstract::STATUS, $attrs)),
+ )
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_gains_override_calls_create_for_event(): void
+ {
+ $occurrenceId = 10;
+ $eventId = 1;
+
+ $existing = $this->existingOccurrence(id: $occurrenceId, eventLocationId: null);
+
+ $locationData = new EventLocationData(
+ type: LocationType::IN_PERSON,
+ location_id: 42,
+ );
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-01 10:00:00',
+ end_date: '2026-06-01 18:00:00',
+ capacity: 100,
+ event_location: $locationData,
+ );
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getAccountId')->andReturn(7);
+
+ $createdEventLocation = Mockery::mock(EventLocationDomainObject::class);
+ $createdEventLocation->shouldReceive('getId')->andReturn(500);
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->exclusionService->shouldNotReceive('addExclusions');
+
+ $this->eventRepository
+ ->shouldReceive('findById')
+ ->once()
+ ->with($eventId)
+ ->andReturn($event);
+
+ $this->eventLocationUpserter
+ ->shouldReceive('createForEvent')
+ ->once()
+ ->with($eventId, 7, $locationData)
+ ->andReturn($createdEventLocation);
+
+ $this->eventLocationUpserter->shouldNotReceive('updateInPlace');
+ $this->eventLocationCleaner->shouldNotReceive('deleteIfOrphaned');
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(
+ $occurrenceId,
+ Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID] === 500
+ && $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true),
+ )
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_edits_existing_override_calls_update_in_place(): void
+ {
+ $occurrenceId = 10;
+ $eventId = 1;
+ $existingEventLocationId = 5;
+
+ $existing = $this->existingOccurrence(
+ id: $occurrenceId,
+ eventLocationId: $existingEventLocationId,
+ );
+
+ $locationData = new EventLocationData(
+ type: LocationType::IN_PERSON,
+ location_id: 42,
+ );
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-01 10:00:00',
+ end_date: '2026-06-01 18:00:00',
+ capacity: 100,
+ event_location: $locationData,
+ );
+
+ $event = Mockery::mock(EventDomainObject::class);
+ $event->shouldReceive('getAccountId')->andReturn(7);
+
+ $updatedEventLocation = Mockery::mock(EventLocationDomainObject::class);
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->exclusionService->shouldNotReceive('addExclusions');
+
+ $this->eventRepository
+ ->shouldReceive('findById')
+ ->once()
+ ->with($eventId)
+ ->andReturn($event);
+
+ $this->eventLocationUpserter
+ ->shouldReceive('updateInPlace')
+ ->once()
+ ->with($existingEventLocationId, $eventId, 7, $locationData)
+ ->andReturn($updatedEventLocation);
+
+ $this->eventLocationUpserter->shouldNotReceive('createForEvent');
+ $this->eventLocationCleaner->shouldNotReceive('deleteIfOrphaned');
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(
+ $occurrenceId,
+ Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID] === $existingEventLocationId),
+ )
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_clear_event_location_clears_fk_and_cleans_up(): void
+ {
+ $occurrenceId = 10;
+ $eventId = 1;
+ $existingEventLocationId = 5;
+
+ $existing = $this->existingOccurrence(
+ id: $occurrenceId,
+ eventLocationId: $existingEventLocationId,
+ );
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-01 10:00:00',
+ end_date: '2026-06-01 18:00:00',
+ capacity: 100,
+ clear_event_location: true,
+ );
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->exclusionService->shouldNotReceive('addExclusions');
+
+ $this->eventLocationUpserter->shouldNotReceive('createForEvent');
+ $this->eventLocationUpserter->shouldNotReceive('updateInPlace');
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(
+ $occurrenceId,
+ Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID] === null
+ && $attrs[EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN] === true),
+ )
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $this->eventLocationCleaner
+ ->shouldReceive('deleteIfOrphaned')
+ ->once()
+ ->with($existingEventLocationId);
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_clear_event_location_noop_when_no_existing_fk(): void
+ {
+ $occurrenceId = 10;
+ $eventId = 1;
+
+ $existing = $this->existingOccurrence(id: $occurrenceId, eventLocationId: null);
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-01 10:00:00',
+ end_date: '2026-06-01 18:00:00',
+ capacity: 100,
+ clear_event_location: true,
+ );
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($existing);
+
+ $this->exclusionService->shouldNotReceive('addExclusions');
+
+ $this->eventLocationUpserter->shouldNotReceive('createForEvent');
+ $this->eventLocationUpserter->shouldNotReceive('updateInPlace');
+ $this->eventLocationCleaner->shouldNotReceive('deleteIfOrphaned');
+
+ $this->occurrenceRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(
+ $occurrenceId,
+ Mockery::on(fn (array $attrs) => $attrs[EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID] === null),
+ )
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
+ $result = $this->handler->handle($occurrenceId, $dto);
+ $this->assertNotNull($result);
+ }
+
+ public function test_throws_when_occurrence_not_found(): void
+ {
+ $occurrenceId = 999;
+ $eventId = 1;
+
+ $dto = new UpsertEventOccurrenceDTO(
+ event_id: $eventId,
+ start_date: '2026-06-01 10:00:00',
+ );
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => $occurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
+ ])
+ ->andReturn(null);
+
+ $this->occurrenceRepository->shouldNotReceive('updateFromArray');
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle($occurrenceId, $dto);
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandlerTest.php
new file mode 100644
index 0000000000..954d71080c
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandlerTest.php
@@ -0,0 +1,170 @@
+visibilityRepository = Mockery::mock(ProductOccurrenceVisibilityRepositoryInterface::class);
+ $this->productRepository = Mockery::mock(ProductRepositoryInterface::class);
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->databaseManager = Mockery::mock(DatabaseManager::class);
+
+ $this->databaseManager->shouldReceive('transaction')
+ ->andReturnUsing(fn ($callback) => $callback());
+
+ $this->handler = new UpdateProductVisibilityHandler(
+ $this->visibilityRepository,
+ $this->productRepository,
+ $this->occurrenceRepository,
+ $this->databaseManager,
+ );
+ }
+
+ private function makeProductCollection(array $ids): Collection
+ {
+ return collect(array_map(
+ fn ($id) => (new ProductDomainObject)->setId($id),
+ $ids,
+ ));
+ }
+
+ public function test_handle_creates_visibility_records_for_selected_products(): void
+ {
+ $dto = new UpdateProductVisibilityDTO(
+ event_id: 1,
+ event_occurrence_id: 10,
+ product_ids: [5],
+ );
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ EventOccurrenceDomainObjectAbstract::ID => 10,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => 1,
+ ])
+ ->andReturn($occurrence);
+
+ $this->visibilityRepository->shouldReceive('deleteWhere')->once();
+
+ $this->productRepository->shouldReceive('findWhere')
+ ->once()
+ ->with([ProductDomainObjectAbstract::EVENT_ID => 1])
+ ->andReturn($this->makeProductCollection([5, 10]));
+
+ $this->visibilityRepository->shouldReceive('create')
+ ->once()
+ ->with([
+ ProductOccurrenceVisibilityDomainObjectAbstract::EVENT_OCCURRENCE_ID => 10,
+ ProductOccurrenceVisibilityDomainObjectAbstract::PRODUCT_ID => 5,
+ ]);
+
+ $visibilityRecords = collect([Mockery::mock(ProductOccurrenceVisibilityDomainObject::class)]);
+ $this->visibilityRepository->shouldReceive('findWhere')
+ ->once()
+ ->with([ProductOccurrenceVisibilityDomainObjectAbstract::EVENT_OCCURRENCE_ID => 10])
+ ->andReturn($visibilityRecords);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertCount(1, $result);
+ }
+
+ public function test_handle_returns_empty_when_all_products_selected(): void
+ {
+ $dto = new UpdateProductVisibilityDTO(
+ event_id: 1,
+ event_occurrence_id: 10,
+ product_ids: [5, 10],
+ );
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($occurrence);
+ $this->visibilityRepository->shouldReceive('deleteWhere')->once();
+
+ $this->productRepository->shouldReceive('findWhere')->once()->andReturn($this->makeProductCollection([5, 10]));
+
+ $this->visibilityRepository->shouldNotReceive('create');
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertInstanceOf(Collection::class, $result);
+ $this->assertEmpty($result);
+ }
+
+ public function test_handle_throws_when_occurrence_not_found(): void
+ {
+ $dto = new UpdateProductVisibilityDTO(
+ event_id: 1,
+ event_occurrence_id: 999,
+ product_ids: [5],
+ );
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn(null);
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle($dto);
+ }
+
+ public function test_handle_throws_when_product_id_does_not_belong_to_event(): void
+ {
+ $dto = new UpdateProductVisibilityDTO(
+ event_id: 1,
+ event_occurrence_id: 10,
+ product_ids: [5, 999],
+ );
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn($occurrence);
+ $this->visibilityRepository->shouldReceive('deleteWhere')->once();
+
+ $this->productRepository->shouldReceive('findWhere')->once()->andReturn($this->makeProductCollection([5]));
+
+ $this->visibilityRepository->shouldNotReceive('create');
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->handler->handle($dto);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandlerTest.php
index 5dfa38f978..fd37df980a 100644
--- a/backend/tests/Unit/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/EventSettings/GetPlatformFeePreviewHandlerTest.php
@@ -2,11 +2,9 @@
namespace Tests\Unit\Services\Application\Handlers\EventSettings;
-use Brick\Money\Currency;
-use HiEvents\DomainObjects\AccountConfigurationDomainObject;
-use HiEvents\DomainObjects\AccountDomainObject;
use HiEvents\DomainObjects\EventDomainObject;
-use HiEvents\Repository\Interfaces\AccountRepositoryInterface;
+use HiEvents\DomainObjects\OrganizerConfigurationDomainObject;
+use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Services\Application\Handlers\EventSettings\DTO\GetPlatformFeePreviewDTO;
use HiEvents\Services\Application\Handlers\EventSettings\GetPlatformFeePreviewHandler;
@@ -20,137 +18,99 @@ class GetPlatformFeePreviewHandlerTest extends TestCase
{
use MockeryPHPUnitIntegration;
- private AccountRepositoryInterface $accountRepository;
private EventRepositoryInterface $eventRepository;
+
private CurrencyConversionClientInterface $currencyConversionClient;
+
private GetPlatformFeePreviewHandler $handler;
protected function setUp(): void
{
parent::setUp();
- $this->accountRepository = Mockery::mock(AccountRepositoryInterface::class);
$this->eventRepository = Mockery::mock(EventRepositoryInterface::class);
$this->currencyConversionClient = Mockery::mock(CurrencyConversionClientInterface::class);
$this->handler = new GetPlatformFeePreviewHandler(
- $this->accountRepository,
$this->eventRepository,
- $this->currencyConversionClient
+ $this->currencyConversionClient,
);
}
- public function testPreviewWithSameCurrency(): void
+ private function mockEventWithConfiguration(string $eventCurrency, ?OrganizerConfigurationDomainObject $configuration): EventDomainObject
{
- $eventId = 1;
- $price = 100.0;
+ $organizer = Mockery::mock(OrganizerDomainObject::class);
+ $organizer->shouldReceive('getOrganizerConfiguration')->andReturn($configuration);
$event = Mockery::mock(EventDomainObject::class);
- $event->shouldReceive('getCurrency')->andReturn('USD');
+ $event->shouldReceive('getCurrency')->andReturn($eventCurrency);
+ $event->shouldReceive('getOrganizer')->andReturn($organizer);
+
+ return $event;
+ }
- $configuration = Mockery::mock(AccountConfigurationDomainObject::class);
+ public function test_preview_with_same_currency(): void
+ {
+ $configuration = Mockery::mock(OrganizerConfigurationDomainObject::class);
$configuration->shouldReceive('getApplicationFeeCurrency')->andReturn('USD');
$configuration->shouldReceive('getFixedApplicationFee')->andReturn(1.0);
$configuration->shouldReceive('getPercentageApplicationFee')->andReturn(10.0);
- $account = Mockery::mock(AccountDomainObject::class);
- $account->shouldReceive('getConfiguration')->andReturn($configuration);
-
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->eventRepository->shouldReceive('findById')
- ->with($eventId)
- ->andReturn($event);
+ ->with(1)
+ ->andReturn($this->mockEventWithConfiguration('USD', $configuration));
- $this->accountRepository->shouldReceive('loadRelation')
- ->andReturnSelf();
- $this->accountRepository->shouldReceive('findByEventId')
- ->with($eventId)
- ->andReturn($account);
-
- $dto = new GetPlatformFeePreviewDTO(eventId: $eventId, price: $price);
- $result = $this->handler->handle($dto);
+ $result = $this->handler->handle(new GetPlatformFeePreviewDTO(eventId: 1, price: 100.0));
$this->assertEquals('USD', $result->eventCurrency);
$this->assertEquals('USD', $result->feeCurrency);
$this->assertEquals(1.0, $result->fixedFeeOriginal);
$this->assertEquals(1.0, $result->fixedFeeConverted);
$this->assertEquals(10.0, $result->percentageFee);
- $this->assertEquals(100.0, $result->samplePrice);
- // Gross-up: (1 + 100*0.1) / (1 - 0.1) = 11 / 0.9 = 12.22
$this->assertEquals(12.22, $result->platformFee);
$this->assertEquals(112.22, $result->total);
}
- public function testPreviewWithCurrencyConversion(): void
+ public function test_preview_with_currency_conversion(): void
{
- $eventId = 1;
- $price = 100.0;
-
- $event = Mockery::mock(EventDomainObject::class);
- $event->shouldReceive('getCurrency')->andReturn('EUR');
-
- $configuration = Mockery::mock(AccountConfigurationDomainObject::class);
+ $configuration = Mockery::mock(OrganizerConfigurationDomainObject::class);
$configuration->shouldReceive('getApplicationFeeCurrency')->andReturn('GBP');
$configuration->shouldReceive('getFixedApplicationFee')->andReturn(1.0);
$configuration->shouldReceive('getPercentageApplicationFee')->andReturn(10.0);
- $account = Mockery::mock(AccountDomainObject::class);
- $account->shouldReceive('getConfiguration')->andReturn($configuration);
-
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->eventRepository->shouldReceive('findById')
- ->with($eventId)
- ->andReturn($event);
+ ->with(1)
+ ->andReturn($this->mockEventWithConfiguration('EUR', $configuration));
- $this->accountRepository->shouldReceive('loadRelation')
- ->andReturnSelf();
- $this->accountRepository->shouldReceive('findByEventId')
- ->with($eventId)
- ->andReturn($account);
-
- // Mock GBP to EUR conversion: £1 = €1.15
$this->currencyConversionClient->shouldReceive('convert')
->with(
- Mockery::on(fn($c) => $c->getCurrencyCode() === 'GBP'),
- Mockery::on(fn($c) => $c->getCurrencyCode() === 'EUR'),
- 1.0
+ Mockery::on(fn ($c) => $c->getCurrencyCode() === 'GBP'),
+ Mockery::on(fn ($c) => $c->getCurrencyCode() === 'EUR'),
+ 1.0,
)
->andReturn(MoneyValue::fromFloat(1.15, 'EUR'));
- $dto = new GetPlatformFeePreviewDTO(eventId: $eventId, price: $price);
- $result = $this->handler->handle($dto);
+ $result = $this->handler->handle(new GetPlatformFeePreviewDTO(eventId: 1, price: 100.0));
$this->assertEquals('EUR', $result->eventCurrency);
$this->assertEquals('GBP', $result->feeCurrency);
$this->assertEquals(1.0, $result->fixedFeeOriginal);
$this->assertEquals(1.15, $result->fixedFeeConverted);
$this->assertEquals(10.0, $result->percentageFee);
- // Gross-up: (1.15 + 100*0.1) / (1 - 0.1) = 11.15 / 0.9 = 12.39
$this->assertEquals(12.39, $result->platformFee);
$this->assertEquals(112.39, $result->total);
}
- public function testPreviewWithNoConfiguration(): void
+ public function test_preview_with_no_configuration(): void
{
- $eventId = 1;
- $price = 100.0;
-
- $event = Mockery::mock(EventDomainObject::class);
- $event->shouldReceive('getCurrency')->andReturn('USD');
-
- $account = Mockery::mock(AccountDomainObject::class);
- $account->shouldReceive('getConfiguration')->andReturn(null);
-
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->eventRepository->shouldReceive('findById')
- ->with($eventId)
- ->andReturn($event);
-
- $this->accountRepository->shouldReceive('loadRelation')
- ->andReturnSelf();
- $this->accountRepository->shouldReceive('findByEventId')
- ->with($eventId)
- ->andReturn($account);
+ ->with(1)
+ ->andReturn($this->mockEventWithConfiguration('USD', null));
- $dto = new GetPlatformFeePreviewDTO(eventId: $eventId, price: $price);
- $result = $this->handler->handle($dto);
+ $result = $this->handler->handle(new GetPlatformFeePreviewDTO(eventId: 1, price: 100.0));
$this->assertEquals('USD', $result->eventCurrency);
$this->assertNull($result->feeCurrency);
@@ -159,36 +119,20 @@ public function testPreviewWithNoConfiguration(): void
$this->assertEquals(100.0, $result->total);
}
- public function testPreviewWithZeroPercentageFee(): void
+ public function test_preview_with_zero_percentage_fee(): void
{
- $eventId = 1;
- $price = 100.0;
-
- $event = Mockery::mock(EventDomainObject::class);
- $event->shouldReceive('getCurrency')->andReturn('USD');
-
- $configuration = Mockery::mock(AccountConfigurationDomainObject::class);
+ $configuration = Mockery::mock(OrganizerConfigurationDomainObject::class);
$configuration->shouldReceive('getApplicationFeeCurrency')->andReturn('USD');
$configuration->shouldReceive('getFixedApplicationFee')->andReturn(0.50);
$configuration->shouldReceive('getPercentageApplicationFee')->andReturn(0.0);
- $account = Mockery::mock(AccountDomainObject::class);
- $account->shouldReceive('getConfiguration')->andReturn($configuration);
-
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->eventRepository->shouldReceive('findById')
- ->with($eventId)
- ->andReturn($event);
-
- $this->accountRepository->shouldReceive('loadRelation')
- ->andReturnSelf();
- $this->accountRepository->shouldReceive('findByEventId')
- ->with($eventId)
- ->andReturn($account);
+ ->with(1)
+ ->andReturn($this->mockEventWithConfiguration('USD', $configuration));
- $dto = new GetPlatformFeePreviewDTO(eventId: $eventId, price: $price);
- $result = $this->handler->handle($dto);
+ $result = $this->handler->handle(new GetPlatformFeePreviewDTO(eventId: 1, price: 100.0));
- // With 0% percentage, just the fixed fee
$this->assertEquals(0.50, $result->platformFee);
$this->assertEquals(100.50, $result->total);
}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandlerTest.php
index 55d841ed00..9930b62ffe 100644
--- a/backend/tests/Unit/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandlerTest.php
@@ -45,7 +45,7 @@ public function test_omitted_show_copy_details_key_falls_back_to_existing_value(
*/
private function runPartialUpdate(bool $existingValue, array $settings): UpdateEventSettingsDTO
{
- $existingSettings = (new EventSettingDomainObject())
+ $existingSettings = (new EventSettingDomainObject)
->setAllowCopyDetailsToAllAttendees($existingValue)
->setPaymentProviders([]);
@@ -60,6 +60,7 @@ private function runPartialUpdate(bool $existingValue, array $settings): UpdateE
->once()
->andReturnUsing(function (UpdateEventSettingsDTO $dto) use (&$captured, $existingSettings) {
$captured = $dto;
+
return $existingSettings;
});
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandlerTest.php
index 9b364bfd04..dd546b1a8f 100644
--- a/backend/tests/Unit/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandlerTest.php
@@ -20,8 +20,11 @@ class UpdateEventSettingsHandlerTest extends TestCase
use MockeryPHPUnitIntegration;
private EventSettingsRepositoryInterface $eventSettingsRepository;
+
private HtmlPurifierService $purifier;
+
private DatabaseManager $databaseManager;
+
private UpdateEventSettingsHandler $handler;
protected function setUp(): void
@@ -32,11 +35,11 @@ protected function setUp(): void
$this->purifier = Mockery::mock(HtmlPurifierService::class);
$this->databaseManager = Mockery::mock(DatabaseManager::class);
- $this->purifier->shouldReceive('purify')->andReturnUsing(fn($v) => $v);
+ $this->purifier->shouldReceive('purify')->andReturnUsing(fn ($v) => $v);
$this->databaseManager
->shouldReceive('transaction')
- ->andReturnUsing(fn($callback) => $callback());
+ ->andReturnUsing(fn ($callback) => $callback());
$this->handler = new UpdateEventSettingsHandler(
eventSettingsRepository: $this->eventSettingsRepository,
@@ -45,11 +48,11 @@ protected function setUp(): void
);
}
- public function testDispatchesCapacityEventWhenAutoProcessToggledOn(): void
+ public function test_dispatches_capacity_event_when_auto_process_toggled_on(): void
{
Event::fake();
- $existingSettings = new EventSettingDomainObject();
+ $existingSettings = new EventSettingDomainObject;
$existingSettings->setWaitlistAutoProcess(false);
$this->eventSettingsRepository
@@ -72,11 +75,11 @@ public function testDispatchesCapacityEventWhenAutoProcessToggledOn(): void
});
}
- public function testDoesNotDispatchEventWhenAutoProcessAlreadyEnabled(): void
+ public function test_does_not_dispatch_event_when_auto_process_already_enabled(): void
{
Event::fake();
- $existingSettings = new EventSettingDomainObject();
+ $existingSettings = new EventSettingDomainObject;
$existingSettings->setWaitlistAutoProcess(true);
$this->eventSettingsRepository
@@ -95,11 +98,11 @@ public function testDoesNotDispatchEventWhenAutoProcessAlreadyEnabled(): void
Event::assertNotDispatched(CapacityChangedEvent::class);
}
- public function testDoesNotDispatchEventWhenAutoProcessDisabled(): void
+ public function test_does_not_dispatch_event_when_auto_process_disabled(): void
{
Event::fake();
- $existingSettings = new EventSettingDomainObject();
+ $existingSettings = new EventSettingDomainObject;
$existingSettings->setWaitlistAutoProcess(true);
$this->eventSettingsRepository
@@ -118,11 +121,11 @@ public function testDoesNotDispatchEventWhenAutoProcessDisabled(): void
Event::assertNotDispatched(CapacityChangedEvent::class);
}
- public function testPersistsAllowCopyDetailsToAllAttendees(): void
+ public function test_persists_allow_copy_details_to_all_attendees(): void
{
Event::fake();
- $existingSettings = new EventSettingDomainObject();
+ $existingSettings = new EventSettingDomainObject;
$this->eventSettingsRepository
->shouldReceive('findFirstWhere')
@@ -140,6 +143,7 @@ public function testPersistsAllowCopyDetailsToAllAttendees(): void
$captured = $arg['allow_copy_details_to_all_attendees'];
}
}
+
return 1;
});
@@ -153,9 +157,8 @@ public function testPersistsAllowCopyDetailsToAllAttendees(): void
private function createDTO(
?bool $waitlist_auto_process = null,
- bool $allow_copy_details_to_all_attendees = true,
- ): UpdateEventSettingsDTO
- {
+ bool $allow_copy_details_to_all_attendees = true,
+ ): UpdateEventSettingsDTO {
return UpdateEventSettingsDTO::fromArray([
'account_id' => 1,
'event_id' => 1,
diff --git a/backend/tests/Unit/Services/Application/Handlers/Images/CreateImageHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Images/CreateImageHandlerTest.php
index d272ee14ab..9810939b81 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Images/CreateImageHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Images/CreateImageHandlerTest.php
@@ -18,6 +18,7 @@
class CreateImageHandlerTest extends TestCase
{
private ImageUploadService $imageUploadService;
+
private CreateImageHandler $handler;
protected function setUp(): void
@@ -37,7 +38,7 @@ protected function setUp(): void
);
}
- public function testHandleSuccessfullyCreatesImage(): void
+ public function test_handle_successfully_creates_image(): void
{
$uploadedFile = m::mock(UploadedFile::class);
$imageDomainObject = m::mock(ImageDomainObject::class);
diff --git a/backend/tests/Unit/Services/Application/Handlers/Location/CreateLocationHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Location/CreateLocationHandlerTest.php
new file mode 100644
index 0000000000..aeac4ae671
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/Location/CreateLocationHandlerTest.php
@@ -0,0 +1,189 @@
+locationRepository = Mockery::mock(LocationRepositoryInterface::class);
+ $this->sanitizer = Mockery::mock(LocationDataSanitizer::class);
+ $databaseManager = Mockery::mock(DatabaseManager::class);
+ $databaseManager->shouldReceive('transaction')->andReturnUsing(fn ($callback) => $callback());
+
+ $this->handler = new CreateLocationHandler(
+ $this->locationRepository,
+ $this->sanitizer,
+ $databaseManager,
+ );
+ }
+
+ public function test_reuses_existing_location_for_same_provider_place(): void
+ {
+ $existing = Mockery::mock(LocationDomainObject::class);
+
+ $this->locationRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ LocationDomainObjectAbstract::ORGANIZER_ID => 10,
+ LocationDomainObjectAbstract::ACCOUNT_ID => 5,
+ LocationDomainObjectAbstract::PROVIDER => 'google',
+ LocationDomainObjectAbstract::PROVIDER_PLACE_ID => 'place_1',
+ ])
+ ->andReturn($existing);
+
+ $this->locationRepository->shouldNotReceive('create');
+
+ $this->assertSame($existing, $this->handler->handle($this->makeDto()));
+ }
+
+ public function test_creates_location_with_sanitized_fields_and_cached_raw_response(): void
+ {
+ $this->locationRepository->shouldReceive('findFirstWhere')->once()->andReturn(null);
+ $this->sanitizer->shouldReceive('sanitizeText')->with('The Venue')->andReturn('Clean Venue');
+ $this->sanitizer
+ ->shouldReceive('sanitizeAddress')
+ ->with(Mockery::type('array'))
+ ->andReturn(['city' => 'Dublin', 'country' => 'IE']);
+ $this->sanitizer
+ ->shouldReceive('cachedRawProviderResponse')
+ ->with('google', 'place_1')
+ ->andReturn(['id' => 'place_1']);
+
+ $created = Mockery::mock(LocationDomainObject::class);
+ $capturedAttributes = null;
+ $this->locationRepository
+ ->shouldReceive('create')
+ ->once()
+ ->andReturnUsing(function (array $attributes) use (&$capturedAttributes, $created) {
+ $capturedAttributes = $attributes;
+
+ return $created;
+ });
+
+ $this->assertSame($created, $this->handler->handle($this->makeDto()));
+ $this->assertSame('Clean Venue', $capturedAttributes[LocationDomainObjectAbstract::NAME]);
+ $this->assertSame(['city' => 'Dublin', 'country' => 'IE'], $capturedAttributes[LocationDomainObjectAbstract::STRUCTURED_ADDRESS]);
+ $this->assertSame(['id' => 'place_1'], $capturedAttributes[LocationDomainObjectAbstract::RAW_PROVIDER_RESPONSE]);
+ $this->assertTrue(str_starts_with($capturedAttributes[LocationDomainObjectAbstract::SHORT_ID], IdHelper::LOCATION_PREFIX));
+ }
+
+ public function test_manual_location_skips_reuse_lookup_and_stores_null_raw_response(): void
+ {
+ $this->locationRepository->shouldNotReceive('findFirstWhere');
+ $this->stubSanitizer();
+ $this->sanitizer
+ ->shouldReceive('cachedRawProviderResponse')
+ ->with(null, null)
+ ->andReturn(null);
+
+ $capturedAttributes = null;
+ $created = Mockery::mock(LocationDomainObject::class);
+ $this->locationRepository
+ ->shouldReceive('create')
+ ->once()
+ ->andReturnUsing(function (array $attributes) use (&$capturedAttributes, $created) {
+ $capturedAttributes = $attributes;
+
+ return $created;
+ });
+
+ $this->assertSame($created, $this->handler->handle($this->makeDto(provider: null, placeId: null)));
+ $this->assertNull($capturedAttributes[LocationDomainObjectAbstract::RAW_PROVIDER_RESPONSE]);
+ }
+
+ public function test_returns_existing_location_when_concurrent_create_hits_unique_index(): void
+ {
+ $existing = Mockery::mock(LocationDomainObject::class);
+
+ $this->locationRepository
+ ->shouldReceive('findFirstWhere')
+ ->twice()
+ ->andReturn(null, $existing);
+
+ $this->stubSanitizer();
+ $this->sanitizer->shouldReceive('cachedRawProviderResponse')->andReturn(null);
+
+ $this->locationRepository
+ ->shouldReceive('create')
+ ->once()
+ ->andThrow($this->makeUniqueViolation());
+
+ $this->assertSame($existing, $this->handler->handle($this->makeDto()));
+ }
+
+ public function test_rethrows_unique_violation_when_no_existing_row_matches(): void
+ {
+ $this->locationRepository->shouldNotReceive('findFirstWhere');
+ $this->stubSanitizer();
+ $this->sanitizer->shouldReceive('cachedRawProviderResponse')->andReturn(null);
+
+ $this->locationRepository
+ ->shouldReceive('create')
+ ->once()
+ ->andThrow($this->makeUniqueViolation());
+
+ $this->expectException(UniqueConstraintViolationException::class);
+ $this->handler->handle($this->makeDto(provider: null, placeId: null));
+ }
+
+ private function makeDto(?string $provider = 'google', ?string $placeId = 'place_1'): UpsertLocationDTO
+ {
+ return new UpsertLocationDTO(
+ organizer_id: 10,
+ account_id: 5,
+ name: 'The Venue',
+ structured_address: new AddressDTO(city: 'Dublin', country: 'IE'),
+ latitude: 53.3,
+ longitude: -6.2,
+ provider: $provider,
+ provider_place_id: $placeId,
+ );
+ }
+
+ private function stubSanitizer(): void
+ {
+ $this->sanitizer->shouldReceive('sanitizeText')->andReturnUsing(fn (?string $value) => $value);
+ $this->sanitizer->shouldReceive('sanitizeAddress')->andReturnUsing(fn (array $address) => $address);
+ }
+
+ private function makeUniqueViolation(): UniqueConstraintViolationException
+ {
+ return new UniqueConstraintViolationException(
+ 'pgsql',
+ 'insert into "locations" ...',
+ [],
+ new Exception('duplicate key value violates unique constraint "locations_provider_place_unique"'),
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Location/DeleteLocationHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Location/DeleteLocationHandlerTest.php
new file mode 100644
index 0000000000..f6b7b4a4c1
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/Location/DeleteLocationHandlerTest.php
@@ -0,0 +1,97 @@
+locationRepository = Mockery::mock(LocationRepositoryInterface::class);
+ $this->locationLockService = Mockery::mock(LocationLockService::class);
+ $databaseManager = Mockery::mock(DatabaseManager::class);
+ $databaseManager->shouldReceive('transaction')->andReturnUsing(fn ($callback) => $callback());
+
+ $this->handler = new DeleteLocationHandler(
+ $this->locationRepository,
+ $databaseManager,
+ $this->locationLockService,
+ );
+ }
+
+ public function test_deletes_unreferenced_location(): void
+ {
+ $this->expectLockAcquired();
+ $this->expectLocationLookup(Mockery::mock(LocationDomainObject::class));
+ $this->locationRepository->shouldReceive('isReferenced')->with(3)->andReturn(false);
+ $this->locationRepository
+ ->shouldReceive('deleteWhere')
+ ->once()
+ ->with([LocationDomainObjectAbstract::ID => 3]);
+
+ $this->handler->handle(10, 5, 3);
+ }
+
+ public function test_throws_when_location_not_found(): void
+ {
+ $this->expectLockAcquired();
+ $this->expectLocationLookup(null);
+
+ $this->expectException(ResourceNotFoundException::class);
+ $this->handler->handle(10, 5, 3);
+ }
+
+ public function test_throws_conflict_when_location_is_referenced(): void
+ {
+ $this->expectLockAcquired();
+ $this->expectLocationLookup(Mockery::mock(LocationDomainObject::class));
+ $this->locationRepository->shouldReceive('isReferenced')->with(3)->andReturn(true);
+ $this->locationRepository->shouldNotReceive('deleteWhere');
+
+ $this->expectException(ResourceConflictException::class);
+ $this->handler->handle(10, 5, 3);
+ }
+
+ private function expectLockAcquired(): void
+ {
+ $this->locationLockService
+ ->shouldReceive('acquireExclusiveTransactionLock')
+ ->once()
+ ->with(3);
+ }
+
+ private function expectLocationLookup(?LocationDomainObject $location): void
+ {
+ $this->locationRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ LocationDomainObjectAbstract::ID => 3,
+ LocationDomainObjectAbstract::ORGANIZER_ID => 10,
+ LocationDomainObjectAbstract::ACCOUNT_ID => 5,
+ ])
+ ->andReturn($location);
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Location/GetLocationsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Location/GetLocationsHandlerTest.php
new file mode 100644
index 0000000000..992dd1b1c6
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/Location/GetLocationsHandlerTest.php
@@ -0,0 +1,36 @@
+shouldReceive('findByOrganizerId')
+ ->once()
+ ->with(10, 5, $params)
+ ->andReturn($paginator);
+
+ $handler = new GetLocationsHandler($locationRepository);
+
+ $this->assertSame($paginator, $handler->handle(10, 5, $params));
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Location/UpdateLocationHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Location/UpdateLocationHandlerTest.php
new file mode 100644
index 0000000000..73fd9ea60a
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/Location/UpdateLocationHandlerTest.php
@@ -0,0 +1,225 @@
+locationRepository = Mockery::mock(LocationRepositoryInterface::class);
+ $this->sanitizer = Mockery::mock(LocationDataSanitizer::class);
+ $databaseManager = Mockery::mock(DatabaseManager::class);
+ $databaseManager->shouldReceive('transaction')->andReturnUsing(fn ($callback) => $callback());
+
+ $this->handler = new UpdateLocationHandler(
+ $this->locationRepository,
+ $this->sanitizer,
+ $databaseManager,
+ );
+ }
+
+ public function test_throws_when_location_not_found_for_organizer_and_account(): void
+ {
+ $this->locationRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ LocationDomainObjectAbstract::ID => 1,
+ LocationDomainObjectAbstract::ORGANIZER_ID => 10,
+ LocationDomainObjectAbstract::ACCOUNT_ID => 5,
+ ])
+ ->andReturn(null);
+
+ $this->expectException(ResourceNotFoundException::class);
+ $this->handler->handle(1, $this->makeDto());
+ }
+
+ public function test_throws_conflict_when_place_is_used_by_another_location(): void
+ {
+ $this->expectLocationLookup($this->makeLocation(id: 1));
+ $this->expectConflictLookup('place_1', $this->makeLocation(id: 2));
+
+ $this->locationRepository->shouldNotReceive('updateFromArray');
+
+ $this->expectException(ResourceConflictException::class);
+ $this->handler->handle(1, $this->makeDto());
+ }
+
+ public function test_updates_with_cached_raw_response(): void
+ {
+ $location = $this->makeLocation(id: 1);
+ $this->expectLocationLookup($location);
+ $this->expectConflictLookup('place_1', $location);
+ $this->stubSanitizer();
+ $this->sanitizer
+ ->shouldReceive('cachedRawProviderResponse')
+ ->with('google', 'place_1')
+ ->andReturn(['fresh' => true]);
+
+ $capturedAttributes = $this->expectUpdate();
+
+ $this->handler->handle(1, $this->makeDto());
+ $this->assertSame(['fresh' => true], $capturedAttributes()[LocationDomainObjectAbstract::RAW_PROVIDER_RESPONSE]);
+ }
+
+ public function test_preserves_existing_raw_response_when_place_unchanged_and_cache_empty(): void
+ {
+ $location = $this->makeLocation(id: 1, raw: ['seed' => true]);
+ $this->expectLocationLookup($location);
+ $this->expectConflictLookup('place_1', $location);
+ $this->stubSanitizer();
+ $this->sanitizer->shouldReceive('cachedRawProviderResponse')->andReturn(null);
+
+ $capturedAttributes = $this->expectUpdate();
+
+ $this->handler->handle(1, $this->makeDto());
+ $this->assertSame(['seed' => true], $capturedAttributes()[LocationDomainObjectAbstract::RAW_PROVIDER_RESPONSE]);
+ }
+
+ public function test_clears_raw_response_when_place_changes_and_cache_empty(): void
+ {
+ $location = $this->makeLocation(id: 1, placeId: 'place_1');
+ $this->expectLocationLookup($location);
+ $this->expectConflictLookup('place_2', null);
+ $this->stubSanitizer();
+ $this->sanitizer->shouldReceive('cachedRawProviderResponse')->andReturn(null);
+
+ $capturedAttributes = $this->expectUpdate();
+
+ $this->handler->handle(1, $this->makeDto(placeId: 'place_2'));
+ $this->assertNull($capturedAttributes()[LocationDomainObjectAbstract::RAW_PROVIDER_RESPONSE]);
+ }
+
+ public function test_wraps_unique_violation_in_conflict_exception(): void
+ {
+ $location = $this->makeLocation(id: 1);
+ $this->expectLocationLookup($location);
+ $this->expectConflictLookup('place_1', $location);
+ $this->stubSanitizer();
+ $this->sanitizer->shouldReceive('cachedRawProviderResponse')->andReturn(null);
+
+ $this->locationRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->andThrow(new UniqueConstraintViolationException(
+ 'pgsql',
+ 'update "locations" ...',
+ [],
+ new Exception('duplicate key value violates unique constraint "locations_provider_place_unique"'),
+ ));
+
+ $this->expectException(ResourceConflictException::class);
+ $this->handler->handle(1, $this->makeDto());
+ }
+
+ private function makeDto(?string $provider = 'google', ?string $placeId = 'place_1'): UpsertLocationDTO
+ {
+ return new UpsertLocationDTO(
+ organizer_id: 10,
+ account_id: 5,
+ name: 'The Venue',
+ structured_address: new AddressDTO(city: 'Dublin', country: 'IE'),
+ latitude: 53.3,
+ longitude: -6.2,
+ provider: $provider,
+ provider_place_id: $placeId,
+ );
+ }
+
+ private function makeLocation(
+ int $id,
+ ?string $provider = 'google',
+ ?string $placeId = 'place_1',
+ ?array $raw = null,
+ ): LocationDomainObject|MockInterface {
+ $location = Mockery::mock(LocationDomainObject::class);
+ $location->shouldReceive('getId')->andReturn($id);
+ $location->shouldReceive('getProvider')->andReturn($provider);
+ $location->shouldReceive('getProviderPlaceId')->andReturn($placeId);
+ $location->shouldReceive('getRawProviderResponse')->andReturn($raw);
+
+ return $location;
+ }
+
+ private function expectLocationLookup(LocationDomainObject $location): void
+ {
+ $this->locationRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ LocationDomainObjectAbstract::ID => 1,
+ LocationDomainObjectAbstract::ORGANIZER_ID => 10,
+ LocationDomainObjectAbstract::ACCOUNT_ID => 5,
+ ])
+ ->andReturn($location);
+ }
+
+ private function expectConflictLookup(string $placeId, ?LocationDomainObject $result): void
+ {
+ $this->locationRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([
+ LocationDomainObjectAbstract::ORGANIZER_ID => 10,
+ LocationDomainObjectAbstract::ACCOUNT_ID => 5,
+ LocationDomainObjectAbstract::PROVIDER => 'google',
+ LocationDomainObjectAbstract::PROVIDER_PLACE_ID => $placeId,
+ ])
+ ->andReturn($result);
+ }
+
+ private function expectUpdate(): callable
+ {
+ $captured = null;
+ $updated = Mockery::mock(LocationDomainObject::class);
+ $this->locationRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(1, Mockery::on(function (array $attributes) use (&$captured) {
+ $captured = $attributes;
+
+ return true;
+ }))
+ ->andReturn($updated);
+
+ return static function () use (&$captured) {
+ return $captured;
+ };
+ }
+
+ private function stubSanitizer(): void
+ {
+ $this->sanitizer->shouldReceive('sanitizeText')->andReturnUsing(fn (?string $value) => $value);
+ $this->sanitizer->shouldReceive('sanitizeAddress')->andReturnUsing(fn (array $address) => $address);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Message/GetMessageRecipientsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Message/GetMessageRecipientsHandlerTest.php
index fdb3c532e1..2261e90aeb 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Message/GetMessageRecipientsHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Message/GetMessageRecipientsHandlerTest.php
@@ -15,7 +15,9 @@
class GetMessageRecipientsHandlerTest extends TestCase
{
private OutgoingMessageRepositoryInterface $outgoingMessageRepository;
+
private MessageRepositoryInterface $messageRepository;
+
private GetMessageRecipientsHandler $handler;
protected function setUp(): void
@@ -30,7 +32,7 @@ protected function setUp(): void
);
}
- public function testHandleReturnsPaginatedRecipients(): void
+ public function test_handle_returns_paginated_recipients(): void
{
$eventId = 10;
$messageId = 20;
@@ -55,7 +57,7 @@ public function testHandleReturnsPaginatedRecipients(): void
$this->assertSame($paginator, $result);
}
- public function testHandleUsesDefaultPerPageFromDto(): void
+ public function test_handle_uses_default_per_page_from_dto(): void
{
$eventId = 5;
$messageId = 15;
@@ -80,7 +82,7 @@ public function testHandleUsesDefaultPerPageFromDto(): void
$this->assertSame($paginator, $result);
}
- public function testHandleThrowsNotFoundWhenMessageDoesNotExist(): void
+ public function test_handle_throws_not_found_when_message_does_not_exist(): void
{
$this->expectException(ResourceNotFoundException::class);
@@ -96,7 +98,7 @@ public function testHandleThrowsNotFoundWhenMessageDoesNotExist(): void
$this->handler->handle(1, 999, $params);
}
- public function testHandleThrowsNotFoundWhenMessageBelongsToDifferentEvent(): void
+ public function test_handle_throws_not_found_when_message_belongs_to_different_event(): void
{
$this->expectException(ResourceNotFoundException::class);
diff --git a/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerScheduledTest.php b/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerScheduledTest.php
index 0b4c2010d9..d58095ecd7 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerScheduledTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerScheduledTest.php
@@ -30,14 +30,23 @@
class SendMessageHandlerScheduledTest extends TestCase
{
private OrderRepositoryInterface $orderRepository;
+
private AttendeeRepositoryInterface $attendeeRepository;
+
private ProductRepositoryInterface $productRepository;
+
private MessageRepositoryInterface $messageRepository;
+
private AccountRepositoryInterface $accountRepository;
+
private EventRepositoryInterface $eventRepository;
+
private HtmlPurifierService $purifier;
+
private Repository $config;
+
private MessagingEligibilityService $eligibilityService;
+
private SendMessageHandler $handler;
protected function setUp(): void
@@ -88,13 +97,13 @@ private function setupAccountMocks(): void
private function setupRepositoryMocks(): void
{
- $attendee = new AttendeeDomainObject();
+ $attendee = new AttendeeDomainObject;
$attendee->setId(10);
- $product = new ProductDomainObject();
+ $product = new ProductDomainObject;
$product->setId(20);
- $order = new OrderDomainObject();
+ $order = new OrderDomainObject;
$order->setId(5);
$this->attendeeRepository->shouldReceive('findWhereIn')->andReturn(collect([$attendee]));
@@ -102,7 +111,7 @@ private function setupRepositoryMocks(): void
$this->orderRepository->shouldReceive('findFirstWhere')->andReturn($order);
}
- public function testFutureScheduledAtSetsScheduledStatusAndDoesNotDispatchJob(): void
+ public function test_future_scheduled_at_sets_scheduled_status_and_does_not_dispatch_job(): void
{
Bus::fake();
@@ -147,7 +156,7 @@ public function testFutureScheduledAtSetsScheduledStatusAndDoesNotDispatchJob():
Bus::assertNotDispatched(SendMessagesJob::class);
}
- public function testNoScheduledAtDispatchesJobImmediately(): void
+ public function test_no_scheduled_at_dispatches_job_immediately(): void
{
Bus::fake();
@@ -190,7 +199,7 @@ public function testNoScheduledAtDispatchesJobImmediately(): void
Bus::assertDispatched(SendMessagesJob::class);
}
- public function testIsTestWithScheduledAtSendsImmediately(): void
+ public function test_is_test_with_scheduled_at_sends_immediately(): void
{
Bus::fake();
diff --git a/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerTest.php
index 4f9cccdedf..16e9dfd65c 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Message/SendMessageHandlerTest.php
@@ -30,13 +30,21 @@
class SendMessageHandlerTest extends TestCase
{
private OrderRepositoryInterface $orderRepository;
+
private AttendeeRepositoryInterface $attendeeRepository;
+
private ProductRepositoryInterface $productRepository;
+
private MessageRepositoryInterface $messageRepository;
+
private AccountRepositoryInterface $accountRepository;
+
private HtmlPurifierService $purifier;
+
private Repository $config;
+
private MessagingEligibilityService $eligibilityService;
+
private EventRepositoryInterface $eventRepository;
private SendMessageHandler $handler;
@@ -68,7 +76,7 @@ protected function setUp(): void
);
}
- public function testThrowsIfAccountNotVerified(): void
+ public function test_throws_if_account_not_verified(): void
{
$dto = new SendMessageDTO(
account_id: 1,
@@ -95,7 +103,7 @@ public function testThrowsIfAccountNotVerified(): void
$this->handler->handle($dto);
}
- public function testThrowsIfSaasModeEnabledAndNotManuallyVerified(): void
+ public function test_throws_if_saas_mode_enabled_and_not_manually_verified(): void
{
$dto = new SendMessageDTO(
account_id: 1,
@@ -125,7 +133,7 @@ public function testThrowsIfSaasModeEnabledAndNotManuallyVerified(): void
$this->handler->handle($dto);
}
- public function testHandleCreatesMessageAndDispatchesJob(): void
+ public function test_handle_creates_message_and_dispatches_job(): void
{
$dto = new SendMessageDTO(
account_id: 1,
@@ -153,19 +161,18 @@ public function testHandleCreatesMessageAndDispatchesJob(): void
$this->accountRepository->shouldReceive('findById')->with(1)->andReturn($account);
$this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturn(false);
- // Mock eligibility checks to pass (return null = no violations)
$this->eligibilityService->shouldReceive('checkTierLimits')->andReturn(null);
$this->eligibilityService->shouldReceive('checkEligibility')->andReturn(null);
$this->purifier->shouldReceive('purify')->with('Test
')->andReturn('Test
');
- $attendee = new AttendeeDomainObject();
+ $attendee = new AttendeeDomainObject;
$attendee->setId(10);
- $product = new ProductDomainObject();
+ $product = new ProductDomainObject;
$product->setId(20);
- $order = new OrderDomainObject();
+ $order = new OrderDomainObject;
$order->setId(5);
$this->attendeeRepository->shouldReceive('findWhereIn')->andReturn(collect([$attendee]));
@@ -189,4 +196,68 @@ public function testHandleCreatesMessageAndDispatchesJob(): void
Bus::assertDispatched(SendMessagesJob::class);
}
+
+ public function test_handle_estimates_recipients_for_multi_occurrence_targeting(): void
+ {
+ $dto = new SendMessageDTO(
+ account_id: 1,
+ event_id: 101,
+ subject: 'Hi',
+ message: 'Body
',
+ type: MessageTypeEnum::ALL_ATTENDEES,
+ is_test: false,
+ send_copy_to_current_user: false,
+ sent_by_user_id: 99,
+ event_occurrence_ids: [201, 202, 203],
+ );
+
+ $event = m::mock(EventDomainObject::class);
+ $event->shouldReceive('getTimezone')->andReturn('UTC');
+ $this->eventRepository->shouldReceive('findById')->with(101)->andReturn($event);
+
+ $account = m::mock(AccountDomainObject::class);
+ $account->shouldReceive('getAccountVerifiedAt')->andReturn(Carbon::now());
+ $account->shouldReceive('getIsManuallyVerified')->andReturn(true);
+ $this->accountRepository->shouldReceive('findById')->with(1)->andReturn($account);
+
+ $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturn(false);
+ $this->eligibilityService->shouldReceive('checkTierLimits')->andReturn(null);
+ $this->eligibilityService->shouldReceive('checkEligibility')->andReturn(null);
+ $this->purifier->shouldReceive('purify')->andReturn('Body
');
+
+ $this->attendeeRepository
+ ->shouldReceive('countWhere')
+ ->once()
+ ->with(m::on(fn (array $where) => isset($where[0])
+ && $where[0][0] === 'event_occurrence_id'
+ && $where[0][1] === 'in'
+ && $where[0][2] === [201, 202, 203]))
+ ->andReturn(42);
+
+ $this->attendeeRepository->shouldReceive('findWhereIn')->andReturn(collect());
+ $this->productRepository->shouldReceive('findWhereIn')->andReturn(collect());
+ $this->orderRepository->shouldReceive('findFirstWhere')->andReturn(null);
+
+ $message = m::mock(MessageDomainObject::class);
+ $message->shouldReceive('getId')->andReturn(1);
+ $message->shouldReceive('getOrderId')->andReturn(null);
+ $message->shouldReceive('getAttendeeIds')->andReturn([]);
+ $message->shouldReceive('getProductIds')->andReturn([]);
+ $this->messageRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with(m::on(function (array $attrs) {
+ return $attrs['event_occurrence_id'] === null
+ && ($attrs['send_data']['event_occurrence_ids'] ?? null) === [201, 202, 203];
+ }))
+ ->andReturn($message);
+
+ Bus::fake();
+ $this->handler->handle($dto);
+
+ Bus::assertDispatched(SendMessagesJob::class, function (SendMessagesJob $job) {
+ return $job->messageData->event_occurrence_ids === [201, 202, 203]
+ && $job->messageData->event_occurrence_id === null;
+ });
+ }
}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php
index 4d820ff70c..8633f135cf 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Order/CompleteOrderHandlerTest.php
@@ -5,14 +5,17 @@
use Carbon\Carbon;
use Exception;
use HiEvents\DomainObjects\AttendeeDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
+use HiEvents\DomainObjects\Status\EventOccurrenceStatus;
use HiEvents\DomainObjects\Status\OrderStatus;
use HiEvents\Exceptions\ResourceConflictException;
use HiEvents\Repository\Interfaces\AffiliateRepositoryInterface;
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
+use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\EventSettingsRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface;
@@ -21,6 +24,7 @@
use HiEvents\Services\Application\Handlers\Order\DTO\CompleteOrderDTO;
use HiEvents\Services\Application\Handlers\Order\DTO\CompleteOrderOrderDTO;
use HiEvents\Services\Application\Handlers\Order\DTO\CompleteOrderProductDataDTO;
+use HiEvents\Services\Domain\Order\OccurrenceStatusValidator;
use HiEvents\Services\Domain\Product\ProductQuantityUpdateService;
use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService;
use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType;
@@ -41,23 +45,34 @@
class CompleteOrderHandlerTest extends TestCase
{
private OrderRepositoryInterface|MockInterface $orderRepository;
+
private AttendeeRepositoryInterface|MockInterface $attendeeRepository;
+
private QuestionAnswerRepositoryInterface|MockInterface $questionAnswersRepository;
+
private ProductQuantityUpdateService|MockInterface $productQuantityUpdateService;
+
private ProductPriceRepositoryInterface|MockInterface $productPriceRepository;
+
private CompleteOrderHandler $completeOrderHandler;
+
private DomainEventDispatcherService $domainEventDispatcherService;
+
private AffiliateRepositoryInterface|MockInterface $affiliateRepository;
+
private EventSettingsRepositoryInterface $eventSettingsRepository;
+
private CheckoutSessionManagementService|MockInterface $sessionManagementService;
+ private EventOccurrenceRepositoryInterface|MockInterface $occurrenceRepository;
+
protected function setUp(): void
{
parent::setUp();
Queue::fake();
Mail::fake();
Bus::fake();
- DB::shouldReceive('transaction')->andReturnUsing(fn($callback) => $callback(Mockery::mock(Connection::class)));
+ DB::shouldReceive('transaction')->andReturnUsing(fn ($callback) => $callback(Mockery::mock(Connection::class)));
$this->orderRepository = Mockery::mock(OrderRepositoryInterface::class);
$this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class);
@@ -69,6 +84,15 @@ protected function setUp(): void
$this->eventSettingsRepository = Mockery::mock(EventSettingsRepositoryInterface::class);
$this->sessionManagementService = Mockery::mock(CheckoutSessionManagementService::class);
$this->sessionManagementService->shouldReceive('verifySession')->andReturn(true)->byDefault();
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->occurrenceRepository->shouldReceive('findWhereIn')->andReturn(
+ collect([
+ (new EventOccurrenceDomainObject)
+ ->setId(1)
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name)
+ ->setStartDate(Carbon::now()->addDay()->toDateTimeString()),
+ ])
+ )->byDefault();
$this->completeOrderHandler = new CompleteOrderHandler(
$this->orderRepository,
@@ -80,6 +104,7 @@ protected function setUp(): void
$this->domainEventDispatcherService,
$this->eventSettingsRepository,
$this->sessionManagementService,
+ new OccurrenceStatusValidator($this->occurrenceRepository),
);
}
@@ -89,7 +114,7 @@ protected function tearDown(): void
parent::tearDown();
}
- public function testHandleSuccessfullyCompletesOrder(): void
+ public function test_handle_successfully_completes_order(): void
{
$orderShortId = 'ABC123';
$orderData = $this->createMockCompleteOrderDTO();
@@ -115,7 +140,7 @@ public function testHandleSuccessfullyCompletesOrder(): void
$this->assertTrue(true);
}
- public function testHandleThrowsResourceNotFoundExceptionWhenOrderNotFound(): void
+ public function test_handle_throws_resource_not_found_exception_when_order_not_found(): void
{
$this->expectException(ResourceNotFoundException::class);
@@ -129,7 +154,7 @@ public function testHandleThrowsResourceNotFoundExceptionWhenOrderNotFound(): vo
$this->completeOrderHandler->handle($orderShortId, $orderData);
}
- public function testHandleThrowsResourceConflictExceptionWhenOrderAlreadyProcessed(): void
+ public function test_handle_throws_resource_conflict_exception_when_order_already_processed(): void
{
$this->expectException(ResourceConflictException::class);
$this->expectExceptionMessage('This order has already been processed');
@@ -148,7 +173,7 @@ public function testHandleThrowsResourceConflictExceptionWhenOrderAlreadyProcess
$this->completeOrderHandler->handle($orderShortId, $orderData);
}
- public function testHandleThrowsResourceConflictExceptionWhenOrderExpired(): void
+ public function test_handle_throws_resource_conflict_exception_when_order_expired(): void
{
$this->expectException(ResourceConflictException::class);
@@ -166,7 +191,7 @@ public function testHandleThrowsResourceConflictExceptionWhenOrderExpired(): voi
$this->completeOrderHandler->handle($orderShortId, $orderData);
}
- public function testHandleUpdatesProductQuantitiesForFreeOrder(): void
+ public function test_handle_updates_product_quantities_for_free_order(): void
{
Event::fake();
@@ -201,7 +226,7 @@ public function testHandleUpdatesProductQuantitiesForFreeOrder(): void
$this->assertSame($order->getStatus(), OrderStatus::COMPLETED->name);
}
- public function testHandleDoesNotUpdateProductQuantitiesForPaidOrder(): void
+ public function test_handle_does_not_update_product_quantities_for_paid_order(): void
{
$orderShortId = 'ABC123';
$orderData = $this->createMockCompleteOrderDTO();
@@ -228,7 +253,7 @@ public function testHandleDoesNotUpdateProductQuantitiesForPaidOrder(): void
$this->expectNotToPerformAssertions();
}
- public function testHandleThrowsExceptionWhenAttendeeInsertFails(): void
+ public function test_handle_throws_exception_when_attendee_insert_fails(): void
{
$this->expectException(Exception::class);
@@ -249,7 +274,7 @@ public function testHandleThrowsExceptionWhenAttendeeInsertFails(): void
$this->completeOrderHandler->handle($orderShortId, $orderData);
}
- public function testExceptionIsThrowWhenAttendeeCountDoesNotMatchOrderItemsCount(): void
+ public function test_exception_is_throw_when_attendee_count_does_not_match_order_items_count(): void
{
$this->expectException(ResourceConflictException::class);
$this->expectExceptionMessage('The number of attendees does not match the number of tickets in the order');
@@ -269,7 +294,52 @@ public function testExceptionIsThrowWhenAttendeeCountDoesNotMatchOrderItemsCount
$this->productPriceRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$this->createMockProductPrice()]));
$this->attendeeRepository->shouldReceive('insert')->andReturn(true);
- $this->attendeeRepository->shouldReceive('findWhere')->andReturn(new Collection());
+ $this->attendeeRepository->shouldReceive('findWhere')->andReturn(new Collection);
+
+ $this->completeOrderHandler->handle($orderShortId, $orderData);
+ }
+
+ public function test_handle_throws_resource_conflict_exception_when_occurrence_is_cancelled(): void
+ {
+ $this->expectException(ResourceConflictException::class);
+ $this->expectExceptionMessage('This event date has been cancelled');
+
+ $orderShortId = 'ABC123';
+ $orderData = $this->createMockCompleteOrderDTO();
+ $order = $this->createMockOrder();
+
+ $this->eventSettingsRepository->shouldReceive('findFirstWhere')->andReturn($this->createMockEventSetting());
+ $this->orderRepository->shouldReceive('findByShortId')->with($orderShortId)->andReturn($order);
+ $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf();
+
+ $this->occurrenceRepository->shouldReceive('findWhereIn')->andReturn(
+ collect([(new EventOccurrenceDomainObject)->setId(1)->setStatus(EventOccurrenceStatus::CANCELLED->name)])
+ );
+
+ $this->completeOrderHandler->handle($orderShortId, $orderData);
+ }
+
+ public function test_handle_throws_resource_conflict_exception_when_occurrence_has_ended(): void
+ {
+ $this->expectException(ResourceConflictException::class);
+ $this->expectExceptionMessage('This event date has already ended');
+
+ $orderShortId = 'ABC123';
+ $orderData = $this->createMockCompleteOrderDTO();
+ $order = $this->createMockOrder();
+
+ $this->eventSettingsRepository->shouldReceive('findFirstWhere')->andReturn($this->createMockEventSetting());
+ $this->orderRepository->shouldReceive('findByShortId')->with($orderShortId)->andReturn($order);
+ $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf();
+
+ $this->occurrenceRepository->shouldReceive('findWhereIn')->andReturn(
+ collect([
+ (new EventOccurrenceDomainObject)
+ ->setId(1)
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name)
+ ->setStartDate(Carbon::now()->subDay()->toDateTimeString()),
+ ])
+ );
$this->completeOrderHandler->handle($orderShortId, $orderData);
}
@@ -292,14 +362,13 @@ private function createMockCompleteOrderDTO(): CompleteOrderDTO
return new CompleteOrderDTO(
order: $orderDTO,
- products: new Collection([$attendeeDTO])
- , event_id: 1
+ products: new Collection([$attendeeDTO]), event_id: 1
);
}
private function createMockOrder(OrderStatus $status = OrderStatus::RESERVED): OrderDomainObject|MockInterface
{
- return (new OrderDomainObject())
+ return (new OrderDomainObject)
->setEmail(null)
->setSessionId('test-session-id')
->setReservedUntil(Carbon::now()->addHour()->toDateTimeString())
@@ -309,19 +378,20 @@ private function createMockOrder(OrderStatus $status = OrderStatus::RESERVED): O
->setLocale('en')
->setTotalGross(10)
->setOrderItems(new Collection([
- $this->createMockOrderItem()
+ $this->createMockOrderItem(),
]));
}
private function createMockOrderItem(): OrderItemDomainObject|MockInterface
{
- return (new OrderItemDomainObject())
+ return (new OrderItemDomainObject)
->setId(1)
->setProductId(1)
->setQuantity(1)
->setPrice(10)
->setTotalGross(10)
- ->setProductPriceId(1);
+ ->setProductPriceId(1)
+ ->setEventOccurrenceId(1);
}
private function createMockProductPrice(): ProductPriceDomainObject|MockInterface
@@ -329,6 +399,7 @@ private function createMockProductPrice(): ProductPriceDomainObject|MockInterfac
$productPrice = Mockery::mock(ProductPriceDomainObject::class);
$productPrice->shouldReceive('getId')->andReturn(1);
$productPrice->shouldReceive('getProductId')->andReturn(1);
+
return $productPrice;
}
@@ -337,12 +408,13 @@ private function createMockAttendee(): AttendeeDomainObject|MockInterface
$attendee = Mockery::mock(AttendeeDomainObject::class);
$attendee->shouldReceive('getId')->andReturn(1);
$attendee->shouldReceive('getProductId')->andReturn(1);
+
return $attendee;
}
private function createMockEventSetting(): EventSettingDomainObject
{
- return (new EventSettingDomainObject())
+ return (new EventSettingDomainObject)
->setId(1)
->setEventId(1);
}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerPromoCodeTest.php b/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerPromoCodeTest.php
index c63e572160..454297fe66 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerPromoCodeTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerPromoCodeTest.php
@@ -11,6 +11,7 @@
use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface;
use HiEvents\Services\Application\Handlers\Order\CreateOrderHandler;
use HiEvents\Services\Application\Handlers\Order\DTO\CreateOrderPublicDTO;
+use HiEvents\Services\Domain\EventOccurrence\OccurrencePurchaseEligibilityService;
use HiEvents\Services\Domain\Order\OrderItemProcessingService;
use HiEvents\Services\Domain\Order\OrderManagementService;
use HiEvents\Services\Domain\Product\AvailableProductQuantitiesFetchService;
@@ -26,16 +27,17 @@ class CreateOrderHandlerPromoCodeTest extends TestCase
use MockeryPHPUnitIntegration;
private const EVENT_ID = 1;
+
private const PROMO_CODE_ID = 5;
- public function testPromoCodeIsDroppedWhenNotUsable(): void
+ public function test_promo_code_is_dropped_when_not_usable(): void
{
$captured = $this->runHandler(isUsable: false);
$this->assertNull($captured);
}
- public function testPromoCodeIsAppliedWhenUsable(): void
+ public function test_promo_code_is_applied_when_usable(): void
{
$captured = $this->runHandler(isUsable: true);
@@ -45,7 +47,7 @@ public function testPromoCodeIsAppliedWhenUsable(): void
private function runHandler(bool $isUsable): mixed
{
- $promoCode = (new PromoCodeDomainObject())
+ $promoCode = (new PromoCodeDomainObject)
->setId(self::PROMO_CODE_ID)
->setCode('save50');
@@ -77,6 +79,7 @@ private function runHandler(bool $isUsable): mixed
->shouldReceive('createNewOrder')
->andReturnUsing(function ($eventId, $event, $timeOut, $locale, $promo, $affiliate, $sessionId) use (&$captured, $order) {
$captured = $promo;
+
return $order;
});
$orderManagementService->shouldReceive('updateOrderTotals')->andReturn($order);
@@ -93,7 +96,9 @@ private function runHandler(bool $isUsable): mixed
$databaseManager = Mockery::mock(DatabaseManager::class);
$databaseManager->shouldReceive('statement')->andReturn(true);
- $databaseManager->shouldReceive('transaction')->andReturnUsing(fn($callback) => $callback());
+ $databaseManager->shouldReceive('transaction')->andReturnUsing(fn ($callback) => $callback());
+
+ $occurrenceEligibilityService = Mockery::mock(OccurrencePurchaseEligibilityService::class);
$handler = new CreateOrderHandler(
$eventRepository,
@@ -103,6 +108,7 @@ private function runHandler(bool $isUsable): mixed
$orderManagementService,
$orderItemProcessingService,
$availabilityService,
+ $occurrenceEligibilityService,
$databaseManager,
);
diff --git a/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerTest.php
index 8bc15306ca..f09ba297a3 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Order/CreateOrderHandlerTest.php
@@ -2,7 +2,9 @@
namespace Tests\Unit\Services\Application\Handlers\Order;
+use HiEvents\DomainObjects\Enums\ProductType;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
@@ -10,16 +12,17 @@
use HiEvents\Repository\Interfaces\AffiliateRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface;
-use HiEvents\Services\Domain\PromoCode\PromoCodeUsageValidationService;
use HiEvents\Services\Application\Handlers\Order\CreateOrderHandler;
use HiEvents\Services\Application\Handlers\Order\DTO\CreateOrderPublicDTO;
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;
use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesDTO;
use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesResponseDTO;
use HiEvents\Services\Domain\Product\DTO\OrderProductPriceDTO;
+use HiEvents\Services\Domain\PromoCode\PromoCodeUsageValidationService;
use Illuminate\Database\DatabaseManager;
use Illuminate\Validation\ValidationException;
use Mockery;
@@ -29,13 +32,23 @@
class CreateOrderHandlerTest extends TestCase
{
private EventRepositoryInterface|MockInterface $eventRepository;
+
private PromoCodeRepositoryInterface|MockInterface $promoCodeRepository;
+
private PromoCodeUsageValidationService|MockInterface $promoCodeUsageValidationService;
+
private AffiliateRepositoryInterface|MockInterface $affiliateRepository;
+
private OrderManagementService|MockInterface $orderManagementService;
+
private OrderItemProcessingService|MockInterface $orderItemProcessingService;
+
private AvailableProductQuantitiesFetchService|MockInterface $availabilityService;
+
+ private OccurrencePurchaseEligibilityService|MockInterface $occurrenceEligibilityService;
+
private DatabaseManager|MockInterface $databaseManager;
+
private CreateOrderHandler $handler;
protected function setUp(): void
@@ -49,10 +62,15 @@ protected function setUp(): void
$this->orderManagementService = Mockery::mock(OrderManagementService::class);
$this->orderItemProcessingService = Mockery::mock(OrderItemProcessingService::class);
$this->availabilityService = Mockery::mock(AvailableProductQuantitiesFetchService::class);
+ $this->occurrenceEligibilityService = Mockery::mock(OccurrencePurchaseEligibilityService::class);
$this->databaseManager = Mockery::mock(DatabaseManager::class);
+ $this->occurrenceEligibilityService->shouldReceive('assertOccurrencePurchasable')
+ ->byDefault()
+ ->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+
$this->databaseManager->shouldReceive('transaction')
- ->andReturnUsing(fn($callback) => $callback());
+ ->andReturnUsing(fn ($callback) => $callback());
$this->handler = new CreateOrderHandler(
$this->eventRepository,
@@ -62,6 +80,7 @@ protected function setUp(): void
$this->orderManagementService,
$this->orderItemProcessingService,
$this->availabilityService,
+ $this->occurrenceEligibilityService,
$this->databaseManager,
);
}
@@ -72,7 +91,7 @@ protected function tearDown(): void
parent::tearDown();
}
- public function testAcquiresAdvisoryLockBeforeCreatingOrder(): void
+ public function test_acquires_advisory_lock_before_creating_order(): void
{
$eventId = 42;
@@ -87,7 +106,7 @@ public function testAcquiresAdvisoryLockBeforeCreatingOrder(): void
$this->assertInstanceOf(OrderDomainObject::class, $result);
}
- public function testThrowsWhenProductQuantityExceedsAvailability(): void
+ public function test_throws_when_product_quantity_exceeds_availability(): void
{
$eventId = 1;
@@ -97,18 +116,10 @@ public function testThrowsWhenProductQuantityExceedsAvailability(): void
$this->orderManagementService->shouldReceive('deleteExistingOrders');
$this->availabilityService->shouldReceive('getAvailableProductQuantities')
- ->with($eventId, true)
+ ->with($eventId, true, Mockery::any())
->andReturn(new AvailableProductQuantitiesResponseDTO(
productQuantities: collect([
- AvailableProductQuantitiesDTO::fromArray([
- 'product_id' => 10,
- 'price_id' => 100,
- 'product_title' => 'Test',
- 'price_label' => null,
- 'quantity_available' => 2,
- 'quantity_reserved' => 0,
- 'initial_quantity_available' => 10,
- ]),
+ $this->createAvailabilityDTO(10, 100, 2),
]),
));
@@ -118,7 +129,7 @@ public function testThrowsWhenProductQuantityExceedsAvailability(): void
$this->handler->handle($eventId, $dto);
}
- public function testPassesWhenQuantityIsWithinAvailability(): void
+ public function test_passes_when_quantity_is_within_availability(): void
{
$eventId = 1;
@@ -131,7 +142,7 @@ public function testPassesWhenQuantityIsWithinAvailability(): void
$this->assertInstanceOf(OrderDomainObject::class, $result);
}
- public function testSkipsZeroQuantityProducts(): void
+ public function test_skips_zero_quantity_products(): void
{
$eventId = 1;
@@ -144,6 +155,233 @@ public function testSkipsZeroQuantityProducts(): void
$this->assertInstanceOf(OrderDomainObject::class, $result);
}
+ public function test_aggregates_ticket_quantities_per_occurrence_using_preloaded_occurrence_data(): void
+ {
+ $eventId = 1;
+
+ $this->databaseManager->shouldReceive('statement')->andReturn(true);
+ $this->setupEventMock($eventId);
+ $this->orderManagementService->shouldReceive('deleteExistingOrders');
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->occurrenceEligibilityService->shouldReceive('assertOccurrencePurchasable')
+ ->once()
+ ->with($eventId, 1, 6, false, $occurrence, 4)
+ ->andReturn($occurrence);
+
+ $this->availabilityService->shouldReceive('getAvailableProductQuantities')
+ ->with($eventId, true, Mockery::any())
+ ->andReturn(new AvailableProductQuantitiesResponseDTO(
+ productQuantities: collect([
+ $this->createAvailabilityDTO(10, 100, 100),
+ $this->createAvailabilityDTO(11, 101, 100),
+ $this->createAvailabilityDTO(12, 102, 100, ProductType::GENERAL->name),
+ ]),
+ capacities: collect(),
+ occurrence: $occurrence,
+ occurrenceReservedQuantity: 4,
+ ));
+
+ $order = Mockery::mock(OrderDomainObject::class);
+ $this->orderManagementService->shouldReceive('createNewOrder')->andReturn($order);
+ $this->orderItemProcessingService->shouldReceive('process')->andReturn(collect([Mockery::mock(OrderItemDomainObject::class)]));
+ $this->orderManagementService->shouldReceive('updateOrderTotals')->andReturn($order);
+
+ $dto = $this->createMultiLineOrderDTO([
+ [10, 100, 3, 1],
+ [11, 101, 3, 1],
+ [12, 102, 5, 1],
+ ]);
+
+ $result = $this->handler->handle($eventId, $dto);
+ $this->assertInstanceOf(OrderDomainObject::class, $result);
+ }
+
+ public function test_rejects_when_aggregate_occurrence_capacity_exceeded(): void
+ {
+ $eventId = 1;
+
+ $this->databaseManager->shouldReceive('statement')->andReturn(true);
+ $this->setupEventMock($eventId);
+ $this->orderManagementService->shouldReceive('deleteExistingOrders');
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->availabilityService->shouldReceive('getAvailableProductQuantities')
+ ->with($eventId, true, Mockery::any())
+ ->andReturn(new AvailableProductQuantitiesResponseDTO(
+ productQuantities: collect([
+ $this->createAvailabilityDTO(10, 100, 100),
+ $this->createAvailabilityDTO(11, 101, 100),
+ ]),
+ capacities: collect(),
+ occurrence: $occurrence,
+ occurrenceReservedQuantity: 6,
+ ));
+
+ $this->occurrenceEligibilityService->shouldReceive('assertOccurrencePurchasable')
+ ->once()
+ ->with($eventId, 1, 6, false, $occurrence, 6)
+ ->andThrow(ValidationException::withMessages([
+ 'event_occurrence_id' => 'Not enough capacity available for this occurrence',
+ ]));
+
+ $this->orderManagementService->shouldNotReceive('createNewOrder');
+
+ $dto = $this->createMultiLineOrderDTO([
+ [10, 100, 3, 1],
+ [11, 101, 3, 1],
+ ]);
+
+ $this->expectException(ValidationException::class);
+ $this->handler->handle($eventId, $dto);
+ }
+
+ public function test_general_only_cart_asserts_occurrence_with_zero_additional_quantity(): void
+ {
+ $eventId = 1;
+
+ $this->databaseManager->shouldReceive('statement')->andReturn(true);
+ $this->setupEventMock($eventId);
+ $this->orderManagementService->shouldReceive('deleteExistingOrders');
+
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+
+ $this->availabilityService->shouldReceive('getAvailableProductQuantities')
+ ->with($eventId, true, Mockery::any())
+ ->andReturn(new AvailableProductQuantitiesResponseDTO(
+ productQuantities: collect([
+ $this->createAvailabilityDTO(12, 102, 100, ProductType::GENERAL->name),
+ ]),
+ capacities: collect(),
+ occurrence: $occurrence,
+ occurrenceReservedQuantity: null,
+ ));
+
+ $this->occurrenceEligibilityService->shouldReceive('assertOccurrencePurchasable')
+ ->once()
+ ->with($eventId, 1, 0, false, $occurrence, null)
+ ->andReturn($occurrence);
+
+ $order = Mockery::mock(OrderDomainObject::class);
+ $this->orderManagementService->shouldReceive('createNewOrder')->andReturn($order);
+ $this->orderItemProcessingService->shouldReceive('process')->andReturn(collect([Mockery::mock(OrderItemDomainObject::class)]));
+ $this->orderManagementService->shouldReceive('updateOrderTotals')->andReturn($order);
+
+ $dto = $this->createMultiLineOrderDTO([
+ [12, 102, 5, 1],
+ ]);
+
+ $result = $this->handler->handle($eventId, $dto);
+ $this->assertInstanceOf(OrderDomainObject::class, $result);
+ }
+
+ public function test_rejects_duplicate_product_price_lines_exceeding_availability(): void
+ {
+ $eventId = 1;
+
+ $this->databaseManager->shouldReceive('statement')->andReturn(true);
+ $this->setupEventMock($eventId);
+ $this->orderManagementService->shouldReceive('deleteExistingOrders');
+
+ $this->availabilityService->shouldReceive('getAvailableProductQuantities')
+ ->andReturn(new AvailableProductQuantitiesResponseDTO(
+ productQuantities: collect([
+ $this->createAvailabilityDTO(10, 100, 4),
+ ]),
+ ));
+
+ $this->orderManagementService->shouldNotReceive('createNewOrder');
+
+ $dto = $this->createMultiLineOrderDTO([
+ [10, 100, 3, 1],
+ [10, 100, 3, 1],
+ ]);
+
+ $this->expectException(ValidationException::class);
+ $this->handler->handle($eventId, $dto);
+ }
+
+ public function test_passes_when_duplicate_product_price_lines_fit_availability(): void
+ {
+ $eventId = 1;
+
+ $this->databaseManager->shouldReceive('statement')->andReturn(true);
+ $this->setupSuccessfulOrderCreation($eventId, productId: 10, priceId: 100, available: 6);
+
+ $dto = $this->createMultiLineOrderDTO([
+ [10, 100, 3, 1],
+ [10, 100, 3, 1],
+ ]);
+
+ $result = $this->handler->handle($eventId, $dto);
+ $this->assertInstanceOf(OrderDomainObject::class, $result);
+ }
+
+ public function test_rejects_same_price_across_occurrences_exceeding_total_availability(): void
+ {
+ $eventId = 1;
+
+ $this->databaseManager->shouldReceive('statement')->andReturn(true);
+ $this->setupEventMock($eventId);
+ $this->orderManagementService->shouldReceive('deleteExistingOrders');
+
+ $this->availabilityService->shouldReceive('getAvailableProductQuantities')
+ ->andReturn(new AvailableProductQuantitiesResponseDTO(
+ productQuantities: collect([
+ $this->createAvailabilityDTO(10, 100, 4),
+ ]),
+ ));
+
+ $this->orderManagementService->shouldNotReceive('createNewOrder');
+
+ $dto = $this->createMultiLineOrderDTO([
+ [10, 100, 3, 1],
+ [10, 100, 3, 2],
+ ]);
+
+ $this->expectException(ValidationException::class);
+ $this->handler->handle($eventId, $dto);
+ }
+
+ private function createAvailabilityDTO(
+ int $productId,
+ int $priceId,
+ int $available,
+ string $productType = ProductType::TICKET->name,
+ ): AvailableProductQuantitiesDTO {
+ return AvailableProductQuantitiesDTO::fromArray([
+ 'product_id' => $productId,
+ 'price_id' => $priceId,
+ 'product_title' => 'Test',
+ 'product_type' => $productType,
+ 'price_label' => null,
+ 'quantity_available' => $available,
+ 'quantity_reserved' => 0,
+ 'initial_quantity_available' => 100,
+ ]);
+ }
+
+ private function createMultiLineOrderDTO(array $lines): CreateOrderPublicDTO
+ {
+ return CreateOrderPublicDTO::fromArray([
+ 'is_user_authenticated' => false,
+ 'session_identifier' => 'test-session',
+ 'order_locale' => 'en',
+ 'products' => collect($lines)->map(fn (array $line) => ProductOrderDetailsDTO::fromArray([
+ 'product_id' => $line[0],
+ 'event_occurrence_id' => $line[3],
+ 'quantities' => collect([
+ OrderProductPriceDTO::fromArray([
+ 'price_id' => $line[1],
+ 'quantity' => $line[2],
+ ]),
+ ]),
+ ])),
+ ]);
+ }
+
private function createOrderDTO(int $productId = 10, int $priceId = 100, int $quantity = 1): CreateOrderPublicDTO
{
return CreateOrderPublicDTO::fromArray([
@@ -153,6 +391,7 @@ private function createOrderDTO(int $productId = 10, int $priceId = 100, int $qu
'products' => collect([
ProductOrderDetailsDTO::fromArray([
'product_id' => $productId,
+ 'event_occurrence_id' => 1,
'quantities' => collect([
OrderProductPriceDTO::fromArray([
'price_id' => $priceId,
@@ -183,25 +422,16 @@ private function setupSuccessfulOrderCreation(
int $productId = 10,
int $priceId = 100,
int $available = 10,
- ): void
- {
+ ): void {
$this->setupEventMock($eventId);
$this->orderManagementService->shouldReceive('deleteExistingOrders');
$this->availabilityService->shouldReceive('getAvailableProductQuantities')
- ->with($eventId, true)
+ ->with($eventId, true, Mockery::any())
->andReturn(new AvailableProductQuantitiesResponseDTO(
productQuantities: collect([
- AvailableProductQuantitiesDTO::fromArray([
- 'product_id' => $productId,
- 'price_id' => $priceId,
- 'product_title' => 'Test Product',
- 'price_label' => null,
- 'quantity_available' => $available,
- 'quantity_reserved' => 0,
- 'initial_quantity_available' => 100,
- ]),
+ $this->createAvailabilityDTO($productId, $priceId, $available),
]),
));
diff --git a/backend/tests/Unit/Services/Application/Handlers/Organizer/CreateOrganizerHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Organizer/CreateOrganizerHandlerTest.php
index ee94b20949..6f45d2e3ac 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Organizer/CreateOrganizerHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Organizer/CreateOrganizerHandlerTest.php
@@ -2,7 +2,10 @@
namespace Tests\Unit\Services\Application\Handlers\Organizer;
+use HiEvents\DomainObjects\OrganizerConfigurationDomainObject;
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\CreateOrganizerHandler;
use HiEvents\Services\Application\Handlers\Organizer\DTO\CreateOrganizerDTO;
@@ -11,21 +14,33 @@
use Illuminate\Database\DatabaseManager;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
+use Psr\Log\LoggerInterface;
use Tests\TestCase;
class CreateOrganizerHandlerTest extends TestCase
{
use MockeryPHPUnitIntegration;
- public function testDescriptionIsPurifiedOnCreate(): void
+ public function test_description_is_purified_on_create(): void
{
$organizerRepository = Mockery::mock(OrganizerRepositoryInterface::class);
+ $organizerConfigurationRepository = Mockery::mock(OrganizerConfigurationRepositoryInterface::class);
+ $accountRepository = Mockery::mock(AccountRepositoryInterface::class);
$databaseManager = Mockery::mock(DatabaseManager::class);
$createDefaultOrganizerSettingsService = Mockery::mock(CreateDefaultOrganizerSettingsService::class);
$purifier = Mockery::mock(HtmlPurifierService::class);
+ $logger = Mockery::mock(LoggerInterface::class);
- $databaseManager->shouldReceive('transaction')->andReturnUsing(fn($callback) => $callback());
- $purifier->shouldReceive('purify')->andReturnUsing(fn($v) => is_string($v) ? 'PURIFIED:' . $v : $v);
+ $databaseManager->shouldReceive('transaction')->andReturnUsing(fn ($callback) => $callback());
+ $purifier->shouldReceive('purify')->andReturnUsing(fn ($v) => is_string($v) ? 'PURIFIED:'.$v : $v);
+
+ $defaultConfiguration = Mockery::mock(OrganizerConfigurationDomainObject::class);
+ $defaultConfiguration->shouldReceive('getId')->andReturn(99);
+ $accountRepository->shouldReceive('findFirst')->andReturn(null);
+ $organizerConfigurationRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['is_system_default' => true])
+ ->andReturn($defaultConfiguration);
$organizer = Mockery::mock(OrganizerDomainObject::class);
$organizer->shouldReceive('getId')->andReturn(5);
@@ -36,6 +51,7 @@ public function testDescriptionIsPurifiedOnCreate(): void
->once()
->andReturnUsing(function ($attributes) use (&$capturedAttributes, $organizer) {
$capturedAttributes = $attributes;
+
return $organizer;
});
@@ -45,9 +61,12 @@ public function testDescriptionIsPurifiedOnCreate(): void
$handler = new CreateOrganizerHandler(
$organizerRepository,
+ $organizerConfigurationRepository,
+ $accountRepository,
$databaseManager,
$createDefaultOrganizerSettingsService,
$purifier,
+ $logger,
);
$dto = new CreateOrganizerDTO(
diff --git a/backend/tests/Unit/Services/Application/Handlers/Organizer/DeleteOrganizerHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Organizer/DeleteOrganizerHandlerTest.php
index 710e4b867a..d49fbd26a5 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Organizer/DeleteOrganizerHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Organizer/DeleteOrganizerHandlerTest.php
@@ -14,14 +14,17 @@
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Collection;
use Mockery as m;
-use Tests\TestCase;
use Psr\Log\LoggerInterface;
+use Tests\TestCase;
class DeleteOrganizerHandlerTest extends TestCase
{
private OrganizerRepositoryInterface $organizerRepository;
+
private EventRepositoryInterface $eventRepository;
+
private OrderRepositoryInterface $orderRepository;
+
private DeleteOrganizerHandler $handler;
protected function setUp(): void
@@ -37,7 +40,7 @@ protected function setUp(): void
$eventDatabaseManager = m::mock(DatabaseManager::class);
$databaseManager->shouldReceive('transaction')
- ->andReturnUsing(fn($callback) => $callback());
+ ->andReturnUsing(fn ($callback) => $callback());
$logger->shouldReceive('info')->byDefault();
$eventLogger->shouldReceive('info')->byDefault();
@@ -60,7 +63,7 @@ protected function setUp(): void
$this->handler = new DeleteOrganizerHandler($organizerDeletionService);
}
- public function testDeleteOrganizerSuccessfully(): void
+ public function test_delete_organizer_successfully(): void
{
$this->organizerRepository->shouldReceive('countWhere')
->with(['account_id' => 10])
@@ -68,7 +71,7 @@ public function testDeleteOrganizerSuccessfully(): void
$this->eventRepository->shouldReceive('findWhere')
->with(['organizer_id' => 1])
- ->andReturn(new Collection());
+ ->andReturn(new Collection);
$this->organizerRepository->shouldReceive('deleteWhere')
->once()
@@ -82,7 +85,7 @@ public function testDeleteOrganizerSuccessfully(): void
$this->assertTrue(true);
}
- public function testDeleteOrganizerFailsWithCompletedOrders(): void
+ public function test_delete_organizer_fails_with_completed_orders(): void
{
$event = m::mock(EventDomainObject::class);
$event->shouldReceive('getId')->andReturn(100);
@@ -106,7 +109,7 @@ public function testDeleteOrganizerFailsWithCompletedOrders(): void
$this->handler->handle($dto);
}
- public function testDeleteLastOrganizerFails(): void
+ public function test_delete_last_organizer_fails(): void
{
$this->organizerRepository->shouldReceive('countWhere')
->with(['account_id' => 10])
@@ -114,7 +117,7 @@ public function testDeleteLastOrganizerFails(): void
$this->eventRepository->shouldReceive('findWhere')
->with(['organizer_id' => 1])
- ->andReturn(new Collection());
+ ->andReturn(new Collection);
$dto = new DeleteOrganizerDTO(organizerId: 1, accountId: 10);
diff --git a/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandlerTest.php
new file mode 100644
index 0000000000..be703ddcd0
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandlerTest.php
@@ -0,0 +1,193 @@
+organizerRepository = m::mock(OrganizerRepositoryInterface::class);
+ $this->organizerStripePlatformRepository = m::mock(OrganizerStripePlatformRepositoryInterface::class);
+ $this->stripeAccountSyncService = m::mock(StripeAccountSyncService::class);
+ $this->stripeAccountSyncService->shouldReceive('seedVatSettingForOrganizerIfMissing')->byDefault();
+ $this->databaseManager = m::mock(DatabaseManager::class);
+ $this->config = m::mock(Repository::class);
+ }
+
+ protected function tearDown(): void
+ {
+ m::close();
+ parent::tearDown();
+ }
+
+ public function test_copies_connection_when_saas_mode_enabled_and_source_complete(): void
+ {
+ $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturnTrue();
+ $this->databaseManager
+ ->shouldReceive('transaction')
+ ->once()
+ ->andReturnUsing(fn (Closure $closure) => $closure());
+
+ $sourcePlatform = (new OrganizerStripePlatformDomainObject)
+ ->setId(11)
+ ->setOrganizerId(2)
+ ->setStripeAccountId('acct_source')
+ ->setStripeConnectAccountType('standard')
+ ->setStripeConnectPlatform('ca')
+ ->setStripeSetupCompletedAt('2026-01-01 00:00:00')
+ ->setStripeAccountDetails(['country' => 'CA']);
+
+ $source = (new OrganizerDomainObject)
+ ->setId(2)
+ ->setAccountId(99)
+ ->setName('Source');
+ $source->setOrganizerStripePlatforms(collect([$sourcePlatform]));
+
+ $target = (new OrganizerDomainObject)
+ ->setId(1)
+ ->setAccountId(99)
+ ->setName('Target');
+ $target->setOrganizerStripePlatforms(collect());
+
+ $this->organizerRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->organizerRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['id' => 1, 'account_id' => 99])
+ ->andReturn($target);
+ $this->organizerRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['id' => 2, 'account_id' => 99])
+ ->andReturn($source);
+
+ $this->organizerStripePlatformRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with(m::on(function (array $attrs) {
+ return $attrs[OrganizerStripePlatformDomainObjectAbstract::ORGANIZER_ID] === 1
+ && $attrs[OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID] === 'acct_source'
+ && $attrs[OrganizerStripePlatformDomainObjectAbstract::STRIPE_CONNECT_PLATFORM] === 'ca';
+ }))
+ ->andReturn(m::mock(OrganizerStripePlatformDomainObject::class));
+
+ $handler = $this->makeHandler();
+
+ $response = $handler->handle(new CopyStripeConnectAccountDTO(
+ targetOrganizerId: 1,
+ sourceOrganizerId: 2,
+ accountId: 99,
+ ));
+
+ $this->assertSame('acct_source', $response->stripeAccountId);
+ $this->assertTrue($response->isConnectSetupComplete);
+ }
+
+ public function test_throws_when_saas_mode_disabled(): void
+ {
+ $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturnFalse();
+
+ $this->expectException(SaasModeEnabledException::class);
+
+ $this->makeHandler()->handle(new CopyStripeConnectAccountDTO(
+ targetOrganizerId: 1,
+ sourceOrganizerId: 2,
+ accountId: 99,
+ ));
+ }
+
+ public function test_throws_when_source_has_no_completed_setup(): void
+ {
+ $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturnTrue();
+ $this->databaseManager
+ ->shouldReceive('transaction')
+ ->once()
+ ->andReturnUsing(fn (Closure $closure) => $closure());
+
+ $source = (new OrganizerDomainObject)->setId(2)->setAccountId(99)->setName('Source');
+ $source->setOrganizerStripePlatforms(new Collection);
+
+ $target = (new OrganizerDomainObject)->setId(1)->setAccountId(99)->setName('Target');
+ $target->setOrganizerStripePlatforms(new Collection);
+
+ $this->organizerRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->organizerRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['id' => 1, 'account_id' => 99])
+ ->andReturn($target);
+ $this->organizerRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['id' => 2, 'account_id' => 99])
+ ->andReturn($source);
+
+ $this->expectException(ResourceConflictException::class);
+
+ $this->makeHandler()->handle(new CopyStripeConnectAccountDTO(
+ targetOrganizerId: 1,
+ sourceOrganizerId: 2,
+ accountId: 99,
+ ));
+ }
+
+ public function test_throws_when_target_organizer_not_found(): void
+ {
+ $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturnTrue();
+ $this->databaseManager
+ ->shouldReceive('transaction')
+ ->once()
+ ->andReturnUsing(fn (Closure $closure) => $closure());
+
+ $this->organizerRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->organizerRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['id' => 1, 'account_id' => 99])
+ ->andReturnNull();
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->makeHandler()->handle(new CopyStripeConnectAccountDTO(
+ targetOrganizerId: 1,
+ sourceOrganizerId: 2,
+ accountId: 99,
+ ));
+ }
+
+ private function makeHandler(): CopyStripeConnectAccountHandler
+ {
+ return new CopyStripeConnectAccountHandler(
+ $this->organizerRepository,
+ $this->organizerStripePlatformRepository,
+ $this->stripeAccountSyncService,
+ $this->databaseManager,
+ $this->config,
+ );
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/CreateStripeConnectAccountHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/CreateStripeConnectAccountHandlerTest.php
new file mode 100644
index 0000000000..79f1da42da
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/CreateStripeConnectAccountHandlerTest.php
@@ -0,0 +1,106 @@
+organizerRepository = m::mock(OrganizerRepositoryInterface::class);
+ $this->organizerStripePlatformRepository = m::mock(OrganizerStripePlatformRepositoryInterface::class);
+ $this->databaseManager = m::mock(DatabaseManager::class);
+ $this->logger = m::mock(LoggerInterface::class);
+ $this->config = m::mock(Repository::class);
+ $this->stripeClientFactory = m::mock(StripeClientFactory::class);
+ $this->stripeConfigurationService = m::mock(StripeConfigurationService::class);
+ $this->stripeAccountSyncService = m::mock(StripeAccountSyncService::class);
+ }
+
+ protected function tearDown(): void
+ {
+ m::close();
+ parent::tearDown();
+ }
+
+ public function test_throws_when_saas_mode_disabled(): void
+ {
+ $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturnFalse();
+
+ $this->expectException(SaasModeEnabledException::class);
+
+ $this->makeHandler()->handle(new CreateStripeConnectAccountDTO(
+ organizerId: 1,
+ accountId: 99,
+ ));
+ }
+
+ public function test_throws_resource_not_found_when_organizer_missing(): void
+ {
+ $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturnTrue();
+ $this->databaseManager
+ ->shouldReceive('transaction')
+ ->once()
+ ->andReturnUsing(fn (Closure $closure) => $closure());
+
+ $this->organizerRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->organizerRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['id' => 1, 'account_id' => 99])
+ ->andReturnNull();
+
+ $this->expectException(ResourceNotFoundException::class);
+
+ $this->makeHandler()->handle(new CreateStripeConnectAccountDTO(
+ organizerId: 1,
+ accountId: 99,
+ ));
+ }
+
+ private function makeHandler(): CreateStripeConnectAccountHandler
+ {
+ return new CreateStripeConnectAccountHandler(
+ $this->organizerRepository,
+ $this->organizerStripePlatformRepository,
+ $this->databaseManager,
+ $this->logger,
+ $this->config,
+ $this->stripeClientFactory,
+ $this->stripeConfigurationService,
+ $this->stripeAccountSyncService,
+ );
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Organizer/UpdateOrganizerLocationHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Organizer/UpdateOrganizerLocationHandlerTest.php
new file mode 100644
index 0000000000..73e2e81d1f
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/Organizer/UpdateOrganizerLocationHandlerTest.php
@@ -0,0 +1,162 @@
+organizerRepository = Mockery::mock(OrganizerRepositoryInterface::class);
+ $this->locationRepository = Mockery::mock(LocationRepositoryInterface::class);
+ $this->locationLockService = Mockery::mock(LocationLockService::class);
+ $databaseManager = Mockery::mock(DatabaseManager::class);
+ $databaseManager->shouldReceive('transaction')->andReturnUsing(fn ($callback) => $callback());
+
+ $this->handler = new UpdateOrganizerLocationHandler(
+ $this->organizerRepository,
+ new LocationOwnershipValidator($this->locationRepository, $this->locationLockService),
+ $databaseManager,
+ );
+ }
+
+ public function test_happy_path_sets_location_id(): void
+ {
+ $dto = new UpdateOrganizerLocationDTO(organizer_id: 10, account_id: 5, location_id: 99);
+ $organizer = Mockery::mock(OrganizerDomainObject::class);
+
+ $this->organizerRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['id' => 10, 'account_id' => 5])
+ ->andReturn($organizer, $organizer);
+
+ $this->locationLockService
+ ->shouldReceive('acquireSharedTransactionLock')
+ ->once()
+ ->with(99);
+
+ $this->locationRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['id' => 99, 'account_id' => 5, 'organizer_id' => 10])
+ ->andReturn(Mockery::mock(LocationDomainObject::class));
+
+ $this->organizerRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ ['location_id' => 99],
+ ['id' => 10, 'account_id' => 5],
+ );
+
+ $this->assertSame($organizer, $this->handler->handle($dto));
+ }
+
+ public function test_null_location_id_clears_the_relation_without_validator_lookup(): void
+ {
+ $dto = new UpdateOrganizerLocationDTO(organizer_id: 10, account_id: 5, location_id: null);
+ $organizer = Mockery::mock(OrganizerDomainObject::class);
+
+ $this->organizerRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn($organizer);
+
+ $this->locationLockService->shouldNotReceive('acquireSharedTransactionLock');
+ $this->locationRepository->shouldNotReceive('findFirstWhere');
+
+ $this->organizerRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(['location_id' => null], ['id' => 10, 'account_id' => 5]);
+
+ $this->assertSame($organizer, $this->handler->handle($dto));
+ }
+
+ public function test_throws_when_organizer_not_found(): void
+ {
+ $dto = new UpdateOrganizerLocationDTO(organizer_id: 10, account_id: 5, location_id: null);
+
+ $this->organizerRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn(null);
+
+ $this->expectException(ResourceNotFoundException::class);
+ $this->handler->handle($dto);
+ }
+
+ public function test_throws_when_location_belongs_to_a_different_organizer_in_same_account(): void
+ {
+ $dto = new UpdateOrganizerLocationDTO(organizer_id: 10, account_id: 5, location_id: 99);
+
+ $this->organizerRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn(Mockery::mock(OrganizerDomainObject::class));
+
+ $this->locationLockService
+ ->shouldReceive('acquireSharedTransactionLock')
+ ->once()
+ ->with(99);
+
+ $this->locationRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['id' => 99, 'account_id' => 5, 'organizer_id' => 10])
+ ->andReturn(null);
+
+ $this->organizerRepository->shouldNotReceive('updateWhere');
+
+ $this->expectException(ResourceNotFoundException::class);
+ $this->handler->handle($dto);
+ }
+
+ public function test_throws_when_location_belongs_to_a_different_account(): void
+ {
+ $dto = new UpdateOrganizerLocationDTO(organizer_id: 10, account_id: 5, location_id: 99);
+
+ $this->organizerRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn(Mockery::mock(OrganizerDomainObject::class));
+
+ $this->locationLockService
+ ->shouldReceive('acquireSharedTransactionLock')
+ ->once()
+ ->with(99);
+
+ $this->locationRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andReturn(null);
+
+ $this->expectException(ResourceNotFoundException::class);
+ $this->handler->handle($dto);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Organizer/UpdateOrganizerStatusHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Organizer/UpdateOrganizerStatusHandlerTest.php
index 988877c14c..55d45cc46d 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Organizer/UpdateOrganizerStatusHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Organizer/UpdateOrganizerStatusHandlerTest.php
@@ -19,8 +19,11 @@
class UpdateOrganizerStatusHandlerTest extends TestCase
{
private OrganizerRepositoryInterface $organizerRepository;
+
private AccountRepositoryInterface $accountRepository;
+
private EventRepositoryInterface $eventRepository;
+
private UpdateOrganizerStatusHandler $handler;
protected function setUp(): void
@@ -34,7 +37,7 @@ protected function setUp(): void
$databaseManager = m::mock(DatabaseManager::class);
$databaseManager->shouldReceive('transaction')
- ->andReturnUsing(fn($callback) => $callback());
+ ->andReturnUsing(fn ($callback) => $callback());
$logger->shouldReceive('info')->byDefault();
@@ -47,7 +50,7 @@ protected function setUp(): void
);
}
- public function testArchiveLastActiveOrganizerFails(): void
+ public function test_archive_last_active_organizer_fails(): void
{
$account = m::mock(AccountDomainObject::class);
$account->shouldReceive('getAccountVerifiedAt')->andReturn('2024-01-01');
@@ -74,7 +77,7 @@ public function testArchiveLastActiveOrganizerFails(): void
$this->handler->handle($dto);
}
- public function testArchiveOrganizerSucceedsWhenOtherActiveOrganizersExist(): void
+ public function test_archive_organizer_succeeds_when_other_active_organizers_exist(): void
{
$account = m::mock(AccountDomainObject::class);
$account->shouldReceive('getAccountVerifiedAt')->andReturn('2024-01-01');
@@ -93,8 +96,8 @@ public function testArchiveOrganizerSucceedsWhenOtherActiveOrganizersExist(): vo
$this->organizerRepository->shouldReceive('updateWhere')
->once()
->with(
- m::on(fn($attrs) => $attrs['status'] === OrganizerStatus::ARCHIVED->name),
- m::on(fn($where) => $where['id'] === 1 && $where['account_id'] === 10),
+ m::on(fn ($attrs) => $attrs['status'] === OrganizerStatus::ARCHIVED->name),
+ m::on(fn ($where) => $where['id'] === 1 && $where['account_id'] === 10),
)
->andReturn(1);
diff --git a/backend/tests/Unit/Services/Application/Handlers/OrganizerSettings/PartialUpdateOrganizerSettingsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/OrganizerSettings/PartialUpdateOrganizerSettingsHandlerTest.php
index 7e0cad91c9..b374be361d 100644
--- a/backend/tests/Unit/Services/Application/Handlers/OrganizerSettings/PartialUpdateOrganizerSettingsHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/OrganizerSettings/PartialUpdateOrganizerSettingsHandlerTest.php
@@ -17,7 +17,9 @@ class PartialUpdateOrganizerSettingsHandlerTest extends TestCase
use MockeryPHPUnitIntegration;
private OrganizerSettingsRepositoryInterface $settingsRepository;
+
private OrganizerRepositoryInterface $organizerRepository;
+
private PartialUpdateOrganizerSettingsHandler $handler;
protected function setUp(): void
@@ -33,12 +35,12 @@ protected function setUp(): void
);
}
- public function testTrackingPixelsArePersisted(): void
+ public function test_tracking_pixels_are_persisted(): void
{
- $organizer = new OrganizerDomainObject();
+ $organizer = new OrganizerDomainObject;
$organizer->setId(1);
- $existingSettings = new OrganizerSettingDomainObject();
+ $existingSettings = new OrganizerSettingDomainObject;
$existingSettings->setId(10);
$existingSettings->setOrganizerId(1);
@@ -70,7 +72,7 @@ public function testTrackingPixelsArePersisted(): void
Mockery::any()
);
- $updatedSettings = new OrganizerSettingDomainObject();
+ $updatedSettings = new OrganizerSettingDomainObject;
$updatedSettings->setId(10);
$this->settingsRepository
@@ -91,16 +93,16 @@ public function testTrackingPixelsArePersisted(): void
$this->assertInstanceOf(OrganizerSettingDomainObject::class, $result);
}
- public function testTrackingPixelsDefaultToExistingWhenNotProvided(): void
+ public function test_tracking_pixels_default_to_existing_when_not_provided(): void
{
- $organizer = new OrganizerDomainObject();
+ $organizer = new OrganizerDomainObject;
$organizer->setId(1);
$existingPixels = [
['provider' => 'facebook_pixel', 'pixel_id' => '9999999', 'enabled' => true],
];
- $existingSettings = new OrganizerSettingDomainObject();
+ $existingSettings = new OrganizerSettingDomainObject;
$existingSettings->setId(10);
$existingSettings->setOrganizerId(1);
$existingSettings->setTrackingPixels($existingPixels);
diff --git a/backend/tests/Unit/Services/Application/Handlers/Product/GetProductsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Product/GetProductsHandlerTest.php
new file mode 100644
index 0000000000..d709fc479c
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/Product/GetProductsHandlerTest.php
@@ -0,0 +1,62 @@
+productRepository = Mockery::mock(ProductRepositoryInterface::class);
+ $this->productFilterService = Mockery::mock(ProductFilterService::class);
+
+ $this->handler = new GetProductsHandler(
+ productRepository: $this->productRepository,
+ productFilterService: $this->productFilterService,
+ );
+ }
+
+ public function test_handle_filters_the_paginated_products_as_a_flat_collection(): void
+ {
+ $product = (new ProductDomainObject)->setId(1)->setEventId(10);
+ $paginator = new LengthAwarePaginator(collect([$product]), 1, 25);
+ $queryParams = new QueryParamsDTO;
+
+ $this->productRepository->shouldReceive('loadRelation')->twice()->andReturnSelf();
+ $this->productRepository->shouldReceive('findByEventId')
+ ->once()
+ ->with(10, $queryParams)
+ ->andReturn($paginator);
+
+ $filteredProducts = collect([$product]);
+ $this->productFilterService->shouldReceive('filterProducts')
+ ->once()
+ ->withArgs(static function (Collection $products, $promoCode = null, bool $hideSoldOutProducts = true, $eventOccurrenceId = null) use ($product) {
+ return $products->all() === [$product] && $hideSoldOutProducts === false;
+ })
+ ->andReturn($filteredProducts);
+
+ $result = $this->handler->handle(eventId: 10, queryParamsDTO: $queryParams);
+
+ $this->assertSame($paginator, $result);
+ $this->assertSame($filteredProducts, $result->getCollection());
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/PromoCode/UpdatePromoCodeHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/PromoCode/UpdatePromoCodeHandlerTest.php
index a5d59ffff9..e90b313c84 100644
--- a/backend/tests/Unit/Services/Application/Handlers/PromoCode/UpdatePromoCodeHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/PromoCode/UpdatePromoCodeHandlerTest.php
@@ -2,6 +2,7 @@
namespace Tests\Unit\Services\Application\Handlers\PromoCode;
+use HiEvents\DomainObjects\Enums\PromoCodeDiscountAppliesToEnum;
use HiEvents\DomainObjects\Enums\PromoCodeDiscountTypeEnum;
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\PromoCodeDomainObject;
@@ -17,8 +18,11 @@
class UpdatePromoCodeHandlerTest extends TestCase
{
private PromoCodeRepositoryInterface $promoCodeRepository;
+
private EventProductValidationService $eventProductValidationService;
+
private EventRepositoryInterface $eventRepository;
+
private UpdatePromoCodeHandler $handler;
protected function setUp(): void
@@ -36,7 +40,7 @@ protected function setUp(): void
);
}
- public function testHandleThrowsExceptionWhenPromoCodeNotFoundForEvent(): void
+ public function test_handle_throws_exception_when_promo_code_not_found_for_event(): void
{
$promoCodeId = 1;
$eventId = 2;
@@ -47,7 +51,8 @@ public function testHandleThrowsExceptionWhenPromoCodeNotFoundForEvent(): void
discount_type: PromoCodeDiscountTypeEnum::PERCENTAGE,
discount: 10.0,
expiry_date: null,
- max_allowed_usages: null
+ max_allowed_usages: null,
+ discount_applies_to: PromoCodeDiscountAppliesToEnum::EACH_PRODUCT
);
$this->promoCodeRepository
@@ -65,7 +70,7 @@ public function testHandleThrowsExceptionWhenPromoCodeNotFoundForEvent(): void
$this->handler->handle($promoCodeId, $dto);
}
- public function testHandleVerifiesPromoCodeBelongsToEvent(): void
+ public function test_handle_verifies_promo_code_belongs_to_event(): void
{
$promoCodeId = 1;
$eventIdFromRequest = 2;
@@ -78,7 +83,8 @@ public function testHandleVerifiesPromoCodeBelongsToEvent(): void
discount_type: PromoCodeDiscountTypeEnum::PERCENTAGE,
discount: 10.0,
expiry_date: null,
- max_allowed_usages: null
+ max_allowed_usages: null,
+ discount_applies_to: PromoCodeDiscountAppliesToEnum::EACH_PRODUCT
);
$this->promoCodeRepository
@@ -98,7 +104,7 @@ public function testHandleVerifiesPromoCodeBelongsToEvent(): void
$this->handler->handle($promoCodeId, $dto);
}
- public function testHandleSuccessfullyUpdatesPromoCodeWhenOwnershipVerified(): void
+ public function test_handle_successfully_updates_promo_code_when_ownership_verified(): void
{
$promoCodeId = 1;
$eventId = 2;
@@ -109,7 +115,8 @@ public function testHandleSuccessfullyUpdatesPromoCodeWhenOwnershipVerified(): v
discount_type: PromoCodeDiscountTypeEnum::PERCENTAGE,
discount: 10.0,
expiry_date: null,
- max_allowed_usages: null
+ max_allowed_usages: null,
+ discount_applies_to: PromoCodeDiscountAppliesToEnum::EACH_PRODUCT
);
$existingPromoCode = m::mock(PromoCodeDomainObject::class);
@@ -158,6 +165,53 @@ public function testHandleSuccessfullyUpdatesPromoCodeWhenOwnershipVerified(): v
$this->assertSame($updatedPromoCode, $result);
}
+ public function test_handle_preserves_stored_discount_applies_to_when_omitted(): void
+ {
+ $promoCodeId = 1;
+ $eventId = 2;
+ $dto = new UpsertPromoCodeDTO(
+ code: 'testcode',
+ event_id: $eventId,
+ applicable_product_ids: [],
+ discount_type: PromoCodeDiscountTypeEnum::FIXED,
+ discount: 10.0,
+ expiry_date: null,
+ max_allowed_usages: null,
+ discount_applies_to: null
+ );
+
+ $existingPromoCode = m::mock(PromoCodeDomainObject::class);
+ $existingPromoCode->shouldReceive('getId')->andReturn($promoCodeId);
+ $existingPromoCode->shouldReceive('getDiscountAppliesTo')->andReturn(PromoCodeDiscountAppliesToEnum::ORDER->name);
+
+ $event = m::mock(EventDomainObject::class);
+ $event->shouldReceive('getTimezone')->andReturn('UTC');
+
+ $this->promoCodeRepository
+ ->shouldReceive('findFirstWhere')
+ ->twice()
+ ->andReturn($existingPromoCode);
+
+ $this->eventProductValidationService
+ ->shouldReceive('validateProductIds')
+ ->once();
+
+ $this->eventRepository
+ ->shouldReceive('findById')
+ ->once()
+ ->andReturn($event);
+
+ $this->promoCodeRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with($promoCodeId, m::on(
+ static fn (array $attributes) => $attributes['discount_applies_to'] === PromoCodeDiscountAppliesToEnum::ORDER->name
+ ))
+ ->andReturn($existingPromoCode);
+
+ $this->assertSame($existingPromoCode, $this->handler->handle($promoCodeId, $dto));
+ }
+
protected function tearDown(): void
{
m::close();
diff --git a/backend/tests/Unit/Services/Application/Handlers/Question/EditQuestionHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Question/EditQuestionHandlerTest.php
index ca4bd07f4f..c9480639bd 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Question/EditQuestionHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Question/EditQuestionHandlerTest.php
@@ -17,12 +17,12 @@ class EditQuestionHandlerTest extends TestCase
{
use MockeryPHPUnitIntegration;
- public function testDescriptionIsPurifiedOnEdit(): void
+ public function test_description_is_purified_on_edit(): void
{
$editQuestionService = Mockery::mock(EditQuestionService::class);
$purifier = Mockery::mock(HtmlPurifierService::class);
- $purifier->shouldReceive('purify')->andReturnUsing(fn($v) => is_string($v) ? 'PURIFIED:' . $v : $v);
+ $purifier->shouldReceive('purify')->andReturnUsing(fn ($v) => is_string($v) ? 'PURIFIED:'.$v : $v);
$capturedQuestion = null;
$editQuestionService
@@ -30,6 +30,7 @@ public function testDescriptionIsPurifiedOnEdit(): void
->once()
->andReturnUsing(function ($question) use (&$capturedQuestion) {
$capturedQuestion = $question;
+
return $question;
});
diff --git a/backend/tests/Unit/Services/Application/Handlers/Reports/GetOrganizerReportHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Reports/GetOrganizerReportHandlerTest.php
index 10250c21bb..4f1f3759a9 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Reports/GetOrganizerReportHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Reports/GetOrganizerReportHandlerTest.php
@@ -13,6 +13,7 @@
class GetOrganizerReportHandlerTest extends TestCase
{
private OrganizerReportServiceFactory $reportServiceFactory;
+
private GetOrganizerReportHandler $handler;
protected function setUp(): void
@@ -23,7 +24,7 @@ protected function setUp(): void
$this->handler = new GetOrganizerReportHandler($this->reportServiceFactory);
}
- public function testHandleReturnsReportData(): void
+ public function test_handle_returns_report_data(): void
{
$organizerId = 1;
$reportType = OrganizerReportTypes::REVENUE_SUMMARY;
@@ -67,7 +68,7 @@ public function testHandleReturnsReportData(): void
$this->assertEquals($expectedCollection, $result);
}
- public function testHandleWorksWithNullDates(): void
+ public function test_handle_works_with_null_dates(): void
{
$organizerId = 1;
$reportType = OrganizerReportTypes::EVENTS_PERFORMANCE;
@@ -105,7 +106,7 @@ public function testHandleWorksWithNullDates(): void
$this->assertTrue(true);
}
- public function testHandleCorrectlyRoutesToTaxSummaryReport(): void
+ public function test_handle_correctly_routes_to_tax_summary_report(): void
{
$organizerId = 2;
$reportType = OrganizerReportTypes::TAX_SUMMARY;
diff --git a/backend/tests/Unit/Services/Application/Handlers/Sitemap/GetSitemapEventsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Sitemap/GetSitemapEventsHandlerTest.php
index 5d9f15b43b..108cb92a52 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Sitemap/GetSitemapEventsHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Sitemap/GetSitemapEventsHandlerTest.php
@@ -18,7 +18,9 @@
class GetSitemapEventsHandlerTest extends TestCase
{
private EventRepositoryInterface $eventRepository;
+
private SitemapGeneratorService $sitemapGenerator;
+
private GetSitemapEventsHandler $handler;
protected function setUp(): void
@@ -38,7 +40,7 @@ protected function setUp(): void
config(['app.frontend_url' => 'https://example.com']);
}
- public function testHandleReturnsCachedXml(): void
+ public function test_handle_returns_cached_xml(): void
{
$expectedXml = ' ';
@@ -57,7 +59,7 @@ public function testHandleReturnsCachedXml(): void
$this->assertEquals($expectedXml, $result);
}
- public function testHandleGeneratesXmlWhenCacheMiss(): void
+ public function test_handle_generates_xml_when_cache_miss(): void
{
$expectedXml = ' ';
$events = new Collection([m::mock(EventDomainObject::class)]);
@@ -84,14 +86,14 @@ public function testHandleGeneratesXmlWhenCacheMiss(): void
Cache::shouldReceive('remember')
->once()
->with('sitemap:events:1', 3600, m::type('Closure'))
- ->andReturnUsing(fn($key, $ttl, $callback) => $callback());
+ ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());
$result = $this->handler->handle(1);
$this->assertEquals($expectedXml, $result);
}
- public function testHandleThrowsExceptionForPageLessThanOne(): void
+ public function test_handle_throws_exception_for_page_less_than_one(): void
{
$this->expectException(ResourceNotFoundException::class);
$this->expectExceptionMessage('Page must be a positive integer');
@@ -99,7 +101,7 @@ public function testHandleThrowsExceptionForPageLessThanOne(): void
$this->handler->handle(0);
}
- public function testHandleThrowsExceptionForNegativePage(): void
+ public function test_handle_throws_exception_for_negative_page(): void
{
$this->expectException(ResourceNotFoundException::class);
$this->expectExceptionMessage('Page must be a positive integer');
@@ -107,7 +109,7 @@ public function testHandleThrowsExceptionForNegativePage(): void
$this->handler->handle(-1);
}
- public function testHandleThrowsExceptionForPageBeyondTotal(): void
+ public function test_handle_throws_exception_for_page_beyond_total(): void
{
$this->eventRepository
->shouldReceive('getSitemapEventCount')
@@ -120,7 +122,7 @@ public function testHandleThrowsExceptionForPageBeyondTotal(): void
$this->handler->handle(2);
}
- public function testHandleAllowsLastValidPage(): void
+ public function test_handle_allows_last_valid_page(): void
{
config(['sitemap.events_per_page' => 100]);
@@ -146,14 +148,14 @@ public function testHandleAllowsLastValidPage(): void
Cache::shouldReceive('remember')
->once()
- ->andReturnUsing(fn($key, $ttl, $callback) => $callback());
+ ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());
$result = $this->handler->handle(3);
$this->assertEquals('xml', $result);
}
- public function testHandleUsesCorrectCacheKeyForDifferentPages(): void
+ public function test_handle_uses_correct_cache_key_for_different_pages(): void
{
$this->eventRepository
->shouldReceive('getSitemapEventCount')
@@ -169,7 +171,7 @@ public function testHandleUsesCorrectCacheKeyForDifferentPages(): void
$this->assertEquals('xml', $result);
}
- public function testHandleTrimsTrailingSlashFromBaseUrl(): void
+ public function test_handle_trims_trailing_slash_from_base_url(): void
{
config(['app.frontend_url' => 'https://example.com/']);
@@ -195,7 +197,7 @@ public function testHandleTrimsTrailingSlashFromBaseUrl(): void
Cache::shouldReceive('remember')
->once()
- ->andReturnUsing(fn($key, $ttl, $callback) => $callback());
+ ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());
$result = $this->handler->handle(1);
diff --git a/backend/tests/Unit/Services/Application/Handlers/Sitemap/GetSitemapIndexHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Sitemap/GetSitemapIndexHandlerTest.php
index 7f6880dc80..8029caf77f 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Sitemap/GetSitemapIndexHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Sitemap/GetSitemapIndexHandlerTest.php
@@ -15,8 +15,11 @@
class GetSitemapIndexHandlerTest extends TestCase
{
private EventRepositoryInterface $eventRepository;
+
private OrganizerRepositoryInterface $organizerRepository;
+
private SitemapGeneratorService $sitemapGenerator;
+
private GetSitemapIndexHandler $handler;
protected function setUp(): void
@@ -39,7 +42,7 @@ protected function setUp(): void
config(['app.frontend_url' => 'https://example.com']);
}
- public function testHandleReturnsCachedXml(): void
+ public function test_handle_returns_cached_xml(): void
{
$expectedXml = ' ';
@@ -53,7 +56,7 @@ public function testHandleReturnsCachedXml(): void
$this->assertEquals($expectedXml, $result);
}
- public function testHandleGeneratesXmlWhenCacheMiss(): void
+ public function test_handle_generates_xml_when_cache_miss(): void
{
$expectedXml = ' ';
@@ -85,7 +88,7 @@ public function testHandleGeneratesXmlWhenCacheMiss(): void
$this->assertEquals($expectedXml, $result);
}
- public function testHandleCalculatesCorrectPageCount(): void
+ public function test_handle_calculates_correct_page_count(): void
{
config(['sitemap.events_per_page' => 500]);
config(['sitemap.organizers_per_page' => 500]);
@@ -108,14 +111,14 @@ public function testHandleCalculatesCorrectPageCount(): void
Cache::shouldReceive('remember')
->once()
- ->andReturnUsing(fn($key, $ttl, $callback) => $callback());
+ ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());
$result = $this->handler->handle();
$this->assertEquals('xml', $result);
}
- public function testHandleReturnsAtLeastOnePage(): void
+ public function test_handle_returns_at_least_one_page(): void
{
$this->eventRepository
->shouldReceive('getSitemapEventCount')
@@ -135,14 +138,14 @@ public function testHandleReturnsAtLeastOnePage(): void
Cache::shouldReceive('remember')
->once()
- ->andReturnUsing(fn($key, $ttl, $callback) => $callback());
+ ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());
$result = $this->handler->handle();
$this->assertEquals('xml', $result);
}
- public function testHandleTrimsTrailingSlashFromBaseUrl(): void
+ public function test_handle_trims_trailing_slash_from_base_url(): void
{
config(['app.frontend_url' => 'https://example.com/']);
@@ -164,7 +167,7 @@ public function testHandleTrimsTrailingSlashFromBaseUrl(): void
Cache::shouldReceive('remember')
->once()
- ->andReturnUsing(fn($key, $ttl, $callback) => $callback());
+ ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());
$result = $this->handler->handle();
diff --git a/backend/tests/Unit/Services/Application/Handlers/Sitemap/GetSitemapOrganizersHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Sitemap/GetSitemapOrganizersHandlerTest.php
index 448f112ecd..9b0da9bb1e 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Sitemap/GetSitemapOrganizersHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Sitemap/GetSitemapOrganizersHandlerTest.php
@@ -18,7 +18,9 @@
class GetSitemapOrganizersHandlerTest extends TestCase
{
private OrganizerRepositoryInterface $organizerRepository;
+
private SitemapGeneratorService $sitemapGenerator;
+
private GetSitemapOrganizersHandler $handler;
protected function setUp(): void
@@ -38,7 +40,7 @@ protected function setUp(): void
config(['app.frontend_url' => 'https://example.com']);
}
- public function testHandleReturnsCachedXml(): void
+ public function test_handle_returns_cached_xml(): void
{
$expectedXml = ' ';
@@ -57,7 +59,7 @@ public function testHandleReturnsCachedXml(): void
$this->assertEquals($expectedXml, $result);
}
- public function testHandleGeneratesXmlWhenCacheMiss(): void
+ public function test_handle_generates_xml_when_cache_miss(): void
{
$expectedXml = ' ';
$organizers = new Collection([m::mock(OrganizerDomainObject::class)]);
@@ -84,14 +86,14 @@ public function testHandleGeneratesXmlWhenCacheMiss(): void
Cache::shouldReceive('remember')
->once()
->with('sitemap:organizers:1', 3600, m::type('Closure'))
- ->andReturnUsing(fn($key, $ttl, $callback) => $callback());
+ ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());
$result = $this->handler->handle(1);
$this->assertEquals($expectedXml, $result);
}
- public function testHandleThrowsExceptionForPageLessThanOne(): void
+ public function test_handle_throws_exception_for_page_less_than_one(): void
{
$this->expectException(ResourceNotFoundException::class);
$this->expectExceptionMessage('Page must be a positive integer');
@@ -99,7 +101,7 @@ public function testHandleThrowsExceptionForPageLessThanOne(): void
$this->handler->handle(0);
}
- public function testHandleThrowsExceptionForNegativePage(): void
+ public function test_handle_throws_exception_for_negative_page(): void
{
$this->expectException(ResourceNotFoundException::class);
$this->expectExceptionMessage('Page must be a positive integer');
@@ -107,7 +109,7 @@ public function testHandleThrowsExceptionForNegativePage(): void
$this->handler->handle(-1);
}
- public function testHandleThrowsExceptionForPageBeyondTotal(): void
+ public function test_handle_throws_exception_for_page_beyond_total(): void
{
$this->organizerRepository
->shouldReceive('getSitemapOrganizerCount')
@@ -120,7 +122,7 @@ public function testHandleThrowsExceptionForPageBeyondTotal(): void
$this->handler->handle(2);
}
- public function testHandleAllowsLastValidPage(): void
+ public function test_handle_allows_last_valid_page(): void
{
config(['sitemap.organizers_per_page' => 100]);
@@ -146,14 +148,14 @@ public function testHandleAllowsLastValidPage(): void
Cache::shouldReceive('remember')
->once()
- ->andReturnUsing(fn($key, $ttl, $callback) => $callback());
+ ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());
$result = $this->handler->handle(3);
$this->assertEquals('xml', $result);
}
- public function testHandleUsesCorrectCacheKeyForDifferentPages(): void
+ public function test_handle_uses_correct_cache_key_for_different_pages(): void
{
$this->organizerRepository
->shouldReceive('getSitemapOrganizerCount')
@@ -169,7 +171,7 @@ public function testHandleUsesCorrectCacheKeyForDifferentPages(): void
$this->assertEquals('xml', $result);
}
- public function testHandleTrimsTrailingSlashFromBaseUrl(): void
+ public function test_handle_trims_trailing_slash_from_base_url(): void
{
config(['app.frontend_url' => 'https://example.com/']);
@@ -195,7 +197,7 @@ public function testHandleTrimsTrailingSlashFromBaseUrl(): void
Cache::shouldReceive('remember')
->once()
- ->andReturnUsing(fn($key, $ttl, $callback) => $callback());
+ ->andReturnUsing(fn ($key, $ttl, $callback) => $callback());
$result = $this->handler->handle(1);
diff --git a/backend/tests/Unit/Services/Application/Handlers/TicketLookup/GetOrdersByLookupTokenHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/TicketLookup/GetOrdersByLookupTokenHandlerTest.php
index 79909fbda0..d735117816 100644
--- a/backend/tests/Unit/Services/Application/Handlers/TicketLookup/GetOrdersByLookupTokenHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/TicketLookup/GetOrdersByLookupTokenHandlerTest.php
@@ -18,7 +18,9 @@
class GetOrdersByLookupTokenHandlerTest extends TestCase
{
private TicketLookupTokenRepositoryInterface $ticketLookupTokenRepository;
+
private OrderRepositoryInterface $orderRepository;
+
private GetOrdersByLookupTokenHandler $handler;
protected function setUp(): void
@@ -35,7 +37,7 @@ protected function setUp(): void
);
}
- public function testHandleSuccessfullyReturnsOrdersWhenTokenIsValid(): void
+ public function test_handle_successfully_returns_orders_when_token_is_valid(): void
{
$token = 'tl_validtoken123';
$email = 'test@example.com';
@@ -72,7 +74,7 @@ public function testHandleSuccessfullyReturnsOrdersWhenTokenIsValid(): void
$this->assertCount(1, $result);
}
- public function testHandleThrowsExceptionWhenTokenNotFound(): void
+ public function test_handle_throws_exception_when_token_not_found(): void
{
$token = 'tl_invalidtoken';
$dto = new GetOrdersByLookupTokenDTO(token: $token);
@@ -92,7 +94,7 @@ public function testHandleThrowsExceptionWhenTokenNotFound(): void
$this->handler->handle($dto);
}
- public function testHandleThrowsExceptionWhenTokenIsExpired(): void
+ public function test_handle_throws_exception_when_token_is_expired(): void
{
$token = 'tl_expiredtoken';
$dto = new GetOrdersByLookupTokenDTO(token: $token);
diff --git a/backend/tests/Unit/Services/Application/Handlers/TicketLookup/SendTicketLookupEmailHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/TicketLookup/SendTicketLookupEmailHandlerTest.php
index 5df968e3d6..06759e7016 100644
--- a/backend/tests/Unit/Services/Application/Handlers/TicketLookup/SendTicketLookupEmailHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/TicketLookup/SendTicketLookupEmailHandlerTest.php
@@ -3,7 +3,6 @@
namespace Tests\Unit\Services\Application\Handlers\TicketLookup;
use HiEvents\DomainObjects\OrderDomainObject;
-use HiEvents\DomainObjects\Status\OrderStatus;
use HiEvents\Mail\TicketLookup\TicketLookupEmail;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Repository\Interfaces\TicketLookupTokenRepositoryInterface;
@@ -20,11 +19,17 @@
class SendTicketLookupEmailHandlerTest extends TestCase
{
private OrderRepositoryInterface $orderRepository;
+
private TicketLookupTokenRepositoryInterface $ticketLookupTokenRepository;
+
private TokenGeneratorService $tokenGeneratorService;
+
private Mailer $mailer;
+
private LoggerInterface $logger;
+
private DatabaseManager $databaseManager;
+
private SendTicketLookupEmailHandler $handler;
protected function setUp(): void
@@ -48,7 +53,7 @@ protected function setUp(): void
);
}
- public function testHandleSuccessfullySendsEmailWhenOrdersExist(): void
+ public function test_handle_successfully_sends_email_when_orders_exist(): void
{
$email = 'test@example.com';
$dto = new SendTicketLookupEmailDTO(email: $email);
@@ -105,7 +110,7 @@ public function testHandleSuccessfullySendsEmailWhenOrdersExist(): void
$this->assertTrue(true);
}
- public function testHandleDoesNotSendEmailWhenNoOrdersExist(): void
+ public function test_handle_does_not_send_email_when_no_orders_exist(): void
{
$email = 'test@example.com';
$dto = new SendTicketLookupEmailDTO(email: $email);
@@ -113,7 +118,7 @@ public function testHandleDoesNotSendEmailWhenNoOrdersExist(): void
$this->orderRepository
->shouldReceive('findWhere')
->once()
- ->andReturn(new Collection());
+ ->andReturn(new Collection);
$this->logger
->shouldReceive('info')
@@ -134,7 +139,7 @@ public function testHandleDoesNotSendEmailWhenNoOrdersExist(): void
$this->assertTrue(true);
}
- public function testHandleConvertsEmailToLowercase(): void
+ public function test_handle_converts_email_to_lowercase(): void
{
$email = 'TEST@EXAMPLE.COM';
$expectedLowercaseEmail = 'test@example.com';
@@ -143,7 +148,7 @@ public function testHandleConvertsEmailToLowercase(): void
$this->orderRepository
->shouldReceive('findWhere')
->once()
- ->andReturn(new Collection());
+ ->andReturn(new Collection);
$this->logger
->shouldReceive('info')
diff --git a/backend/tests/Unit/Services/Application/Handlers/Waitlist/CreateWaitlistEntryHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Waitlist/CreateWaitlistEntryHandlerTest.php
new file mode 100644
index 0000000000..4be53f8ed9
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/Waitlist/CreateWaitlistEntryHandlerTest.php
@@ -0,0 +1,132 @@
+createWaitlistEntryService = Mockery::mock(CreateWaitlistEntryService::class);
+ $this->eventSettingsRepository = Mockery::mock(EventSettingsRepositoryInterface::class);
+ $this->eventRepository = Mockery::mock(EventRepositoryInterface::class);
+ $this->productPriceRepository = Mockery::mock(ProductPriceRepositoryInterface::class);
+ $this->productRepository = Mockery::mock(ProductRepositoryInterface::class);
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->occurrenceEligibilityService = Mockery::mock(OccurrencePurchaseEligibilityService::class);
+
+ $this->handler = new CreateWaitlistEntryHandler(
+ $this->createWaitlistEntryService,
+ $this->eventSettingsRepository,
+ $this->eventRepository,
+ $this->productPriceRepository,
+ $this->productRepository,
+ $this->occurrenceRepository,
+ $this->occurrenceEligibilityService,
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ public function test_single_event_without_occurrence_id_resolves_to_first_occurrence(): void
+ {
+ $dto = new CreateWaitlistEntryDTO(
+ event_id: 10,
+ product_price_id: 20,
+ email: 'buyer@example.com',
+ first_name: 'Buyer',
+ );
+
+ $event = new EventDomainObject;
+ $event->setType('SINGLE');
+ $this->eventRepository->shouldReceive('findById')->with(10)->andReturn($event);
+
+ $firstOccurrence = (new EventOccurrenceDomainObject)
+ ->setId(42)
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name)
+ ->setStartDate(now()->addDay()->toDateTimeString())
+ ->setEndDate(now()->addDays(2)->toDateTimeString());
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->andReturn(collect([$firstOccurrence]));
+
+ $eventSettings = new EventSettingDomainObject;
+ $eventSettings->setWaitlistEnabled(true);
+ $this->eventSettingsRepository->shouldReceive('findFirstWhere')->andReturn($eventSettings);
+
+ $productPrice = (new ProductPriceDomainObject)->setId(20)->setProductId(30);
+ $this->productPriceRepository->shouldReceive('findById')->with(20)->andReturn($productPrice);
+
+ $product = (new ProductDomainObject)->setId(30);
+ $this->productRepository->shouldReceive('findFirstWhere')->andReturn($product);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->with(['id' => 42, 'event_id' => 10])
+ ->andReturn($firstOccurrence);
+
+ $this->occurrenceEligibilityService
+ ->shouldReceive('assertProductsVisibleOnOccurrence')
+ ->once()
+ ->with(42, [30]);
+
+ $this->createWaitlistEntryService
+ ->shouldReceive('createEntry')
+ ->once()
+ ->with(
+ Mockery::on(fn (CreateWaitlistEntryDTO $rebuilt) => $rebuilt->event_occurrence_id === 42
+ && $rebuilt->event_id === 10
+ && $rebuilt->product_price_id === 20
+ && $rebuilt->email === 'buyer@example.com'),
+ $eventSettings,
+ $product,
+ )
+ ->andReturn(new WaitlistEntryDomainObject);
+
+ $result = $this->handler->handle($dto);
+
+ $this->assertInstanceOf(WaitlistEntryDomainObject::class, $result);
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/CheckInList/CheckInListActivityValidatorTest.php b/backend/tests/Unit/Services/Domain/CheckInList/CheckInListActivityValidatorTest.php
new file mode 100644
index 0000000000..1891bc5b46
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/CheckInList/CheckInListActivityValidatorTest.php
@@ -0,0 +1,75 @@
+validator = new CheckInListActivityValidator;
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ private function checkInList(?string $expiresAt, ?string $activatesAt): CheckInListDomainObject|MockInterface
+ {
+ $list = Mockery::mock(CheckInListDomainObject::class);
+ $list->shouldReceive('getExpiresAt')->andReturn($expiresAt);
+ $list->shouldReceive('getActivatesAt')->andReturn($activatesAt);
+
+ return $list;
+ }
+
+ public function test_passes_when_no_window_set(): void
+ {
+ $this->validator->assertActive($this->checkInList(expiresAt: null, activatesAt: null));
+
+ $this->assertTrue(true);
+ }
+
+ public function test_passes_when_within_window(): void
+ {
+ $this->validator->assertActive($this->checkInList(
+ expiresAt: now()->addDay()->toDateTimeString(),
+ activatesAt: now()->subDay()->toDateTimeString(),
+ ));
+
+ $this->assertTrue(true);
+ }
+
+ public function test_throws_when_expired(): void
+ {
+ $this->expectException(CannotCheckInException::class);
+ $this->expectExceptionMessage('Check-in list has expired');
+
+ $this->validator->assertActive($this->checkInList(
+ expiresAt: now()->subMinute()->toDateTimeString(),
+ activatesAt: null,
+ ));
+ }
+
+ public function test_throws_when_not_yet_active(): void
+ {
+ $this->expectException(CannotCheckInException::class);
+ $this->expectExceptionMessage('Check-in list is not active yet');
+
+ $this->validator->assertActive($this->checkInList(
+ expiresAt: null,
+ activatesAt: now()->addMinute()->toDateTimeString(),
+ ));
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/CheckInList/CheckInListDataServiceTest.php b/backend/tests/Unit/Services/Domain/CheckInList/CheckInListDataServiceTest.php
new file mode 100644
index 0000000000..14b2fce69b
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/CheckInList/CheckInListDataServiceTest.php
@@ -0,0 +1,131 @@
+checkInListRepository = Mockery::mock(CheckInListRepositoryInterface::class);
+ $this->attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class);
+
+ $this->service = new CheckInListDataService(
+ $this->checkInListRepository,
+ $this->attendeeRepository,
+ );
+ }
+
+ public function test_verify_attendee_belongs_to_check_in_list_passes_when_product_matches(): void
+ {
+ $product = Mockery::mock(ProductDomainObject::class);
+ $product->shouldReceive('getId')->andReturn(1);
+
+ $checkInList = Mockery::mock(CheckInListDomainObject::class);
+ $checkInList->shouldReceive('getProducts')->andReturn(new Collection([$product]));
+ $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(null);
+
+ $attendee = Mockery::mock(AttendeeDomainObject::class);
+ $attendee->shouldReceive('getProductId')->andReturn(1);
+
+ $this->service->verifyAttendeeBelongsToCheckInList($checkInList, $attendee);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_verify_passes_across_occurrences_when_list_has_no_occurrence(): void
+ {
+ $product = Mockery::mock(ProductDomainObject::class);
+ $product->shouldReceive('getId')->andReturn(1);
+
+ $checkInList = Mockery::mock(CheckInListDomainObject::class);
+ $checkInList->shouldReceive('getProducts')->andReturn(new Collection([$product]));
+ $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(null);
+
+ $attendee = Mockery::mock(AttendeeDomainObject::class);
+ $attendee->shouldReceive('getProductId')->andReturn(1);
+
+ $this->service->verifyAttendeeBelongsToCheckInList($checkInList, $attendee);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_verify_passes_when_occurrence_matches(): void
+ {
+ $product = Mockery::mock(ProductDomainObject::class);
+ $product->shouldReceive('getId')->andReturn(1);
+
+ $checkInList = Mockery::mock(CheckInListDomainObject::class);
+ $checkInList->shouldReceive('getProducts')->andReturn(new Collection([$product]));
+ $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(5);
+
+ $attendee = Mockery::mock(AttendeeDomainObject::class);
+ $attendee->shouldReceive('getProductId')->andReturn(1);
+ $attendee->shouldReceive('getEventOccurrenceId')->andReturn(5);
+
+ $this->service->verifyAttendeeBelongsToCheckInList($checkInList, $attendee);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_verify_throws_when_occurrence_mismatch(): void
+ {
+ $product = Mockery::mock(ProductDomainObject::class);
+ $product->shouldReceive('getId')->andReturn(1);
+
+ $checkInList = Mockery::mock(CheckInListDomainObject::class);
+ $checkInList->shouldReceive('getProducts')->andReturn(new Collection([$product]));
+ $checkInList->shouldReceive('getEventOccurrenceId')->andReturn(5);
+
+ $attendee = Mockery::mock(AttendeeDomainObject::class);
+ $attendee->shouldReceive('getProductId')->andReturn(1);
+ $attendee->shouldReceive('getEventOccurrenceId')->andReturn(10);
+ $attendee->shouldReceive('getFullName')->andReturn('John Doe');
+
+ $this->expectException(CannotCheckInException::class);
+
+ $this->service->verifyAttendeeBelongsToCheckInList($checkInList, $attendee);
+ }
+
+ public function test_verify_throws_when_product_mismatch(): void
+ {
+ $product = Mockery::mock(ProductDomainObject::class);
+ $product->shouldReceive('getId')->andReturn(1);
+
+ $checkInList = Mockery::mock(CheckInListDomainObject::class);
+ $checkInList->shouldReceive('getProducts')->andReturn(new Collection([$product]));
+
+ $attendee = Mockery::mock(AttendeeDomainObject::class);
+ $attendee->shouldReceive('getProductId')->andReturn(99);
+ $attendee->shouldReceive('getFullName')->andReturn('Jane Doe');
+
+ $this->expectException(CannotCheckInException::class);
+
+ $this->service->verifyAttendeeBelongsToCheckInList($checkInList, $attendee);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/Email/EmailContextHtmlEscaperTest.php b/backend/tests/Unit/Services/Domain/Email/EmailContextHtmlEscaperTest.php
new file mode 100644
index 0000000000..e625b492d4
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Email/EmailContextHtmlEscaperTest.php
@@ -0,0 +1,116 @@
+escaper = new EmailContextHtmlEscaper;
+ }
+
+ public function test_escapes_html_in_scalar_string_values(): void
+ {
+ $escaped = $this->escaper->escape([
+ 'event' => [
+ 'title' => '',
+ ],
+ 'organizer' => [
+ 'name' => 'Bob & Sons ',
+ ],
+ 'order' => [
+ 'first_name' => ' ',
+ 'last_name' => 'Click ',
+ ],
+ ]);
+
+ $this->assertSame('<script>alert(1)</script>', $escaped['event']['title']);
+ $this->assertSame('Bob & <b>Sons</b>', $escaped['organizer']['name']);
+ $this->assertSame('<img src=x onerror=alert(1)>', $escaped['order']['first_name']);
+ $this->assertSame('<a href="https://phish.example">Click</a>', $escaped['order']['last_name']);
+ }
+
+ public function test_escapes_nested_location_values(): void
+ {
+ $escaped = $this->escaper->escape([
+ 'event' => [
+ 'full_address' => 'Venue , Main St',
+ 'location_details' => [
+ 'venue_name' => '',
+ 'address_line_1' => '1 Main St ',
+ ],
+ ],
+ 'event_location' => [
+ 'name' => ' ',
+ 'structured_address' => [
+ 'city' => 'Dublin & Co',
+ ],
+ ],
+ ]);
+
+ $this->assertSame('<b>Venue</b>, Main St', $escaped['event']['full_address']);
+ $this->assertSame('<script>alert(1)</script>', $escaped['event']['location_details']['venue_name']);
+ $this->assertSame('1 Main <i>St</i>', $escaped['event']['location_details']['address_line_1']);
+ $this->assertSame('<img src=x>', $escaped['event_location']['name']);
+ $this->assertSame('Dublin & Co', $escaped['event_location']['structured_address']['city']);
+ }
+
+ public function test_preserves_purified_html_token_values(): void
+ {
+ $escaped = $this->escaper->escape([
+ 'event' => [
+ 'description' => 'An amazing event
',
+ ],
+ 'settings' => [
+ 'post_checkout_message' => 'Thank you! ',
+ 'offline_payment_instructions' => 'Transfer to IBAN
',
+ ],
+ 'event_location' => [
+ 'online_connection_details' => 'Zoom: example.zoom.us/j/123
',
+ ],
+ ]);
+
+ $this->assertSame('An amazing event
', $escaped['event']['description']);
+ $this->assertSame('Thank you! ', $escaped['settings']['post_checkout_message']);
+ $this->assertSame('Transfer to IBAN
', $escaped['settings']['offline_payment_instructions']);
+ $this->assertSame('Zoom: example.zoom.us/j/123
', $escaped['event_location']['online_connection_details']);
+ }
+
+ public function test_preserves_non_string_values(): void
+ {
+ $escaped = $this->escaper->escape([
+ 'order' => [
+ 'is_awaiting_offline_payment' => false,
+ ],
+ 'event_location' => [
+ 'latitude' => 53.3478,
+ 'structured_address' => null,
+ ],
+ 'cancellation' => [
+ 'refund_issued' => true,
+ ],
+ ]);
+
+ $this->assertFalse($escaped['order']['is_awaiting_offline_payment']);
+ $this->assertSame(53.3478, $escaped['event_location']['latitude']);
+ $this->assertNull($escaped['event_location']['structured_address']);
+ $this->assertTrue($escaped['cancellation']['refund_issued']);
+ }
+
+ public function test_only_escapes_purified_paths_at_their_exact_position(): void
+ {
+ $escaped = $this->escaper->escape([
+ 'attendee' => [
+ 'description' => 'not the event description ',
+ ],
+ ]);
+
+ $this->assertSame('<b>not the event description</b>', $escaped['attendee']['description']);
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/Email/EmailTemplateServiceTest.php b/backend/tests/Unit/Services/Domain/Email/EmailTemplateServiceTest.php
index 821e17574c..5300a8e732 100644
--- a/backend/tests/Unit/Services/Domain/Email/EmailTemplateServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Email/EmailTemplateServiceTest.php
@@ -5,17 +5,21 @@
use HiEvents\DomainObjects\EmailTemplateDomainObject;
use HiEvents\DomainObjects\Enums\EmailTemplateType;
use HiEvents\Repository\Interfaces\EmailTemplateRepositoryInterface;
+use HiEvents\Services\Domain\Email\EmailContextHtmlEscaper;
use HiEvents\Services\Domain\Email\EmailTemplateService;
use HiEvents\Services\Domain\Email\EmailTokenContextBuilder;
use HiEvents\Services\Infrastructure\Email\LiquidTemplateRenderer;
-use Tests\TestCase;
use Mockery;
+use Tests\TestCase;
class EmailTemplateServiceTest extends TestCase
{
private EmailTemplateService $emailTemplateService;
+
private EmailTemplateRepositoryInterface $mockRepository;
+
private LiquidTemplateRenderer $mockLiquidRenderer;
+
private EmailTokenContextBuilder $mockTokenBuilder;
protected function setUp(): void
@@ -29,8 +33,59 @@ protected function setUp(): void
$this->emailTemplateService = new EmailTemplateService(
$this->mockRepository,
$this->mockLiquidRenderer,
- $this->mockTokenBuilder
+ $this->mockTokenBuilder,
+ new EmailContextHtmlEscaper,
+ );
+ }
+
+ public function test_render_template_escapes_user_content_in_body_but_not_subject(): void
+ {
+ $service = new EmailTemplateService(
+ $this->mockRepository,
+ new LiquidTemplateRenderer,
+ $this->mockTokenBuilder,
+ new EmailContextHtmlEscaper,
+ );
+
+ $template = Mockery::mock(EmailTemplateDomainObject::class, [
+ 'getSubject' => 'Order for {{ event.title }}',
+ 'getBody' => 'Hi {{ order.first_name }}, welcome to {{ event.title }}',
+ 'getCta' => null,
+ ]);
+
+ $result = $service->renderTemplate($template, [
+ 'event' => ['title' => 'Rock & Roll Night '],
+ 'order' => ['first_name' => ' '],
+ ]);
+
+ $this->assertSame(
+ 'Hi <img src=x onerror=alert(1)>, welcome to Rock & Roll <b>Night</b>',
+ $result->body
+ );
+ $this->assertSame('Order for Rock & Roll Night ', $result->subject);
+ }
+
+ public function test_render_template_keeps_purified_html_tokens_unescaped_in_body(): void
+ {
+ $service = new EmailTemplateService(
+ $this->mockRepository,
+ new LiquidTemplateRenderer,
+ $this->mockTokenBuilder,
+ new EmailContextHtmlEscaper,
);
+
+ $template = Mockery::mock(EmailTemplateDomainObject::class, [
+ 'getSubject' => 'Your order',
+ 'getBody' => '{{ settings.post_checkout_message }} {{ event.description }}',
+ 'getCta' => null,
+ ]);
+
+ $result = $service->renderTemplate($template, [
+ 'settings' => ['post_checkout_message' => 'Thanks! '],
+ 'event' => ['description' => 'Details
'],
+ ]);
+
+ $this->assertSame('Thanks! Details
', $result->body);
}
public function test_gets_event_level_template_when_exists(): void
@@ -48,18 +103,18 @@ public function test_gets_event_level_template_when_exists(): void
->shouldReceive('findByTypeWithFallback')
->with(
EmailTemplateType::ORDER_CONFIRMATION,
- 1, // accountId
- 1, // eventId
- 1 // organizerId
+ 1,
+ 1,
+ 1
)
->once()
->andReturn($eventTemplate);
$result = $this->emailTemplateService->getTemplateByType(
EmailTemplateType::ORDER_CONFIRMATION,
- 1, // accountId
- 1, // eventId
- 1 // organizerId
+ 1,
+ 1,
+ 1
);
$this->assertSame($eventTemplate, $result);
@@ -80,18 +135,18 @@ public function test_falls_back_to_organizer_template_when_no_event_template():
->shouldReceive('findByTypeWithFallback')
->with(
EmailTemplateType::ORDER_CONFIRMATION,
- 1, // accountId
- 1, // eventId
- 1 // organizerId
+ 1,
+ 1,
+ 1
)
->once()
->andReturn($organizerTemplate);
$result = $this->emailTemplateService->getTemplateByType(
EmailTemplateType::ORDER_CONFIRMATION,
- 1, // accountId
- 1, // eventId
- 1 // organizerId
+ 1,
+ 1,
+ 1
);
$this->assertSame($organizerTemplate, $result);
@@ -103,18 +158,18 @@ public function test_returns_null_when_no_templates_exist(): void
->shouldReceive('findByTypeWithFallback')
->with(
EmailTemplateType::ORDER_CONFIRMATION,
- 1, // accountId
- 1, // eventId
- 1 // organizerId
+ 1,
+ 1,
+ 1
)
->once()
->andReturn(null);
$result = $this->emailTemplateService->getTemplateByType(
EmailTemplateType::ORDER_CONFIRMATION,
- 1, // accountId
- 1, // eventId
- 1 // organizerId
+ 1,
+ 1,
+ 1
);
$this->assertNull($result);
@@ -135,18 +190,18 @@ public function test_gets_organizer_level_template_when_no_event_id(): void
->shouldReceive('findByTypeWithFallback')
->with(
EmailTemplateType::ATTENDEE_TICKET,
- 1, // accountId
- null, // eventId
- 1 // organizerId
+ 1,
+ null,
+ 1
)
->once()
->andReturn($organizerTemplate);
$result = $this->emailTemplateService->getTemplateByType(
EmailTemplateType::ATTENDEE_TICKET,
- 1, // accountId
- null, // eventId
- 1 // organizerId
+ 1,
+ null,
+ 1
);
$this->assertSame($organizerTemplate, $result);
@@ -167,18 +222,18 @@ public function test_prefers_active_templates_over_inactive(): void
->shouldReceive('findByTypeWithFallback')
->with(
EmailTemplateType::ORDER_CONFIRMATION,
- 1, // accountId
- 1, // eventId
- 1 // organizerId
+ 1,
+ 1,
+ 1
)
->once()
->andReturn($activeTemplate);
$result = $this->emailTemplateService->getTemplateByType(
EmailTemplateType::ORDER_CONFIRMATION,
- 1, // accountId
- 1, // eventId
- 1 // organizerId
+ 1,
+ 1,
+ 1
);
$this->assertSame($activeTemplate, $result);
@@ -200,18 +255,18 @@ public function test_handles_different_template_types(): void
->shouldReceive('findByTypeWithFallback')
->with(
EmailTemplateType::ATTENDEE_TICKET,
- 1, // accountId
- null, // eventId
- 1 // organizerId
+ 1,
+ null,
+ 1
)
->once()
->andReturn($attendeeTicketTemplate);
$result = $this->emailTemplateService->getTemplateByType(
EmailTemplateType::ATTENDEE_TICKET,
- 1, // accountId
- null, // eventId
- 1 // organizerId
+ 1,
+ null,
+ 1
);
$this->assertSame($attendeeTicketTemplate, $result);
diff --git a/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php b/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php
index 1ff9ced8a2..1e0b8eb918 100644
--- a/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php
+++ b/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php
@@ -3,12 +3,16 @@
namespace Tests\Unit\Services\Domain\Email;
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\LocationDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
-use HiEvents\DomainObjects\Enums\PaymentProviders;
use HiEvents\Services\Domain\Email\EmailTokenContextBuilder;
use Illuminate\Support\Collection;
use Mockery;
@@ -24,6 +28,12 @@ protected function setUp(): void
$this->contextBuilder = app(EmailTokenContextBuilder::class);
}
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
public function test_builds_order_confirmation_context(): void
{
$order = $this->createMockOrder();
@@ -44,22 +54,18 @@ public function test_builds_order_confirmation_context(): void
$this->assertArrayHasKey('organizer', $context);
$this->assertArrayHasKey('settings', $context);
- // Test order context
$this->assertEquals('ORD-123456', $context['order']['number']);
- $this->assertEquals('$9,999.00', $context['order']['total']); // Updated expected format
+ $this->assertEquals('$9,999.00', $context['order']['total']);
$this->assertEquals('John', $context['order']['first_name']);
$this->assertEquals('Doe', $context['order']['last_name']);
$this->assertEquals('john@example.com', $context['order']['email']);
- // Test event context
$this->assertEquals('Amazing Event', $context['event']['title']);
$this->assertEquals('This is an amazing event', $context['event']['description']);
- // Test organizer context
$this->assertEquals('Great Organizer', $context['organizer']['name']);
$this->assertEquals('contact@organizer.com', $context['organizer']['email']);
- // Test settings context
$this->assertEquals('support@event.com', $context['settings']['support_email']);
}
@@ -86,29 +92,23 @@ public function test_builds_attendee_ticket_context(): void
$this->assertArrayHasKey('event', $context);
$this->assertArrayHasKey('organizer', $context);
- // Test attendee context
$this->assertEquals('Jane Smith', $context['attendee']['name']);
$this->assertEquals('jane@example.com', $context['attendee']['email']);
- // Test ticket context
$this->assertEquals('General Admission', $context['ticket']['name']);
- $this->assertEquals('$4,999.00', $context['ticket']['price']); // Updated expected format
+ $this->assertEquals('$4,999.00', $context['ticket']['price']);
- // Test event context
$this->assertEquals('Amazing Event', $context['event']['title']);
-
- // Test organizer context
+
$this->assertEquals('Great Organizer', $context['organizer']['name']);
}
- public function test_offline_payment_instructions_tokens_are_rendered_into_context(): void
+ public function test_whitelists_only_allowed_tokens_for_order_confirmation(): void
{
$order = $this->createMockOrder();
$event = $this->createMockEvent();
$organizer = $this->createMockOrganizer();
- $eventSettings = $this->createMockEventSettings(
- offlinePaymentInstructions: 'Use {{ order.number }} for {{ event.title }}
',
- );
+ $eventSettings = $this->createMockEventSettings();
$context = $this->contextBuilder->buildOrderConfirmationContext(
$order,
@@ -117,40 +117,52 @@ public function test_offline_payment_instructions_tokens_are_rendered_into_conte
$eventSettings
);
- $this->assertSame(
- 'Use ORD-123456 for Amazing Event
',
- $context['settings']['offline_payment_instructions'],
- );
+ $this->assertArrayHasKey('order', $context);
+ $this->assertArrayHasKey('event', $context);
+ $this->assertArrayHasKey('organizer', $context);
+ $this->assertArrayHasKey('settings', $context);
+
+ $this->assertArrayHasKey('number', $context['order']);
+ $this->assertArrayHasKey('first_name', $context['order']);
+ $this->assertArrayHasKey('title', $context['event']);
}
- public function test_rendered_offline_payment_instructions_are_purified(): void
+ public function test_whitelists_only_allowed_tokens_for_attendee_ticket(): void
{
- $order = $this->createMockOrder(firstName: 'John');
+ $attendee = $this->createMockAttendee();
+ $order = $this->createMockOrder();
$event = $this->createMockEvent();
$organizer = $this->createMockOrganizer();
- $eventSettings = $this->createMockEventSettings(
- offlinePaymentInstructions: 'Reference {{ order.first_name }}
',
- );
+ $eventSettings = $this->createMockEventSettings();
- $context = $this->contextBuilder->buildOrderConfirmationContext(
+ $context = $this->contextBuilder->buildAttendeeTicketContext(
+ $attendee,
$order,
$event,
$organizer,
$eventSettings
);
- $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
+ {
+ $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
+ {
+ $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..95ca5a256c
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/EventOccurrence/CancelOccurrenceAttendeesServiceTest.php
@@ -0,0 +1,351 @@
+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);
+
+ $this->logger->shouldReceive('error')->zeroOrMoreTimes()->byDefault();
+
+ $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->orderRepository
+ ->shouldReceive('findWhereIn')
+ ->with('id', [1000])
+ ->andReturn(new Collection([$this->makeOrder(id: 1000)]));
+
+ $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]],
+ ],
+ );
+
+ $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)));
+
+ $result = $this->service->cancelForOccurrence($eventId, $occurrenceId);
+
+ $this->assertSame([101, 102, 103], $result['attendee_ids']);
+ $this->assertSame(3, $result['sales_backed_count']);
+
+ 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');
+
+ $result = $this->service->cancelForOccurrence(1, 10);
+
+ $this->assertSame(['attendee_ids' => [], 'sales_backed_count' => 0], $result);
+
+ Event::assertNotDispatched(CapacityChangedEvent::class);
+ }
+
+ public function test_also_cancels_awaiting_payment_attendees(): void
+ {
+ $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->orderRepository
+ ->shouldReceive('findWhereIn')
+ ->with('id', [1000])
+ ->andReturn(new Collection([$this->makeOrder(id: 1000, status: OrderStatus::AWAITING_OFFLINE_PAYMENT->name)]));
+
+ $this->attendeeRepository->shouldReceive('updateWhere')->once();
+ $this->productQuantityService->shouldReceive('decreaseQuantitySold')->once();
+ $this->domainEventDispatcherService->shouldReceive('dispatch')->once();
+ $this->statisticsCancellationService->shouldNotReceive('decrementForCancelledAttendee');
+
+ $this->service->cancelForOccurrence(1, 10);
+
+ Event::assertDispatched(CapacityChangedEvent::class);
+ }
+
+ public function test_does_not_decrement_inventory_for_reserved_order_attendees(): void
+ {
+ $attendee = $this->makeAttendee(id: 501, productId: 9, productPriceId: 90, orderId: 7000);
+
+ $this->attendeeRepository
+ ->shouldReceive('findWhere')
+ ->andReturn(new Collection([$attendee]));
+
+ $this->orderRepository
+ ->shouldReceive('findWhereIn')
+ ->with('id', [7000])
+ ->andReturn(new Collection([$this->makeOrder(id: 7000, status: OrderStatus::RESERVED->name)]));
+
+ $this->attendeeRepository->shouldReceive('updateWhere')->once();
+ $this->domainEventDispatcherService->shouldReceive('dispatch')->once();
+
+ $this->productQuantityService->shouldNotReceive('decreaseQuantitySold');
+ $this->statisticsCancellationService->shouldNotReceive('decrementForCancelledAttendee');
+
+ $result = $this->service->cancelForOccurrence(1, 10);
+
+ $this->assertSame([501], $result['attendee_ids']);
+ $this->assertSame(0, $result['sales_backed_count']);
+
+ Event::assertNotDispatched(CapacityChangedEvent::class);
+ }
+
+ public function test_sales_backed_count_only_counts_inventory_backed_attendees(): void
+ {
+ $completedAttendee = $this->makeAttendee(id: 601, productId: 3, productPriceId: 30, orderId: 8000);
+ $reservedAttendee = $this->makeAttendee(id: 602, productId: 3, productPriceId: 30, orderId: 8001);
+
+ $this->attendeeRepository
+ ->shouldReceive('findWhere')
+ ->andReturn(new Collection([$completedAttendee, $reservedAttendee]));
+
+ $this->orderRepository
+ ->shouldReceive('findWhereIn')
+ ->with('id', Mockery::on(function ($ids) {
+ if (! is_array($ids)) {
+ return false;
+ }
+ sort($ids);
+
+ return $ids === [8000, 8001];
+ }))
+ ->andReturn(new Collection([
+ $this->makeOrder(id: 8000, status: OrderStatus::COMPLETED->name),
+ $this->makeOrder(id: 8001, status: OrderStatus::RESERVED->name),
+ ]));
+
+ $this->attendeeRepository->shouldReceive('updateWhere')->once();
+ $this->productQuantityService->shouldReceive('decreaseQuantitySold')->once()->with(30, 1, 10);
+ $this->domainEventDispatcherService->shouldReceive('dispatch')->twice();
+
+ $result = $this->service->cancelForOccurrence(1, 10);
+
+ $this->assertSame([601, 602], $result['attendee_ids']);
+ $this->assertSame(1, $result['sales_backed_count']);
+ }
+
+ public function test_decrements_attendee_statistics_grouped_by_source_order(): void
+ {
+ $eventId = 5;
+ $occurrenceId = 50;
+
+ $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');
+
+ $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]));
+
+ $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);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_logs_and_continues_when_statistics_decrement_throws(): void
+ {
+ $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);
+
+ $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', ?string $status = null): MockInterface
+ {
+ $order = Mockery::mock(OrderDomainObject::class);
+ $order->shouldReceive('getId')->andReturn($id);
+ $order->shouldReceive('getCreatedAt')->andReturn($createdAt);
+ $order->shouldReceive('getStatus')->andReturn($status ?? OrderStatus::COMPLETED->name);
+
+ 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..ab205a6ba5
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityServiceTest.php
@@ -0,0 +1,385 @@
+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->occurrenceRepository
+ ->shouldReceive('countWhere')
+ ->byDefault()
+ ->andReturn(1);
+
+ $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
+ {
+ $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
+ {
+ $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::ACTIVE->name, capacity: 10, usedCapacity: 10));
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('sold out');
+
+ $this->service->assertOccurrencePurchasable(eventId: 1, occurrenceId: 10);
+ }
+
+ public function test_rejects_when_capacity_exceeded(): void
+ {
+ $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_zero_additional_quantity_skips_capacity_check(): void
+ {
+ $occurrence = $this->occurrence(EventOccurrenceStatus::ACTIVE->name, capacity: 10, usedCapacity: 4);
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->andReturn($occurrence);
+ $this->orderItemRepository->shouldNotReceive('getReservedQuantityForOccurrence');
+
+ $result = $this->service->assertOccurrencePurchasable(
+ eventId: 1,
+ occurrenceId: 10,
+ additionalQuantity: 0,
+ );
+
+ $this->assertSame($occurrence, $result);
+ }
+
+ public function test_zero_additional_quantity_skips_sold_out_gate(): void
+ {
+ $occurrence = $this->occurrence(EventOccurrenceStatus::ACTIVE->name, capacity: 10, usedCapacity: 10);
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->andReturn($occurrence);
+ $this->orderItemRepository->shouldNotReceive('getReservedQuantityForOccurrence');
+
+ $result = $this->service->assertOccurrencePurchasable(
+ eventId: 1,
+ occurrenceId: 10,
+ additionalQuantity: 0,
+ );
+
+ $this->assertSame($occurrence, $result);
+ }
+
+ public function test_zero_additional_quantity_still_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, additionalQuantity: 0);
+ }
+
+ public function test_zero_additional_quantity_still_rejects_past_occurrence(): void
+ {
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn($this->occurrence(EventOccurrenceStatus::ACTIVE->name, startDate: '2020-01-01 10:00:00'));
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('already ended');
+
+ $this->service->assertOccurrencePurchasable(eventId: 1, occurrenceId: 10, additionalQuantity: 0);
+ }
+
+ public function test_uses_preloaded_occurrence_and_reserved_quantity_without_queries(): void
+ {
+ $occurrence = $this->occurrence(EventOccurrenceStatus::ACTIVE->name, capacity: 10, usedCapacity: 4);
+ $this->occurrenceRepository->shouldNotReceive('findFirstWhere');
+ $this->orderItemRepository->shouldNotReceive('getReservedQuantityForOccurrence');
+
+ $result = $this->service->assertOccurrencePurchasable(
+ eventId: 1,
+ occurrenceId: 10,
+ additionalQuantity: 3,
+ occurrence: $occurrence,
+ reservedQuantity: 3,
+ );
+
+ $this->assertSame($occurrence, $result);
+ }
+
+ public function test_preloaded_reserved_quantity_counts_against_capacity(): void
+ {
+ $occurrence = $this->occurrence(EventOccurrenceStatus::ACTIVE->name, capacity: 10, usedCapacity: 4);
+ $this->occurrenceRepository->shouldNotReceive('findFirstWhere');
+ $this->orderItemRepository->shouldNotReceive('getReservedQuantityForOccurrence');
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('capacity');
+
+ $this->service->assertOccurrencePurchasable(
+ eventId: 1,
+ occurrenceId: 10,
+ additionalQuantity: 4,
+ occurrence: $occurrence,
+ reservedQuantity: 3,
+ );
+ }
+
+ public function test_preloaded_occurrence_from_other_event_is_rejected(): void
+ {
+ $occurrence = $this->occurrence(EventOccurrenceStatus::ACTIVE->name)->setEventId(2);
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('not found');
+
+ $this->service->assertOccurrencePurchasable(
+ eventId: 1,
+ occurrenceId: 10,
+ occurrence: $occurrence,
+ );
+ }
+
+ 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
+ {
+ $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
+ {
+ $occurrence = $this->occurrence(EventOccurrenceStatus::ACTIVE->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
+ {
+ $occurrence = $this->occurrence(EventOccurrenceStatus::ACTIVE->name, capacity: 1, usedCapacity: 5);
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->andReturn($occurrence);
+
+ $this->orderItemRepository->shouldNotReceive('getReservedQuantityForOccurrence');
+
+ $result = $this->service->assertOccurrencePurchasable(
+ eventId: 1,
+ occurrenceId: 10,
+ additionalQuantity: 50,
+ overrideCapacity: true,
+ );
+
+ $this->assertSame($occurrence, $result);
+ }
+
+ public function test_uses_single_occurrence_wording_for_single_occurrence_event(): void
+ {
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn($this->occurrence(EventOccurrenceStatus::CANCELLED->name));
+ $this->occurrenceRepository
+ ->shouldReceive('countWhere')
+ ->andReturn(1);
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('This event has been cancelled');
+
+ $this->service->assertOccurrencePurchasable(eventId: 1, occurrenceId: 10);
+ }
+
+ public function test_uses_occurrence_wording_for_multi_occurrence_event(): void
+ {
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn($this->occurrence(EventOccurrenceStatus::CANCELLED->name));
+ $this->occurrenceRepository
+ ->shouldReceive('countWhere')
+ ->andReturn(2);
+
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('This event occurrence has been cancelled');
+
+ $this->service->assertOccurrencePurchasable(eventId: 1, occurrenceId: 10);
+ }
+
+ public function test_product_visibility_allows_all_when_no_rules_exist(): void
+ {
+ $this->visibilityRepository
+ ->shouldReceive('findWhereIn')
+ ->andReturn(collect());
+
+ $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');
+
+ $this->service->assertProductsVisibleOnOccurrence(occurrenceId: 10, productIds: [1, 99]);
+ }
+
+ public function test_product_visibility_no_op_for_empty_product_list(): void
+ {
+ $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/EventOccurrence/PublicOccurrenceVisibilityServiceTest.php b/backend/tests/Unit/Services/Domain/EventOccurrence/PublicOccurrenceVisibilityServiceTest.php
new file mode 100644
index 0000000000..2149a995c7
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/EventOccurrence/PublicOccurrenceVisibilityServiceTest.php
@@ -0,0 +1,99 @@
+service = new PublicOccurrenceVisibilityService;
+ }
+
+ public function test_should_hide_sold_out_occurrences_for_recurring_event_with_setting_enabled(): void
+ {
+ $event = (new EventDomainObject)
+ ->setType(EventType::RECURRING->name)
+ ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(true))
+ ->setProductCategories(collect());
+
+ $this->assertTrue($this->service->shouldHideSoldOutOccurrences($event));
+ }
+
+ public function test_should_not_hide_sold_out_occurrences_for_single_event(): void
+ {
+ $event = (new EventDomainObject)
+ ->setType(EventType::SINGLE->name)
+ ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(true))
+ ->setProductCategories(collect());
+
+ $this->assertFalse($this->service->shouldHideSoldOutOccurrences($event));
+ }
+
+ public function test_should_not_hide_sold_out_occurrences_when_setting_disabled(): void
+ {
+ $event = (new EventDomainObject)
+ ->setType(EventType::RECURRING->name)
+ ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(false))
+ ->setProductCategories(collect());
+
+ $this->assertFalse($this->service->shouldHideSoldOutOccurrences($event));
+ }
+
+ public function test_should_not_hide_sold_out_occurrences_when_waitlist_product_exists(): void
+ {
+ $category = new ProductCategoryDomainObject;
+ $category->setProducts(collect([
+ (new ProductDomainObject)->setWaitlistEnabled(true),
+ ]));
+
+ $event = (new EventDomainObject)
+ ->setType(EventType::RECURRING->name)
+ ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(true))
+ ->setProductCategories(collect([$category]));
+
+ $this->assertFalse($this->service->shouldHideSoldOutOccurrences($event));
+ }
+
+ public function test_build_where_conditions_for_recurring_event_hiding_sold_out(): void
+ {
+ $where = $this->service->buildWhereConditions(
+ eventId: 5,
+ isRecurring: true,
+ hideSoldOutOccurrences: true,
+ );
+
+ $this->assertSame(5, $where[EventOccurrenceDomainObjectAbstract::EVENT_ID]);
+ $this->assertContains(
+ [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name],
+ $where,
+ );
+ $this->assertCount(2, array_filter($where, static fn ($condition) => $condition instanceof Closure));
+ }
+
+ public function test_build_where_conditions_for_single_event_without_hiding(): void
+ {
+ $where = $this->service->buildWhereConditions(
+ eventId: 5,
+ isRecurring: false,
+ hideSoldOutOccurrences: false,
+ );
+
+ $this->assertSame(5, $where[EventOccurrenceDomainObjectAbstract::EVENT_ID]);
+ $this->assertCount(0, array_filter($where, static fn ($condition) => $condition instanceof Closure));
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsCancellationServiceTest.php b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsCancellationServiceTest.php
index 042b44af75..4cadb349ae 100644
--- a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsCancellationServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsCancellationServiceTest.php
@@ -8,10 +8,15 @@
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\DomainObjects\Status\AttendeeStatus;
+use HiEvents\Repository\Interfaces\AffiliateRepositoryInterface;
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\Repository\Interfaces\ProductRepositoryInterface;
+use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface;
use HiEvents\Services\Domain\EventStatistics\EventStatisticsCancellationService;
use HiEvents\Services\Infrastructure\Utlitiy\Retry\Retrier;
use Illuminate\Database\DatabaseManager;
@@ -24,54 +29,83 @@
class EventStatisticsCancellationServiceTest extends TestCase
{
private EventStatisticsCancellationService $service;
+
private MockInterface|EventStatisticRepositoryInterface $eventStatisticsRepository;
+
private MockInterface|EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository;
+
private MockInterface|AttendeeRepositoryInterface $attendeeRepository;
+
private MockInterface|OrderRepositoryInterface $orderRepository;
+
private MockInterface|DatabaseManager $databaseManager;
+
private MockInterface|LoggerInterface $logger;
+
private MockInterface|Retrier $retrier;
+ private MockInterface|PromoCodeRepositoryInterface $promoCodeRepository;
+
+ private MockInterface|ProductRepositoryInterface $productRepository;
+
+ private MockInterface|AffiliateRepositoryInterface $affiliateRepository;
+
protected function setUp(): void
{
parent::setUp();
$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);
$this->logger = Mockery::mock(LoggerInterface::class);
$this->retrier = Mockery::mock(Retrier::class);
+ $this->promoCodeRepository = Mockery::mock(PromoCodeRepositoryInterface::class);
+ $this->productRepository = Mockery::mock(ProductRepositoryInterface::class);
+ $this->affiliateRepository = Mockery::mock(AffiliateRepositoryInterface::class);
$this->service = new EventStatisticsCancellationService(
$this->eventStatisticsRepository,
$this->eventDailyStatisticRepository,
+ $eventOccurrenceStatisticRepository,
+ $eventOccurrenceDailyStatisticRepository,
$this->attendeeRepository,
$this->orderRepository,
$this->logger,
$this->databaseManager,
- $this->retrier
+ $this->retrier,
+ $this->promoCodeRepository,
+ $this->productRepository,
+ $this->affiliateRepository,
);
}
- public function testDecrementForCancelledOrderSuccess(): void
+ public function test_decrement_for_cancelled_order_success(): void
{
$eventId = 1;
$orderId = 123;
$orderDate = '2024-01-15 10:30:00';
- // Create mock order items
$ticketOrderItem1 = Mockery::mock(OrderItemDomainObject::class);
$ticketOrderItem1->shouldReceive('getQuantity')->andReturn(2);
+ $ticketOrderItem1->shouldReceive('getEventOccurrenceId')->andReturnNull();
+ $ticketOrderItem1->shouldReceive('getProductId')->andReturn(7);
+ $ticketOrderItem1->shouldReceive('getTotalBeforeAdditions')->andReturn(20.0);
$ticketOrderItem2 = Mockery::mock(OrderItemDomainObject::class);
$ticketOrderItem2->shouldReceive('getQuantity')->andReturn(1);
+ $ticketOrderItem2->shouldReceive('getEventOccurrenceId')->andReturnNull();
+ $ticketOrderItem2->shouldReceive('getProductId')->andReturn(8);
+ $ticketOrderItem2->shouldReceive('getTotalBeforeAdditions')->andReturn(10.0);
$orderItems = new Collection([$ticketOrderItem1, $ticketOrderItem2]);
$ticketOrderItems = new Collection([$ticketOrderItem1, $ticketOrderItem2]);
- // Create mock order
$order = Mockery::mock(OrderDomainObject::class);
$order->shouldReceive('getEventId')->andReturn($eventId);
$order->shouldReceive('getId')->andReturn($orderId);
@@ -79,8 +113,18 @@ public function testDecrementForCancelledOrderSuccess(): void
$order->shouldReceive('getOrderItems')->andReturn($orderItems);
$order->shouldReceive('getTicketOrderItems')->andReturn($ticketOrderItems);
$order->shouldReceive('getStatisticsDecrementedAt')->andReturnNull();
+ $order->shouldReceive('isOrderCompleted')->andReturnTrue();
+ $order->shouldReceive('getPromoCodeId')->andReturn(55);
+ $order->shouldReceive('getAffiliateId')->andReturn(99);
+ $order->shouldReceive('getTotalGross')->andReturn(100.0);
+
+ $this->promoCodeRepository
+ ->shouldReceive('decrementEach')
+ ->once()
+ ->with(['id' => 55], ['order_usage_count' => 1, 'attendee_usage_count' => 3]);
+ $this->productRepository->shouldReceive('decrement')->twice();
+ $this->affiliateRepository->shouldReceive('decrementSales')->once()->with(99, 100.0);
- // Mock order repository to return order with relations
$this->orderRepository
->shouldReceive('loadRelation')
->with(OrderItemDomainObject::class)
@@ -91,7 +135,6 @@ public function testDecrementForCancelledOrderSuccess(): void
->with($orderId)
->andReturn($order);
- // Mock aggregate event statistics
$eventStatistics = Mockery::mock(EventStatisticDomainObject::class);
$eventStatistics->shouldReceive('getId')->andReturn(1);
$eventStatistics->shouldReceive('getAttendeesRegistered')->andReturn(10);
@@ -100,7 +143,6 @@ public function testDecrementForCancelledOrderSuccess(): void
$eventStatistics->shouldReceive('getOrdersCancelled')->andReturn(2);
$eventStatistics->shouldReceive('getVersion')->andReturn(5);
- // Mock daily event statistics
$eventDailyStatistic = Mockery::mock(EventDailyStatisticDomainObject::class);
$eventDailyStatistic->shouldReceive('getAttendeesRegistered')->andReturn(8);
$eventDailyStatistic->shouldReceive('getProductsSold')->andReturn(12);
@@ -108,7 +150,6 @@ public function testDecrementForCancelledOrderSuccess(): void
$eventDailyStatistic->shouldReceive('getOrdersCancelled')->andReturn(1);
$eventDailyStatistic->shouldReceive('getVersion')->andReturn(3);
- // Mock attendee repository to return 2 active attendees (1 was already cancelled)
$activeAttendee1 = Mockery::mock(AttendeeDomainObject::class);
$activeAttendee2 = Mockery::mock(AttendeeDomainObject::class);
$this->attendeeRepository
@@ -120,37 +161,32 @@ public function testDecrementForCancelledOrderSuccess(): void
)
->andReturn(new Collection([$activeAttendee1, $activeAttendee2]));
- // Set up retrier to execute the action immediately
$this->retrier
->shouldReceive('retry')
->andReturnUsing(function ($callableAction) {
return $callableAction(1);
});
- // Set up database transaction
$this->databaseManager
->shouldReceive('transaction')
->andReturnUsing(function ($callback) {
return $callback();
});
- // Expect finding aggregate statistics
$this->eventStatisticsRepository
->shouldReceive('findFirstWhere')
->with(['event_id' => $eventId])
->andReturn($eventStatistics);
- // Expect updating aggregate statistics with decremented values
- // Note: We use full order quantities for products_sold since products don't get "uncancelled"
$this->eventStatisticsRepository
->shouldReceive('updateWhere')
->with(
[
- 'attendees_registered' => 8, // 10 - 2 (2 active attendees)
- 'products_sold' => 12, // 15 - 3 (full order quantities)
- 'orders_created' => 4, // 5 - 1
- 'orders_cancelled' => 3, // 2 + 1
- 'version' => 6, // 5 + 1
+ 'attendees_registered' => 8,
+ 'products_sold' => 12,
+ 'orders_created' => 4,
+ 'orders_cancelled' => 3,
+ 'version' => 6,
],
[
'id' => 1,
@@ -159,7 +195,6 @@ public function testDecrementForCancelledOrderSuccess(): void
)
->andReturn(1);
- // Expect finding daily statistics
$this->eventDailyStatisticRepository
->shouldReceive('findFirstWhere')
->with([
@@ -168,17 +203,15 @@ public function testDecrementForCancelledOrderSuccess(): void
])
->andReturn($eventDailyStatistic);
- // Expect updating daily statistics with decremented values
- // Note: We use full order quantities for products_sold since products don't get "uncancelled"
$this->eventDailyStatisticRepository
->shouldReceive('updateWhere')
->with(
[
- 'attendees_registered' => 6, // 8 - 2 (2 active attendees)
- 'products_sold' => 9, // 12 - 3 (full order quantities)
- 'orders_created' => 3, // 4 - 1
- 'orders_cancelled' => 2, // 1 + 1
- 'version' => 4, // 3 + 1
+ 'attendees_registered' => 6,
+ 'products_sold' => 9,
+ 'orders_created' => 3,
+ 'orders_cancelled' => 2,
+ 'version' => 4,
],
[
'event_id' => $eventId,
@@ -188,7 +221,6 @@ public function testDecrementForCancelledOrderSuccess(): void
)
->andReturn(1);
- // Expect marking statistics as decremented
$this->orderRepository
->shouldReceive('updateFromArray')
->with($orderId, Mockery::on(function ($data) {
@@ -196,28 +228,61 @@ public function testDecrementForCancelledOrderSuccess(): void
}))
->once();
- // Expect logging
$this->logger->shouldReceive('info')->atLeast()->once();
- // Execute
$this->service->decrementForCancelledOrder($order);
$this->assertTrue(true);
}
- public function testSkipsDecrementWhenAlreadyDecremented(): void
+ public function test_never_completed_order_touches_no_statistics_but_is_marked_decremented(): void
+ {
+ $eventId = 1;
+ $orderId = 456;
+
+ $order = Mockery::mock(OrderDomainObject::class);
+ $order->shouldReceive('getEventId')->andReturn($eventId);
+ $order->shouldReceive('getId')->andReturn($orderId);
+ $order->shouldReceive('getStatisticsDecrementedAt')->andReturnNull();
+ $order->shouldReceive('isOrderCompleted')->andReturnFalse();
+
+ $this->orderRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->orderRepository->shouldReceive('findById')->with($orderId)->andReturn($order);
+
+ $this->eventStatisticsRepository->shouldNotReceive('findFirstWhere');
+ $this->eventStatisticsRepository->shouldNotReceive('updateWhere');
+ $this->eventDailyStatisticRepository->shouldNotReceive('findFirstWhere');
+ $this->eventDailyStatisticRepository->shouldNotReceive('updateWhere');
+ $this->attendeeRepository->shouldNotReceive('findWhereIn');
+ $this->promoCodeRepository->shouldNotReceive('decrementEach');
+ $this->productRepository->shouldNotReceive('decrement');
+ $this->affiliateRepository->shouldNotReceive('decrementSales');
+ $this->retrier->shouldNotReceive('retry');
+
+ $this->orderRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with($orderId, Mockery::on(function ($data) {
+ return array_key_exists('statistics_decremented_at', $data) && $data['statistics_decremented_at'] !== null;
+ }));
+ $this->logger->shouldReceive('info')->atLeast()->once();
+
+ $this->service->decrementForCancelledOrder($order);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_skips_decrement_when_already_decremented(): void
{
$orderId = 123;
$eventId = 1;
$decrementedAt = '2024-01-15 09:00:00';
- // Create mock order with statistics already decremented
$order = Mockery::mock(OrderDomainObject::class);
$order->shouldReceive('getId')->andReturn($orderId);
$order->shouldReceive('getEventId')->andReturn($eventId);
$order->shouldReceive('getStatisticsDecrementedAt')->andReturn($decrementedAt);
- // Mock order repository
$this->orderRepository
->shouldReceive('loadRelation')
->with(OrderItemDomainObject::class)
@@ -228,7 +293,6 @@ public function testSkipsDecrementWhenAlreadyDecremented(): void
->with($orderId)
->andReturn($order);
- // Expect logging that statistics were already decremented
$this->logger
->shouldReceive('info')
->with(
@@ -241,64 +305,55 @@ public function testSkipsDecrementWhenAlreadyDecremented(): void
)
->once();
- // Should not call any update methods
$this->eventStatisticsRepository->shouldNotReceive('updateWhere');
$this->eventDailyStatisticRepository->shouldNotReceive('updateWhere');
$this->orderRepository->shouldNotReceive('updateFromArray');
- // Execute
$this->service->decrementForCancelledOrder($order);
$this->assertTrue(true);
}
- public function testDecrementForCancelledAttendee(): void
+ public function test_decrement_for_cancelled_attendee(): void
{
$eventId = 1;
$orderDate = '2024-01-15 10:30:00';
$attendeeCount = 2;
- // Mock aggregate event statistics
$eventStatistics = Mockery::mock(EventStatisticDomainObject::class);
$eventStatistics->shouldReceive('getId')->andReturn(1);
$eventStatistics->shouldReceive('getAttendeesRegistered')->andReturn(10);
$eventStatistics->shouldReceive('getProductsSold')->andReturn(15);
$eventStatistics->shouldReceive('getVersion')->andReturn(5);
- // Mock daily event statistics
$eventDailyStatistic = Mockery::mock(EventDailyStatisticDomainObject::class);
$eventDailyStatistic->shouldReceive('getAttendeesRegistered')->andReturn(8);
$eventDailyStatistic->shouldReceive('getProductsSold')->andReturn(12);
$eventDailyStatistic->shouldReceive('getVersion')->andReturn(3);
- // Set up retrier to execute the action immediately
$this->retrier
->shouldReceive('retry')
->andReturnUsing(function ($callableAction) {
return $callableAction(1);
});
- // Set up database transaction
$this->databaseManager
->shouldReceive('transaction')
->andReturnUsing(function ($callback) {
return $callback();
});
- // Expect finding aggregate statistics
$this->eventStatisticsRepository
->shouldReceive('findFirstWhere')
->with(['event_id' => $eventId])
->andReturn($eventStatistics);
- // Expect updating aggregate statistics with decremented values
- // Note: products_sold should NOT be affected by individual attendee cancellations
$this->eventStatisticsRepository
->shouldReceive('updateWhere')
->with(
[
- 'attendees_registered' => 8, // 10 - 2
- 'version' => 6, // 5 + 1
+ 'attendees_registered' => 8,
+ 'version' => 6,
],
[
'id' => 1,
@@ -307,7 +362,6 @@ public function testDecrementForCancelledAttendee(): void
)
->andReturn(1);
- // Expect finding daily statistics
$this->eventDailyStatisticRepository
->shouldReceive('findFirstWhere')
->with([
@@ -316,14 +370,12 @@ public function testDecrementForCancelledAttendee(): void
])
->andReturn($eventDailyStatistic);
- // Expect updating daily statistics with decremented values
- // Note: products_sold should NOT be affected by individual attendee cancellations
$this->eventDailyStatisticRepository
->shouldReceive('updateWhere')
->with(
[
- 'attendees_registered' => 6, // 8 - 2
- 'version' => 4, // 3 + 1
+ 'attendees_registered' => 6,
+ 'version' => 4,
],
[
'event_id' => $eventId,
@@ -333,10 +385,8 @@ public function testDecrementForCancelledAttendee(): void
)
->andReturn(1);
- // Expect logging
- $this->logger->shouldReceive('info')->twice(); // One for aggregate, one for daily
+ $this->logger->shouldReceive('info')->twice();
- // Execute
$this->service->decrementForCancelledAttendee($eventId, $orderDate, $attendeeCount);
$this->assertTrue(true);
diff --git a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsIncrementServiceTest.php b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsIncrementServiceTest.php
index 107a1257e9..67a12e1257 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;
@@ -25,13 +27,21 @@
class EventStatisticsIncrementServiceTest extends TestCase
{
private EventStatisticsIncrementService $service;
+
private MockInterface|PromoCodeRepositoryInterface $promoCodeRepository;
+
private MockInterface|ProductRepositoryInterface $productRepository;
+
private MockInterface|EventStatisticRepositoryInterface $eventStatisticsRepository;
+
private MockInterface|EventDailyStatisticRepositoryInterface $eventDailyStatisticRepository;
+
private MockInterface|DatabaseManager $databaseManager;
+
private MockInterface|OrderRepositoryInterface $orderRepository;
+
private MockInterface|LoggerInterface $logger;
+
private MockInterface|Retrier $retrier;
protected function setUp(): void
@@ -42,6 +52,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 +64,8 @@ protected function setUp(): void
$this->productRepository,
$this->eventStatisticsRepository,
$this->eventDailyStatisticRepository,
+ $eventOccurrenceStatisticRepository,
+ $eventOccurrenceDailyStatisticRepository,
$this->databaseManager,
$this->orderRepository,
$this->logger,
@@ -59,7 +73,7 @@ protected function setUp(): void
);
}
- public function testIncrementForOrderWithExistingStatistics(): void
+ public function test_increment_for_order_with_existing_statistics(): void
{
$eventId = 1;
$orderId = 123;
@@ -71,11 +85,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]);
@@ -200,13 +216,15 @@ public function testIncrementForOrderWithExistingStatistics(): void
// Expect incrementing promo code usage
$this->promoCodeRepository
- ->shouldReceive('increment')
- ->with($promoCodeId, PromoCodeDomainObjectAbstract::ORDER_USAGE_COUNT)
- ->once();
-
- $this->promoCodeRepository
- ->shouldReceive('increment')
- ->with($promoCodeId, PromoCodeDomainObjectAbstract::ATTENDEE_USAGE_COUNT, 3)
+ ->shouldReceive('incrementEach')
+ ->with(
+ [
+ PromoCodeDomainObjectAbstract::ORDER_USAGE_COUNT => 1,
+ PromoCodeDomainObjectAbstract::ATTENDEE_USAGE_COUNT => 3,
+ ],
+ [],
+ ['id' => $promoCodeId],
+ )
->once();
// Expect incrementing product statistics
@@ -226,11 +244,10 @@ public function testIncrementForOrderWithExistingStatistics(): void
// Execute
$this->service->incrementForOrder($order);
-
$this->assertTrue(true);
}
- public function testIncrementForOrderCreatesNewStatistics(): void
+ public function test_increment_for_order_creates_new_statistics(): void
{
$eventId = 1;
$orderId = 123;
@@ -241,6 +258,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]);
@@ -341,7 +359,6 @@ public function testIncrementForOrderCreatesNewStatistics(): void
// Execute
$this->service->incrementForOrder($order);
-
$this->assertTrue(true);
}
diff --git a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsRefundServiceTest.php b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsRefundServiceTest.php
index 5e41a74713..5e15459401 100644
--- a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsRefundServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsRefundServiceTest.php
@@ -3,12 +3,20 @@
namespace Tests\Unit\Services\Domain\EventStatistics;
use HiEvents\DomainObjects\EventDailyStatisticDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDailyStatisticDomainObject;
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\Carbon;
+use Illuminate\Support\Collection;
use Mockery;
use Mockery\MockInterface;
use Psr\Log\LoggerInterface;
@@ -17,34 +25,73 @@
class EventStatisticsRefundServiceTest extends TestCase
{
+ private const REFUND_DATE = '2026-07-20';
+
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
{
parent::setUp();
+ Carbon::setTestNow(self::REFUND_DATE.' 12:00:00');
+
$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);
+ $this->stubOrderReload(totalGross: 0.0, items: []);
+
$this->service = new EventStatisticsRefundService(
$this->eventStatisticsRepository,
$this->eventDailyStatisticRepository,
+ $this->eventOccurrenceStatisticRepository,
+ $this->eventOccurrenceDailyStatisticRepository,
+ $this->orderRepository,
$this->logger
);
}
- public function testUpdateForRefundFullAmount(): void
+ /**
+ * @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 test_update_for_refund_full_amount(): void
{
$eventId = 1;
$orderId = 123;
$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,87 +101,79 @@ 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([
'event_id' => $eventId,
- 'date' => '2024-01-15',
+ 'date' => self::REFUND_DATE,
])
->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,
- 'date' => '2024-01-15',
+ 'date' => self::REFUND_DATE,
]
)
->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);
}
- public function testUpdateForRefundPartialAmount(): void
+ public function test_update_for_refund_partial_amount(): void
{
$eventId = 1;
$orderId = 123;
$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,119 +183,103 @@ 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([
'event_id' => $eventId,
- 'date' => '2024-01-15',
+ 'date' => self::REFUND_DATE,
])
->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,
- 'date' => '2024-01-15',
+ 'date' => self::REFUND_DATE,
]
)
->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);
}
- public function testThrowsExceptionWhenAggregateStatisticsNotFound(): void
+ public function test_throws_exception_when_aggregate_statistics_not_found(): void
{
$eventId = 1;
$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
+ public function test_creates_daily_statistics_row_when_none_exists_for_refund_date(): void
{
$eventId = 1;
$orderId = 123;
$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,64 +289,312 @@ 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([
'event_id' => $eventId,
- 'date' => '2024-01-15',
+ 'date' => self::REFUND_DATE,
])
->andReturnNull();
- // Expect warning log for missing daily statistics
- $this->logger
- ->shouldReceive('warning')
- ->with(
- 'Event daily statistics not found for refund',
- [
- 'event_id' => $eventId,
- 'date' => '2024-01-15',
- 'order_id' => $orderId,
- ]
- )
+ $this->eventDailyStatisticRepository
+ ->shouldReceive('create')
+ ->with([
+ 'event_id' => $eventId,
+ 'date' => self::REFUND_DATE,
+ 'products_sold' => 0,
+ 'attendees_registered' => 0,
+ 'sales_total_gross' => -50.00,
+ 'sales_total_before_additions' => 0,
+ 'total_tax' => 0,
+ 'total_fee' => 0,
+ 'total_refunded' => 50.00,
+ 'orders_created' => 0,
+ 'orders_cancelled' => 0,
+ ])
->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);
+ }
+
+ public function test_update_for_refund_updates_occurrence_stats_for_order_with_items(): 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);
+
+ $item = $this->makeOrderItemMock(
+ occurrenceId: 50,
+ totalGross: 100.00,
+ totalTax: 8.00,
+ totalServiceFee: 2.00,
+ );
+
+ $reloaded = Mockery::mock(OrderDomainObject::class);
+ $reloaded->shouldReceive('getOrderItems')->andReturn(new Collection([$item]));
+ $reloaded->shouldReceive('getTotalGross')->andReturn(100.00);
+ $reloaded->shouldReceive('getCreatedAt')->andReturn($orderDate);
+ $reloaded->shouldReceive('getEventId')->andReturn($eventId);
+
+ $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' => self::REFUND_DATE]
+ );
+
+ $this->logger->shouldReceive('info')->twice();
+
+ $this->service->updateForRefund($order, $refundAmount);
$this->assertTrue(true);
}
+ public function test_update_for_refund_splits_refund_across_multiple_occurrences(): 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);
+
+ $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);
+ $reloaded->shouldReceive('getEventId')->andReturn($eventId);
+
+ $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::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' => self::REFUND_DATE]);
+
+ $this->eventOccurrenceDailyStatisticRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(Mockery::any(), ['event_occurrence_id' => 200, 'date' => self::REFUND_DATE]);
+
+ $this->logger->shouldReceive('info')->twice();
+
+ $this->service->updateForRefund($order, $refundAmount);
+
+ $this->assertTrue(true);
+ }
+
+ public function test_update_for_refund_skips_occurrence_path_when_no_items_have_occurrence_id(): 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);
+ }
+
+ 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();
+
+ $this->eventOccurrenceDailyStatisticRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn(Mockery::mock(EventOccurrenceDailyStatisticDomainObject::class));
+ }
+
+ 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
{
+ Carbon::setTestNow();
Mockery::close();
parent::tearDown();
}
diff --git a/backend/tests/Unit/Services/Domain/Image/ImageUploadServiceTest.php b/backend/tests/Unit/Services/Domain/Image/ImageUploadServiceTest.php
index 9f8f4328b4..ea647e6c6c 100644
--- a/backend/tests/Unit/Services/Domain/Image/ImageUploadServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Image/ImageUploadServiceTest.php
@@ -17,8 +17,11 @@
class ImageUploadServiceTest extends TestCase
{
private ImageStorageService $imageStorageService;
+
private ImageRepositoryInterface $imageRepository;
+
private ImageMetadataService $imageMetadataService;
+
private ImageUploadService $service;
protected function setUp(): void
@@ -36,7 +39,7 @@ protected function setUp(): void
);
}
- public function testUploadSuccessfullyCreatesImageRecordWithMetadata(): void
+ public function test_upload_successfully_creates_image_record_with_metadata(): void
{
$uploadedFile = m::mock(UploadedFile::class);
$storedImage = new ImageStorageResponseDTO(
@@ -92,7 +95,7 @@ public function testUploadSuccessfullyCreatesImageRecordWithMetadata(): void
$this->assertSame($imageDomainObject, $result);
}
- public function testUploadSuccessfullyCreatesImageRecordWithoutMetadata(): void
+ public function test_upload_successfully_creates_image_record_without_metadata(): void
{
$uploadedFile = m::mock(UploadedFile::class);
$storedImage = new ImageStorageResponseDTO(
@@ -138,7 +141,7 @@ public function testUploadSuccessfullyCreatesImageRecordWithoutMetadata(): void
$this->assertSame($imageDomainObject, $result);
}
- public function testUploadThrowsExceptionIfStorageFails(): void
+ public function test_upload_throws_exception_if_storage_fails(): void
{
$this->expectException(CouldNotUploadImageException::class);
diff --git a/backend/tests/Unit/Services/Domain/Invoice/InvoiceCreateServiceTest.php b/backend/tests/Unit/Services/Domain/Invoice/InvoiceCreateServiceTest.php
new file mode 100644
index 0000000000..a4dcb06051
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Invoice/InvoiceCreateServiceTest.php
@@ -0,0 +1,43 @@
+setId(7);
+
+ $invoiceRepository = Mockery::mock(InvoiceRepositoryInterface::class);
+ $invoiceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['order_id' => 123])
+ ->andReturn($existingInvoice);
+
+ $invoiceRepository->shouldNotReceive('create');
+
+ $orderRepository = Mockery::mock(OrderRepositoryInterface::class);
+ $orderRepository->shouldNotReceive('findById');
+
+ $service = new InvoiceCreateService($orderRepository, $invoiceRepository);
+
+ $result = $service->createInvoiceForOrder(123);
+
+ $this->assertSame(7, $result->getId());
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/Location/LocationDataSanitizerTest.php b/backend/tests/Unit/Services/Domain/Location/LocationDataSanitizerTest.php
new file mode 100644
index 0000000000..dac800c5d1
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Location/LocationDataSanitizerTest.php
@@ -0,0 +1,94 @@
+geoProvider = Mockery::mock(GeoProviderInterface::class);
+ $this->sanitizer = new LocationDataSanitizer($this->geoProvider);
+ }
+
+ public function test_sanitize_text_preserves_ampersands_and_special_characters(): void
+ {
+ $this->assertSame('Barnes & Noble', $this->sanitizer->sanitizeText('Barnes & Noble'));
+ $this->assertSame("O'Reilly's \"Pub\"", $this->sanitizer->sanitizeText("O'Reilly's \"Pub\""));
+ }
+
+ public function test_sanitize_text_preserves_angle_brackets_and_markup(): void
+ {
+ $this->assertSame('I <3 NY', $this->sanitizer->sanitizeText('I <3 NY'));
+ $this->assertSame('Bar sanitizer->sanitizeText('Bar assertSame('x ', $this->sanitizer->sanitizeText('x '));
+ $this->assertSame('Cafe', $this->sanitizer->sanitizeText('Cafe'));
+ }
+
+ public function test_sanitize_text_replaces_control_characters_with_single_space(): void
+ {
+ $this->assertSame('Ve nue', $this->sanitizer->sanitizeText("Ve\x00nue\x1F"));
+ $this->assertSame('Line1 Line2', $this->sanitizer->sanitizeText("Line1\nLine2"));
+ $this->assertSame('Line1 Line2', $this->sanitizer->sanitizeText("Line1\r\n\tLine2"));
+ $this->assertSame('Line1 Line2', $this->sanitizer->sanitizeText("Line1 \n Line2"));
+ }
+
+ public function test_sanitize_text_returns_null_for_null(): void
+ {
+ $this->assertNull($this->sanitizer->sanitizeText(null));
+ }
+
+ public function test_sanitize_address_sanitizes_strings_and_uppercases_country(): void
+ {
+ $result = $this->sanitizer->sanitizeAddress([
+ 'venue_name' => 'Tom & Jerry',
+ 'city' => 'Dublin',
+ 'country' => 'ie',
+ ]);
+
+ $this->assertSame('Tom & Jerry', $result['venue_name']);
+ $this->assertSame('Dublin', $result['city']);
+ $this->assertSame('IE', $result['country']);
+ }
+
+ public function test_sanitize_address_without_country_key(): void
+ {
+ $this->assertSame(['city' => 'Dublin'], $this->sanitizer->sanitizeAddress(['city' => 'Dublin']));
+ }
+
+ public function test_cached_raw_provider_response_requires_provider_and_place_id(): void
+ {
+ $this->geoProvider->shouldNotReceive('getCachedRawPlaceDetails');
+
+ $this->assertNull($this->sanitizer->cachedRawProviderResponse(null, 'place_1'));
+ $this->assertNull($this->sanitizer->cachedRawProviderResponse('google', null));
+ }
+
+ public function test_cached_raw_provider_response_delegates_to_provider_cache(): void
+ {
+ $this->geoProvider
+ ->shouldReceive('getCachedRawPlaceDetails')
+ ->once()
+ ->with('place_1')
+ ->andReturn(['id' => 'place_1']);
+
+ $this->assertSame(['id' => 'place_1'], $this->sanitizer->cachedRawProviderResponse('google', 'place_1'));
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/Location/LocationLockServiceTest.php b/backend/tests/Unit/Services/Domain/Location/LocationLockServiceTest.php
new file mode 100644
index 0000000000..384d69deee
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Location/LocationLockServiceTest.php
@@ -0,0 +1,55 @@
+shouldReceive('statement')
+ ->once()
+ ->with('SELECT pg_advisory_xact_lock_shared(?, ?)', [LocationLockService::LOCATIONS_LOCK_KEYSPACE, 42]);
+
+ (new LocationLockService($databaseManager))->acquireSharedTransactionLock(42);
+ }
+
+ public function test_exclusive_lock_uses_exclusive_advisory_lock_in_locations_keyspace(): void
+ {
+ $databaseManager = Mockery::mock(DatabaseManager::class);
+ $databaseManager
+ ->shouldReceive('statement')
+ ->once()
+ ->with('SELECT pg_advisory_xact_lock(?, ?)', [LocationLockService::LOCATIONS_LOCK_KEYSPACE, 42]);
+
+ (new LocationLockService($databaseManager))->acquireExclusiveTransactionLock(42);
+ }
+
+ public function test_lock_key_stays_within_int4_range_for_bigint_ids(): void
+ {
+ $bigintId = 2147483647 + 5;
+
+ $databaseManager = Mockery::mock(DatabaseManager::class);
+ $databaseManager
+ ->shouldReceive('statement')
+ ->once()
+ ->with('SELECT pg_advisory_xact_lock(?, ?)', [LocationLockService::LOCATIONS_LOCK_KEYSPACE, 5]);
+ $databaseManager
+ ->shouldReceive('statement')
+ ->once()
+ ->with('SELECT pg_advisory_xact_lock_shared(?, ?)', [LocationLockService::LOCATIONS_LOCK_KEYSPACE, 5]);
+
+ $service = new LocationLockService($databaseManager);
+ $service->acquireExclusiveTransactionLock($bigintId);
+ $service->acquireSharedTransactionLock($bigintId);
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/Message/MessageDispatchServiceTest.php b/backend/tests/Unit/Services/Domain/Message/MessageDispatchServiceTest.php
new file mode 100644
index 0000000000..7576815ff9
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Message/MessageDispatchServiceTest.php
@@ -0,0 +1,148 @@
+messageRepository = Mockery::mock(MessageRepositoryInterface::class);
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+
+ $this->service = new MessageDispatchService(
+ $this->messageRepository,
+ $this->occurrenceRepository,
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ public function test_message_for_cancelled_occurrence_is_cancelled_and_not_dispatched(): void
+ {
+ $message = $this->makeMessage(occurrenceId: 5);
+
+ $occurrence = (new EventOccurrenceDomainObject)->setStatus(EventOccurrenceStatus::CANCELLED->name);
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['id' => 5])
+ ->andReturn($occurrence);
+
+ $this->messageRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(1, ['status' => MessageStatus::CANCELLED->name]);
+
+ $this->messageRepository->shouldNotReceive('updateWhere');
+
+ $this->service->dispatchMessage($message);
+
+ Bus::assertNotDispatched(SendMessagesJob::class);
+ }
+
+ public function test_message_for_deleted_occurrence_is_cancelled_and_not_dispatched(): void
+ {
+ $message = $this->makeMessage(occurrenceId: 6);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['id' => 6])
+ ->andReturnNull();
+
+ $this->messageRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(1, ['status' => MessageStatus::CANCELLED->name]);
+
+ $this->service->dispatchMessage($message);
+
+ Bus::assertNotDispatched(SendMessagesJob::class);
+ }
+
+ public function test_message_for_active_occurrence_is_dispatched(): void
+ {
+ $message = $this->makeMessage(occurrenceId: 7);
+
+ $occurrence = (new EventOccurrenceDomainObject)->setStatus(EventOccurrenceStatus::ACTIVE->name);
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['id' => 7])
+ ->andReturn($occurrence);
+
+ $this->messageRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ ['status' => MessageStatus::PROCESSING->name],
+ ['id' => 1, 'status' => MessageStatus::SCHEDULED->name],
+ )
+ ->andReturn(1);
+
+ $this->service->dispatchMessage($message);
+
+ Bus::assertDispatched(SendMessagesJob::class);
+ }
+
+ public function test_message_without_occurrence_scope_is_dispatched(): void
+ {
+ $message = $this->makeMessage(occurrenceId: null);
+
+ $this->occurrenceRepository->shouldNotReceive('findFirstWhere');
+
+ $this->messageRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->andReturn(1);
+
+ $this->service->dispatchMessage($message);
+
+ Bus::assertDispatched(SendMessagesJob::class);
+ }
+
+ private function makeMessage(?int $occurrenceId): MessageDomainObject
+ {
+ $message = new MessageDomainObject;
+ $message->setId(1);
+ $message->setEventId(10);
+ $message->setSubject('Subject');
+ $message->setMessage('Body');
+ $message->setType('ALL_ATTENDEES');
+ $message->setSentByUserId(1);
+ $message->setEventOccurrenceId($occurrenceId);
+ $message->setSendData([
+ 'account_id' => 99,
+ 'send_copy_to_current_user' => false,
+ ]);
+
+ return $message;
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/Order/OccurrenceStatusValidatorTest.php b/backend/tests/Unit/Services/Domain/Order/OccurrenceStatusValidatorTest.php
new file mode 100644
index 0000000000..c1d9ac6bf7
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Order/OccurrenceStatusValidatorTest.php
@@ -0,0 +1,122 @@
+occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->validator = new OccurrenceStatusValidator($this->occurrenceRepository);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ private function orderWithOccurrenceIds(array $occurrenceIds): OrderDomainObject
+ {
+ $items = array_map(function (?int $id) {
+ $item = Mockery::mock(OrderItemDomainObject::class);
+ $item->shouldReceive('getEventOccurrenceId')->andReturn($id);
+
+ return $item;
+ }, $occurrenceIds);
+
+ $order = Mockery::mock(OrderDomainObject::class);
+ $order->shouldReceive('getOrderItems')->andReturn(new Collection($items));
+
+ return $order;
+ }
+
+ private function occurrence(bool $cancelled, bool $past): EventOccurrenceDomainObject|MockInterface
+ {
+ $occurrence = Mockery::mock(EventOccurrenceDomainObject::class);
+ $occurrence->shouldReceive('isCancelled')->andReturn($cancelled);
+ $occurrence->shouldReceive('isPast')->andReturn($past);
+
+ return $occurrence;
+ }
+
+ public function test_assert_passes_when_all_occurrences_are_active(): void
+ {
+ $this->occurrenceRepository->shouldReceive('findWhereIn')
+ ->with('id', [1])
+ ->andReturn(new Collection([$this->occurrence(cancelled: false, past: false)]));
+
+ $this->validator->assertOrderOccurrencesArePurchasable($this->orderWithOccurrenceIds([1]));
+
+ $this->assertTrue(true);
+ }
+
+ public function test_assert_skips_lookup_when_no_occurrence_ids(): void
+ {
+ $this->occurrenceRepository->shouldNotReceive('findWhereIn');
+
+ $this->validator->assertOrderOccurrencesArePurchasable($this->orderWithOccurrenceIds([null, null]));
+
+ $this->assertTrue(true);
+ }
+
+ public function test_assert_throws_when_occurrence_is_cancelled(): void
+ {
+ $this->occurrenceRepository->shouldReceive('findWhereIn')
+ ->andReturn(new Collection([$this->occurrence(cancelled: true, past: false)]));
+
+ $this->expectException(ResourceConflictException::class);
+ $this->expectExceptionMessage('This event date has been cancelled');
+
+ $this->validator->assertOrderOccurrencesArePurchasable($this->orderWithOccurrenceIds([1]));
+ }
+
+ public function test_assert_throws_when_occurrence_is_past(): void
+ {
+ $this->occurrenceRepository->shouldReceive('findWhereIn')
+ ->andReturn(new Collection([$this->occurrence(cancelled: false, past: true)]));
+
+ $this->expectException(ResourceConflictException::class);
+ $this->expectExceptionMessage('This event date has already ended');
+
+ $this->validator->assertOrderOccurrencesArePurchasable($this->orderWithOccurrenceIds([1]));
+ }
+
+ public function test_find_blocking_returns_cancelled_occurrence(): void
+ {
+ $blocking = $this->occurrence(cancelled: true, past: false);
+
+ $this->occurrenceRepository->shouldReceive('findWhereIn')
+ ->andReturn(new Collection([
+ $this->occurrence(cancelled: false, past: false),
+ $blocking,
+ ]));
+
+ $this->assertSame($blocking, $this->validator->findBlockingOccurrence($this->orderWithOccurrenceIds([1, 2])));
+ }
+
+ public function test_find_blocking_returns_null_when_all_active(): void
+ {
+ $this->occurrenceRepository->shouldReceive('findWhereIn')
+ ->andReturn(new Collection([$this->occurrence(cancelled: false, past: false)]));
+
+ $this->assertNull($this->validator->findBlockingOccurrence($this->orderWithOccurrenceIds([1])));
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/Order/OfflinePaymentInstructionsRenderServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OfflinePaymentInstructionsRenderServiceTest.php
index 9d6296dee8..d6d0576a41 100644
--- a/backend/tests/Unit/Services/Domain/Order/OfflinePaymentInstructionsRenderServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Order/OfflinePaymentInstructionsRenderServiceTest.php
@@ -43,7 +43,7 @@ public function test_rendered_instructions_are_purified(): void
public function test_order_without_event_is_left_untouched(): void
{
- $order = (new OrderDomainObject())->setId(30);
+ $order = (new OrderDomainObject)->setId(30);
$this->service()->renderForOrder($order);
@@ -62,7 +62,7 @@ public function test_empty_instructions_are_left_untouched(): void
public function test_render_failure_leaves_original_instructions(): void
{
$order = $this->makeOrder('Use {{ order.number }}
');
- $order->getEvent()->setStartDate('not-a-valid-date');
+ $order->setCreatedAt('not-a-valid-date');
$this->service()->renderForOrder($order);
@@ -79,32 +79,30 @@ private function service(): OfflinePaymentInstructionsRenderService
private function makeOrder(
?string $offlinePaymentInstructions,
- string $orderFirstName = 'Jane',
- ): OrderDomainObject
- {
- $organizer = (new OrganizerDomainObject())
+ string $orderFirstName = 'Jane',
+ ): OrderDomainObject {
+ $organizer = (new OrganizerDomainObject)
->setId(1)
->setName('Example Organizer')
->setEmail('organizer@example.com');
- $settings = (new EventSettingDomainObject())
+ $settings = (new EventSettingDomainObject)
->setId(20)
->setEventId(10)
->setPaymentProviders([PaymentProviders::OFFLINE->value])
->setSupportEmail('support@example.com')
->setOfflinePaymentInstructions($offlinePaymentInstructions);
- $event = (new EventDomainObject())
+ $event = (new EventDomainObject)
->setId(10)
->setTitle('Summer Session')
->setDescription('An evening event')
- ->setStartDate('2026-08-15 18:00:00')
->setCurrency('GBP')
->setTimezone('UTC')
->setOrganizer($organizer)
->setEventSettings($settings);
- return (new OrderDomainObject())
+ return (new OrderDomainObject)
->setId(30)
->setEventId(10)
->setShortId('order-short-id')
diff --git a/backend/tests/Unit/Services/Domain/Order/OrderApplicationFeeCalculationServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderApplicationFeeCalculationServiceTest.php
index 2566caebbb..19b317ee95 100644
--- a/backend/tests/Unit/Services/Domain/Order/OrderApplicationFeeCalculationServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Order/OrderApplicationFeeCalculationServiceTest.php
@@ -2,9 +2,9 @@
namespace Tests\Unit\Services\Domain\Order;
-use HiEvents\DomainObjects\AccountConfigurationDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
+use HiEvents\DomainObjects\OrganizerConfigurationDomainObject;
use HiEvents\Services\Domain\Order\OrderApplicationFeeCalculationService;
use HiEvents\Services\Domain\Order\Vat\VatRateDeterminationService;
use HiEvents\Services\Infrastructure\CurrencyConversion\CurrencyConversionClientInterface;
@@ -15,8 +15,11 @@
class OrderApplicationFeeCalculationServiceTest extends TestCase
{
private Repository $config;
+
private CurrencyConversionClientInterface $currencyConversionClient;
+
private OrderApplicationFeeCalculationService $service;
+
private VatRateDeterminationService $vatRateDeterminationService;
protected function setUp(): void
@@ -33,12 +36,12 @@ protected function setUp(): void
private function createOrderWithItems(array $items, string $currency = 'USD'): OrderDomainObject
{
- $order = (new OrderDomainObject())
+ $order = (new OrderDomainObject)
->setCurrency($currency)
->setOrderItems(collect($items));
// Calculate gross manually for test accuracy
- $total = collect($items)->reduce(fn($carry, $item) => $carry + ($item->getPrice() * $item->getQuantity()), 0);
+ $total = collect($items)->reduce(fn ($carry, $item) => $carry + ($item->getPrice() * $item->getQuantity()), 0);
$order->setTotalGross($total);
return $order;
@@ -49,12 +52,13 @@ private function createItem(float $price, int $quantity): OrderItemDomainObject
$item = $this->createMock(OrderItemDomainObject::class);
$item->method('getPrice')->willReturn($price);
$item->method('getQuantity')->willReturn($quantity);
+
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();
@@ -66,7 +70,7 @@ private function createAccountConfig(float $fixedFee = 0, float $percentageFee =
return $config;
}
- public function testNoFeeWhenSaasModeDisabled(): void
+ public function test_no_fee_when_saas_mode_disabled(): void
{
$this->config->method('get')->with('app.saas_mode_enabled')->willReturn(false);
@@ -78,7 +82,7 @@ public function testNoFeeWhenSaasModeDisabled(): void
$this->assertNull($fee);
}
- public function testNoFeeForFreeOrder(): void
+ public function test_no_fee_for_free_order(): void
{
$this->config->method('get')->willReturn(true);
@@ -90,7 +94,7 @@ public function testNoFeeForFreeOrder(): void
$this->assertEquals(0.0, $fee->grossApplicationFee->toFloat());
}
- public function testFixedAndPercentageFeeSameCurrency(): void
+ public function test_fixed_and_percentage_fee_same_currency(): void
{
$this->config->method('get')->willReturn(true);
@@ -110,7 +114,7 @@ public function testFixedAndPercentageFeeSameCurrency(): void
$this->assertEquals(6.50, $fee->grossApplicationFee->toFloat());
}
- public function testCurrencyConversionForFixedFee(): void
+ public function test_currency_conversion_for_fixed_fee(): void
{
$this->config->method('get')->willReturn(true);
@@ -132,7 +136,7 @@ public function testCurrencyConversionForFixedFee(): void
$this->assertEquals(5.00, $fee->grossApplicationFee->toFloat());
}
- public function testNoConversionWhenOrderCurrencyMatchesFeeCurrency(): void
+ public function test_no_conversion_when_order_currency_matches_fee_currency(): void
{
$this->config->method('get')->willReturn(true);
@@ -153,7 +157,7 @@ public function testNoConversionWhenOrderCurrencyMatchesFeeCurrency(): void
$this->assertEquals(12.00, $fee->grossApplicationFee->toFloat());
}
- public function testConversionFromEurToUsd(): void
+ public function test_conversion_from_eur_to_usd(): void
{
$this->config->method('get')->willReturn(true);
diff --git a/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php
index 61a44df095..1a3d64308a 100644
--- a/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Order/OrderCancelServiceTest.php
@@ -3,9 +3,12 @@
namespace Tests\Unit\Services\Domain\Order;
use HiEvents\DomainObjects\AttendeeDomainObject;
+use HiEvents\DomainObjects\Enums\CapacityChangeDirection;
+use HiEvents\DomainObjects\Enums\ProductType;
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
+use HiEvents\DomainObjects\OrderItemDomainObject;
use HiEvents\DomainObjects\OrganizerDomainObject;
use HiEvents\DomainObjects\Status\AttendeeStatus;
use HiEvents\Events\CapacityChangedEvent;
@@ -13,13 +16,15 @@
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
+use HiEvents\Services\Domain\EventStatistics\EventStatisticsCancellationService;
use HiEvents\Services\Domain\Order\OrderCancelService;
use HiEvents\Services\Domain\Product\ProductQuantityUpdateService;
+use HiEvents\Services\Domain\Waitlist\RevertWaitlistOffersForCancelledOrderService;
use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService;
use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType;
use HiEvents\Services\Infrastructure\DomainEvents\Events\OrderEvent;
-use HiEvents\Services\Domain\EventStatistics\EventStatisticsCancellationService;
use Illuminate\Contracts\Mail\Mailer;
+use Illuminate\Database\Connection;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event;
@@ -30,15 +35,27 @@
class OrderCancelServiceTest extends TestCase
{
private Mailer $mailer;
+
private AttendeeRepositoryInterface $attendeeRepository;
+
private EventRepositoryInterface $eventRepository;
+
private OrderRepositoryInterface $orderRepository;
+
private DatabaseManager $databaseManager;
+
+ private Connection $connection;
+
private ProductQuantityUpdateService $productQuantityService;
+
private OrderCancelService $service;
+
private DomainEventDispatcherService $domainEventDispatcherService;
+
private EventStatisticsCancellationService $eventStatisticsCancellationService;
+ private RevertWaitlistOffersForCancelledOrderService $revertWaitlistOffersService;
+
protected function setUp(): void
{
parent::setUp();
@@ -48,9 +65,15 @@ protected function setUp(): void
$this->eventRepository = m::mock(EventRepositoryInterface::class);
$this->orderRepository = m::mock(OrderRepositoryInterface::class);
$this->databaseManager = m::mock(DatabaseManager::class);
+ $this->connection = m::mock(Connection::class);
+ $this->databaseManager->shouldReceive('connection')->andReturn($this->connection)->byDefault();
+ $this->connection->shouldReceive('afterCommit')
+ ->andReturnUsing(static fn (callable $callback) => $callback())
+ ->byDefault();
$this->productQuantityService = m::mock(ProductQuantityUpdateService::class);
$this->domainEventDispatcherService = m::mock(DomainEventDispatcherService::class);
$this->eventStatisticsCancellationService = m::mock(EventStatisticsCancellationService::class);
+ $this->revertWaitlistOffersService = m::mock(RevertWaitlistOffersForCancelledOrderService::class);
$this->service = new OrderCancelService(
mailer: $this->mailer,
@@ -61,42 +84,36 @@ protected function setUp(): void
productQuantityService: $this->productQuantityService,
domainEventDispatcherService: $this->domainEventDispatcherService,
eventStatisticsCancellationService: $this->eventStatisticsCancellationService,
+ revertWaitlistOffersService: $this->revertWaitlistOffersService,
);
}
- public function testCancelOrder(): void
+ public function test_cancel_order(): void
{
Event::fake();
- $order = m::mock(OrderDomainObject::class);
- $order->shouldReceive('getEventId')->andReturn(1);
- $order->shouldReceive('getId')->andReturn(1);
- $order->shouldReceive('getEmail')->andReturn('customer@example.com');
- $order->shouldReceive('isOrderAwaitingOfflinePayment')->andReturn(false);
-
- $order->shouldReceive('getLocale')->andReturn('en');
-
- $attendee1 = m::mock(AttendeeDomainObject::class);
- $attendee1->shouldReceive('getproductPriceId')->andReturn(1);
- $attendee1->shouldReceive('getProductId')->andReturn(10);
-
- $attendee2 = m::mock(AttendeeDomainObject::class);
- $attendee2->shouldReceive('getproductPriceId')->andReturn(2);
- $attendee2->shouldReceive('getProductId')->andReturn(20);
+ $order = $this->makeOrder(email: 'customer@example.com', completed: true);
+ $attendee1 = $this->makeAttendee(productPriceId: 1, productId: 10);
+ $attendee2 = $this->makeAttendee(productPriceId: 2, productId: 20);
$attendees = new Collection([$attendee1, $attendee2]);
$this->attendeeRepository
->shouldReceive('findWhere')
->twice()
- ->with([
- 'order_id' => $order->getId(),
- ])
+ ->with(['order_id' => 1])
->andReturn($attendees);
$this->attendeeRepository->shouldReceive('updateWhere')->once();
- $this->productQuantityService->shouldReceive('decreaseQuantitySold')->twice();
+ $this->productQuantityService->shouldReceive('decreaseQuantitySold')->once()->with(1, 1, 1);
+ $this->productQuantityService->shouldReceive('decreaseQuantitySold')->once()->with(2, 1, 1);
+
+ $this->expectOrderItemsReload($order, [
+ $this->makeOrderItem(ProductType::TICKET->name, productPriceId: 1, quantity: 1),
+ $this->makeOrderItem(ProductType::GENERAL->name, productPriceId: 5, quantity: 3),
+ ]);
+ $this->productQuantityService->shouldReceive('decreaseQuantitySold')->once()->with(5, 3);
$this->orderRepository->shouldReceive('updateWhere')->once();
@@ -104,27 +121,9 @@ public function testCancelOrder(): void
->once()
->with($order);
- $event = new EventDomainObject();
- $event->setEventSettings(new EventSettingDomainObject());
- $event->setOrganizer(new OrganizerDomainObject());
- $this->eventRepository
- ->shouldReceive('loadRelation')
- ->twice()
- ->andReturnSelf()
- ->getMock()
- ->shouldReceive('findById')->once()->andReturn($event);
-
- $this->mailer->shouldReceive('to')
- ->once()
- ->andReturnSelf();
+ $this->revertWaitlistOffersService->shouldReceive('revertOffersForOrder')->once()->with(1)->andReturn([]);
- $this->mailer->shouldReceive('locale')
- ->once()
- ->andReturnSelf();
-
- $this->mailer->shouldReceive('send')->once()->withArgs(function ($mail) {
- return $mail instanceof OrderCancelled;
- });
+ $this->expectCancellationEmail();
$this->domainEventDispatcherService->shouldReceive('dispatch')
->withArgs(function (OrderEvent $event) use ($order) {
@@ -134,7 +133,7 @@ public function testCancelOrder(): void
->once();
$this->databaseManager->shouldReceive('transaction')->once()->andReturnUsing(function ($callback) {
- $callback();
+ return $callback();
});
$attendees->each(function ($attendee) {
@@ -144,7 +143,7 @@ public function testCancelOrder(): void
try {
$this->service->cancelOrder($order);
} catch (Throwable $e) {
- $this->fail("Failed to cancel order: " . $e->getMessage());
+ $this->fail('Failed to cancel order: '.$e->getMessage());
}
Event::assertDispatched(CapacityChangedEvent::class, 2);
@@ -156,66 +155,39 @@ public function testCancelOrder(): void
});
}
- public function testCancelOrderAwaitingOfflinePayment(): void
+ public function test_cancel_order_awaiting_offline_payment(): void
{
Event::fake();
- $order = m::mock(OrderDomainObject::class);
- $order->shouldReceive('getEventId')->andReturn(1);
- $order->shouldReceive('getId')->andReturn(1);
- $order->shouldReceive('getEmail')->andReturn('customer@example.com');
- $order->shouldReceive('isOrderAwaitingOfflinePayment')->andReturn(true);
- $order->shouldReceive('getLocale')->andReturn('en');
-
- $attendee1 = m::mock(AttendeeDomainObject::class);
- $attendee1->shouldReceive('getproductPriceId')->andReturn(1);
- $attendee1->shouldReceive('getProductId')->andReturn(10);
-
- $attendee2 = m::mock(AttendeeDomainObject::class);
- $attendee2->shouldReceive('getproductPriceId')->andReturn(2);
- $attendee2->shouldReceive('getProductId')->andReturn(20);
+ $order = $this->makeOrder(email: 'customer@example.com', awaitingOfflinePayment: true);
+ $attendee1 = $this->makeAttendee(productPriceId: 1, productId: 10);
+ $attendee2 = $this->makeAttendee(productPriceId: 2, productId: 20);
$attendees = new Collection([$attendee1, $attendee2]);
$this->attendeeRepository
->shouldReceive('findWhere')
->twice()
- ->with([
- 'order_id' => $order->getId(),
- ])
+ ->with(['order_id' => 1])
->andReturn($attendees);
$this->attendeeRepository->shouldReceive('updateWhere')->once();
$this->productQuantityService->shouldReceive('decreaseQuantitySold')->twice();
+ $this->expectOrderItemsReload($order, [
+ $this->makeOrderItem(ProductType::TICKET->name, productPriceId: 1, quantity: 2),
+ ]);
+
$this->orderRepository->shouldReceive('updateWhere')->once();
$this->eventStatisticsCancellationService->shouldReceive('decrementForCancelledOrder')
->once()
->with($order);
- $event = new EventDomainObject();
- $event->setEventSettings(new EventSettingDomainObject());
- $event->setOrganizer(new OrganizerDomainObject());
- $this->eventRepository
- ->shouldReceive('loadRelation')
- ->twice()
- ->andReturnSelf()
- ->getMock()
- ->shouldReceive('findById')->once()->andReturn($event);
-
- $this->mailer->shouldReceive('to')
- ->once()
- ->andReturnSelf();
+ $this->revertWaitlistOffersService->shouldReceive('revertOffersForOrder')->once()->with(1)->andReturn([]);
- $this->mailer->shouldReceive('locale')
- ->once()
- ->andReturnSelf();
-
- $this->mailer->shouldReceive('send')->once()->withArgs(function ($mail) {
- return $mail instanceof OrderCancelled;
- });
+ $this->expectCancellationEmail();
$this->domainEventDispatcherService->shouldReceive('dispatch')
->withArgs(function (OrderEvent $event) use ($order) {
@@ -225,7 +197,7 @@ public function testCancelOrderAwaitingOfflinePayment(): void
->once();
$this->databaseManager->shouldReceive('transaction')->once()->andReturnUsing(function ($callback) {
- $callback();
+ return $callback();
});
$attendees->each(function ($attendee) {
@@ -235,9 +207,235 @@ public function testCancelOrderAwaitingOfflinePayment(): void
try {
$this->service->cancelOrder($order);
} catch (Throwable $e) {
- $this->fail("Failed to cancel order: " . $e->getMessage());
+ $this->fail('Failed to cancel order: '.$e->getMessage());
}
- $this->assertTrue(true, "Order cancellation proceeded without throwing an exception.");
+ $this->assertTrue(true, 'Order cancellation proceeded without throwing an exception.');
+ }
+
+ public function test_cancel_reserved_order_without_email_skips_email_and_reverts_waitlist_offers(): void
+ {
+ Event::fake();
+
+ $order = $this->makeOrder(email: null);
+
+ $this->attendeeRepository
+ ->shouldReceive('findWhere')
+ ->twice()
+ ->with(['order_id' => 1])
+ ->andReturn(new Collection);
+
+ $this->attendeeRepository->shouldReceive('updateWhere')->once();
+
+ $this->productQuantityService->shouldNotReceive('decreaseQuantitySold');
+ $this->orderRepository->shouldNotReceive('loadRelation');
+
+ $this->orderRepository->shouldReceive('updateWhere')->once();
+
+ $this->eventStatisticsCancellationService->shouldReceive('decrementForCancelledOrder')
+ ->once()
+ ->with($order);
+
+ $this->revertWaitlistOffersService->shouldReceive('revertOffersForOrder')->once()->with(1)->andReturn([]);
+
+ $this->eventRepository->shouldNotReceive('loadRelation');
+ $this->mailer->shouldNotReceive('to');
+ $this->mailer->shouldNotReceive('send');
+
+ $this->domainEventDispatcherService->shouldReceive('dispatch')->once();
+
+ $this->databaseManager->shouldReceive('transaction')->once()->andReturnUsing(function ($callback) {
+ return $callback();
+ });
+
+ $this->service->cancelOrder($order);
+
+ Event::assertNotDispatched(CapacityChangedEvent::class);
+ }
+
+ public function test_cancel_order_awaiting_offline_payment_restores_non_ticket_quantities(): void
+ {
+ Event::fake();
+
+ $order = $this->makeOrder(email: 'customer@example.com', awaitingOfflinePayment: true);
+
+ $attendee = $this->makeAttendee(productPriceId: 1, productId: 10);
+ $attendees = new Collection([$attendee]);
+
+ $this->attendeeRepository
+ ->shouldReceive('findWhere')
+ ->twice()
+ ->with(['order_id' => 1])
+ ->andReturn($attendees);
+
+ $this->attendeeRepository->shouldReceive('updateWhere')->once();
+
+ $this->productQuantityService->shouldReceive('decreaseQuantitySold')->once()->with(1, 1, 1);
+
+ $this->expectOrderItemsReload($order, [
+ $this->makeOrderItem(ProductType::TICKET->name, productPriceId: 1, quantity: 1),
+ $this->makeOrderItem(ProductType::GENERAL->name, productPriceId: 5, quantity: 3),
+ ]);
+ $this->productQuantityService->shouldReceive('decreaseQuantitySold')->once()->with(5, 3);
+
+ $this->orderRepository->shouldReceive('updateWhere')->once();
+
+ $this->eventStatisticsCancellationService->shouldReceive('decrementForCancelledOrder')
+ ->once()
+ ->with($order);
+
+ $this->revertWaitlistOffersService->shouldReceive('revertOffersForOrder')->once()->with(1)->andReturn([]);
+
+ $this->expectCancellationEmail();
+
+ $this->domainEventDispatcherService->shouldReceive('dispatch')->once();
+
+ $this->databaseManager->shouldReceive('transaction')->once()->andReturnUsing(function ($callback) {
+ return $callback();
+ });
+
+ $attendee->shouldReceive('getStatus')->andReturn(AttendeeStatus::AWAITING_PAYMENT->name);
+
+ $this->service->cancelOrder($order);
+ }
+
+ public function test_waitlist_capacity_events_are_dispatched_after_commit(): void
+ {
+ Event::fake();
+
+ $order = $this->makeOrder(email: null);
+
+ $this->attendeeRepository
+ ->shouldReceive('findWhere')
+ ->twice()
+ ->with(['order_id' => 1])
+ ->andReturn(new Collection);
+
+ $this->attendeeRepository->shouldReceive('updateWhere')->once();
+ $this->orderRepository->shouldReceive('updateWhere')->once();
+
+ $this->eventStatisticsCancellationService->shouldReceive('decrementForCancelledOrder')
+ ->once()
+ ->with($order);
+
+ $waitlistCapacityEvent = new CapacityChangedEvent(
+ eventId: 1,
+ direction: CapacityChangeDirection::INCREASED,
+ productId: 42,
+ productPriceId: 9,
+ eventOccurrenceId: 3,
+ );
+
+ $this->revertWaitlistOffersService
+ ->shouldReceive('revertOffersForOrder')
+ ->once()
+ ->with(1)
+ ->andReturn([$waitlistCapacityEvent]);
+
+ $this->domainEventDispatcherService->shouldReceive('dispatch')->once();
+
+ $capturedAfterCommitCallback = null;
+ $this->connection->shouldReceive('afterCommit')
+ ->once()
+ ->andReturnUsing(static function (callable $callback) use (&$capturedAfterCommitCallback) {
+ $capturedAfterCommitCallback = $callback;
+ });
+
+ $this->databaseManager->shouldReceive('transaction')->once()->andReturnUsing(function ($callback) {
+ $result = $callback();
+
+ Event::assertNotDispatched(CapacityChangedEvent::class);
+
+ return $result;
+ });
+
+ $this->service->cancelOrder($order);
+
+ Event::assertNotDispatched(CapacityChangedEvent::class);
+
+ ($capturedAfterCommitCallback)();
+
+ Event::assertDispatched(CapacityChangedEvent::class, function (CapacityChangedEvent $e) {
+ return $e->eventId === 1
+ && $e->direction === CapacityChangeDirection::INCREASED
+ && $e->productId === 42
+ && $e->productPriceId === 9
+ && $e->eventOccurrenceId === 3;
+ });
+ }
+
+ private function makeOrder(?string $email, bool $completed = false, bool $awaitingOfflinePayment = false): OrderDomainObject
+ {
+ $order = m::mock(OrderDomainObject::class);
+ $order->shouldReceive('getEventId')->andReturn(1);
+ $order->shouldReceive('getId')->andReturn(1);
+ $order->shouldReceive('getEmail')->andReturn($email);
+ $order->shouldReceive('getLocale')->andReturn('en');
+ $order->shouldReceive('isOrderCompleted')->andReturn($completed);
+ $order->shouldReceive('isOrderAwaitingOfflinePayment')->andReturn($awaitingOfflinePayment);
+
+ return $order;
+ }
+
+ private function makeAttendee(int $productPriceId, int $productId): AttendeeDomainObject
+ {
+ $attendee = m::mock(AttendeeDomainObject::class);
+ $attendee->shouldReceive('getProductPriceId')->andReturn($productPriceId);
+ $attendee->shouldReceive('getProductId')->andReturn($productId);
+ $attendee->shouldReceive('getEventOccurrenceId')->andReturn(1);
+
+ return $attendee;
+ }
+
+ private function makeOrderItem(string $productType, int $productPriceId, int $quantity): OrderItemDomainObject
+ {
+ $orderItem = m::mock(OrderItemDomainObject::class);
+ $orderItem->shouldReceive('getProductType')->andReturn($productType);
+ $orderItem->shouldReceive('getProductPriceId')->andReturn($productPriceId);
+ $orderItem->shouldReceive('getQuantity')->andReturn($quantity);
+
+ return $orderItem;
+ }
+
+ private function expectOrderItemsReload(OrderDomainObject $order, array $orderItems): void
+ {
+ $orderWithItems = m::mock(OrderDomainObject::class);
+ $orderWithItems->shouldReceive('getOrderItems')->andReturn(new Collection($orderItems));
+
+ $this->orderRepository
+ ->shouldReceive('loadRelation')
+ ->once()
+ ->with(OrderItemDomainObject::class)
+ ->andReturnSelf();
+ $this->orderRepository
+ ->shouldReceive('findById')
+ ->once()
+ ->with($order->getId())
+ ->andReturn($orderWithItems);
+ }
+
+ private function expectCancellationEmail(): void
+ {
+ $event = new EventDomainObject;
+ $event->setEventSettings(new EventSettingDomainObject);
+ $event->setOrganizer(new OrganizerDomainObject);
+ $this->eventRepository
+ ->shouldReceive('loadRelation')
+ ->twice()
+ ->andReturnSelf()
+ ->getMock()
+ ->shouldReceive('findById')->once()->andReturn($event);
+
+ $this->mailer->shouldReceive('to')
+ ->once()
+ ->andReturnSelf();
+
+ $this->mailer->shouldReceive('locale')
+ ->once()
+ ->andReturnSelf();
+
+ $this->mailer->shouldReceive('send')->once()->withArgs(function ($mail) {
+ return $mail instanceof OrderCancelled;
+ });
}
}
diff --git a/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php
index 31b501da22..4d1de2c276 100644
--- a/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php
@@ -2,33 +2,49 @@
namespace Tests\Unit\Services\Domain\Order;
-use HiEvents\DomainObjects\CapacityAssignmentDomainObject;
-use HiEvents\DomainObjects\Enums\ProductPriceType;
+use Carbon\Carbon;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
+use HiEvents\DomainObjects\ProductOccurrenceVisibilityDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
-use HiEvents\DomainObjects\PromoCodeDomainObject;
-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;
use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesResponseDTO;
+use HiEvents\Services\Domain\Product\ProductPriceService;
use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException;
use Mockery;
use Mockery\MockInterface;
-use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Tests\TestCase;
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 ProductPriceService|MockInterface $productPriceService;
+
private OrderCreateRequestValidationService $service;
protected function setUp(): void
@@ -39,450 +55,823 @@ 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);
+ $this->productPriceService = Mockery::mock(ProductPriceService::class);
+
+ $this->visibilityRepository
+ ->shouldReceive('findWhereIn')
+ ->byDefault()
+ ->andReturn(collect());
+
+ $this->orderItemRepository
+ ->shouldReceive('getReservedQuantityForOccurrence')
+ ->byDefault()
+ ->andReturn(0);
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->byDefault()
+ ->andReturn(collect([1]));
+
+ $this->occurrenceRepository
+ ->shouldReceive('countWhere')
+ ->byDefault()
+ ->andReturn(1);
+
+ $this->productRepository
+ ->shouldReceive('loadRelation')
+ ->byDefault()
+ ->andReturnSelf();
+
+ $this->productRepository
+ ->shouldReceive('findWhereIn')
+ ->byDefault()
+ ->andReturnUsing(fn ($field, $ids) => collect($ids)
+ ->map(fn ($id) => $this->createTicketProductStub((int) $id)));
+
+ $eligibilityService = new OccurrencePurchaseEligibilityService(
+ $this->occurrenceRepository,
+ $this->orderItemRepository,
+ $this->visibilityRepository,
+ );
$this->service = new OrderCreateRequestValidationService(
$this->productRepository,
$this->promoCodeRepository,
$this->eventRepository,
+ $this->occurrenceRepository,
$this->availabilityService,
+ $eligibilityService,
+ $this->productPriceService,
);
}
- 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(
+ capacity: 10,
+ usedCapacity: 10,
);
- $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));
+ }
- $this->service->validateRequestData($eventId, $data);
+ 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->setupOccurrenceLookup(1, 10, $occurrence);
+ $this->setupEventLookup(1);
+
+ $data = $this->createRequestData(10, quantity: 5);
+
+ $this->service->validateRequestData(1, $data);
+ }
+
+ public function test_general_products_do_not_consume_occurrence_capacity(): void
+ {
+ $occurrence = $this->createOccurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
+ capacity: 10,
+ usedCapacity: 8,
+ );
+
+ $this->setupOccurrenceLookup(1, 10, $occurrence);
+ $this->setupEventLookup(1);
+ $this->setupAvailability(1);
+ $this->setupProducts(1, 10, 100, productType: 'GENERAL');
+
+ $data = $this->createRequestData(10, quantity: 5);
+
+ $this->service->validateRequestData(1, $data);
$this->assertTrue(true);
}
- public function testZeroQuantityTierWithNegativeAvailabilityDoesNotThrow(): void
+ public function test_accepts_active_occurrence_with_sufficient_capacity(): 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->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 test_normalizes_missing_occurrence_id_for_single_event_checkout(): void
+ {
+ $occurrence = $this->createOccurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
+ capacity: 100,
+ usedCapacity: 0,
+ );
+
+ $this->setupEventLookup(1, isRecurring: false);
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->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);
+
+ $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);
+ }
+
+ public function test_enforces_per_occurrence_visibility_for_multi_occurrence_order(): void
+ {
+ $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(Carbon::now()->addMonths(2)->toDateTimeString());
+
+ $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);
+
+ $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(Carbon::now()->addMonths(3)->toDateTimeString());
+
+ $this->setupOccurrenceLookup(1, 10, $occurrence10);
+ $this->setupOccurrenceLookup(1, 20, $occurrence20);
+ $this->setupEventLookup(1);
+ $this->setupAvailability(1);
+ $this->setupProducts(1, 10, 100);
+
+ $this->visibilityRepository
+ ->shouldReceive('findWhereIn')
+ ->with('event_occurrence_id', [10])
+ ->andReturn(collect());
+ $this->visibilityRepository
+ ->shouldReceive('findWhereIn')
+ ->with('event_occurrence_id', [20])
+ ->andReturn(collect());
$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->service->validateRequestData(1, $data);
+ $this->assertTrue(true);
+ }
+
+ public function test_rejects_donation_below_occurrence_override_floor(): void
+ {
+ $occurrence = $this->createOccurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
+ );
+
+ $this->setupOccurrenceLookup(1, 10, $occurrence);
+ $this->setupEventLookup(1);
+ $this->setupAvailability(1);
+ $this->setupProducts(1, 10, 100, type: 'DONATION');
+
+ $this->productPriceService
+ ->shouldReceive('getDonationMinimumPrice')
+ ->with(Mockery::type(ProductDomainObject::class), 100, 10)
+ ->andReturn(25.0);
+
$this->expectException(ValidationException::class);
- $this->service->validateRequestData($eventId, $data);
+ $this->expectExceptionMessage('minimum amount');
+
+ $this->service->validateRequestData(1, $this->createRequestData(10, price: 15.0));
}
- public function testNegativeQuantityOnAPriceTierIsRejected(): void
+ public function test_accepts_donation_at_effective_floor(): void
{
- $eventId = 1;
- $productId = 10;
- $cheapPriceId = 101;
- $expensivePriceId = 102;
+ $occurrence = $this->createOccurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
+ );
- $this->setupMocks(
- eventId: $eventId,
- productId: $productId,
- priceIds: [$cheapPriceId, $expensivePriceId],
- priceLabels: ['Cheap', 'VIP'],
- availabilities: [
- ['price_id' => $cheapPriceId, 'quantity_available' => 100, 'quantity_reserved' => 0],
- ['price_id' => $expensivePriceId, 'quantity_available' => 100, 'quantity_reserved' => 0],
- ],
+ $this->setupOccurrenceLookup(1, 10, $occurrence);
+ $this->setupEventLookup(1);
+ $this->setupAvailability(1);
+ $this->setupProducts(1, 10, 100, type: 'DONATION');
+
+ $this->productPriceService
+ ->shouldReceive('getDonationMinimumPrice')
+ ->with(Mockery::type(ProductDomainObject::class), 100, 10)
+ ->andReturn(25.0);
+
+ $this->service->validateRequestData(1, $this->createRequestData(10, price: 25.0));
+ $this->assertTrue(true);
+ }
+
+ public function test_accepts_general_only_cart_on_sold_out_occurrence(): void
+ {
+ $occurrence = $this->createOccurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
+ capacity: 10,
+ usedCapacity: 10,
+ );
+
+ $this->setupOccurrenceLookup(1, 10, $occurrence);
+ $this->setupEventLookup(1);
+ $this->setupAvailability(1);
+ $this->setupProducts(1, 10, 100, productType: 'GENERAL');
+
+ $this->service->validateRequestData(1, $this->createRequestData(10, quantity: 2));
+ $this->assertTrue(true);
+ }
+
+ public function test_rejects_duplicate_product_price_lines_exceeding_available_stock(): void
+ {
+ $occurrence = $this->createOccurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
);
+ $this->setupOccurrenceLookup(1, 10, $occurrence);
+ $this->setupEventLookup(1);
+ $this->setupAvailability(1, available: 4);
+ $this->setupProducts(1, 10, 100);
+
$data = [
'products' => [
[
- 'product_id' => $productId,
- 'quantities' => [
- ['price_id' => $cheapPriceId, 'quantity' => 5],
- ['price_id' => $expensivePriceId, 'quantity' => -1],
- ],
+ 'product_id' => 10,
+ 'event_occurrence_id' => 10,
+ 'quantities' => [['price_id' => 100, 'quantity' => 3]],
+ ],
+ [
+ 'product_id' => 10,
+ 'event_occurrence_id' => 10,
+ 'quantities' => [['price_id' => 100, 'quantity' => 3]],
],
],
];
$this->expectException(ValidationException::class);
- $this->service->validateRequestData($eventId, $data);
+ $this->expectExceptionMessage('maximum number of products available');
+
+ $this->service->validateRequestData(1, $data);
}
- public function testUnrelatedOverReservedCapacityDoesNotBlockSelectedProduct(): void
+ public function test_accepts_duplicate_product_price_lines_within_available_stock(): void
{
- $eventId = 1;
- $selectedProductId = 10;
- $selectedPriceId = 101;
- $unrelatedProductId = 20;
- $unrelatedPriceId = 201;
-
- $unrelatedProduct = Mockery::mock(ProductDomainObject::class);
- $unrelatedProduct->shouldReceive('getId')->andReturn($unrelatedProductId);
+ $occurrence = $this->createOccurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
+ );
- $unrelatedCapacity = (new CapacityAssignmentDomainObject())
- ->setCapacity(10)
- ->setUsedCapacity(20)
- ->setProducts(collect([$unrelatedProduct]));
+ $this->setupOccurrenceLookup(1, 10, $occurrence);
+ $this->setupEventLookup(1);
+ $this->setupAvailability(1);
+ $this->setupProducts(1, 10, 100);
- $this->setupMocks(
- eventId: $eventId,
- productId: $selectedProductId,
- priceIds: [$selectedPriceId],
- priceLabels: ['Selected Product'],
- availabilities: [
- ['price_id' => $selectedPriceId, 'quantity_available' => 5, 'quantity_reserved' => 0],
- ],
- capacities: collect([$unrelatedCapacity]),
- extraAvailabilities: [
+ $data = [
+ 'products' => [
[
- 'product_id' => $unrelatedProductId,
- 'price_id' => $unrelatedPriceId,
- 'quantity_available' => -10,
- 'quantity_reserved' => 0,
+ 'product_id' => 10,
+ 'event_occurrence_id' => 10,
+ 'quantities' => [['price_id' => 100, 'quantity' => 3]],
+ ],
+ [
+ 'product_id' => 10,
+ 'event_occurrence_id' => 10,
+ 'quantities' => [['price_id' => 100, 'quantity' => 3]],
],
],
+ ];
+
+ $this->service->validateRequestData(1, $data);
+ $this->assertTrue(true);
+ }
+
+ public function test_rejects_same_price_across_occurrences_exceeding_available_stock(): void
+ {
+ $occurrence10 = $this->createOccurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
);
+ $occurrence20 = (new EventOccurrenceDomainObject)
+ ->setId(20)
+ ->setEventId(1)
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name)
+ ->setCapacity(null)
+ ->setUsedCapacity(0)
+ ->setStartDate(Carbon::now()->addMonths(2)->toDateTimeString());
+
+ $this->setupOccurrenceLookup(1, 10, $occurrence10);
+ $this->setupOccurrenceLookup(1, 20, $occurrence20);
+ $this->setupEventLookup(1, isRecurring: true);
+ $this->setupAvailability(1, available: 4);
+ $this->setupProducts(1, 10, 100);
+
$data = [
'products' => [
[
- 'product_id' => $selectedProductId,
- 'quantities' => [
- ['price_id' => $selectedPriceId, 'quantity' => 1],
- ],
+ 'product_id' => 10,
+ 'event_occurrence_id' => 10,
+ 'quantities' => [['price_id' => 100, 'quantity' => 3]],
+ ],
+ [
+ 'product_id' => 10,
+ 'event_occurrence_id' => 20,
+ 'quantities' => [['price_id' => 100, 'quantity' => 3]],
],
],
];
- $this->service->validateRequestData($eventId, $data);
- $this->assertTrue(true);
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('maximum number of products available');
+
+ $this->service->validateRequestData(1, $data);
}
- public function testHiddenProductIsRejected(): void
+ public function test_rejects_duplicate_lines_exceeding_max_per_order(): void
{
- $eventId = 1;
- $productId = 10;
- $priceId = 101;
-
- $this->setupMocks(
- eventId: $eventId,
- productId: $productId,
- priceIds: [$priceId],
- priceLabels: ['Hidden VIP'],
- availabilities: [
- ['price_id' => $priceId, 'quantity_available' => 50, 'quantity_reserved' => 0],
- ],
- isHidden: true,
+ $occurrence = $this->createOccurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
);
+ $this->setupOccurrenceLookup(1, 10, $occurrence);
+ $this->setupEventLookup(1);
+ $this->setupAvailability(1);
+ $this->setupProducts(1, 10, 100, maxPerOrder: 10);
+
$data = [
'products' => [
[
- 'product_id' => $productId,
- 'quantities' => [
- ['price_id' => $priceId, 'quantity' => 1],
- ],
+ 'product_id' => 10,
+ 'event_occurrence_id' => 10,
+ 'quantities' => [['price_id' => 100, 'quantity' => 6]],
+ ],
+ [
+ 'product_id' => 10,
+ 'event_occurrence_id' => 10,
+ 'quantities' => [['price_id' => 100, 'quantity' => 6]],
],
],
];
- $this->expectException(NotFoundHttpException::class);
- $this->service->validateRequestData($eventId, $data);
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('maximum number of products available for Test Products is 10');
+
+ $this->service->validateRequestData(1, $data);
}
- public function testProductHiddenWithoutPromoCodeIsRejectedWhenNoPromoCodeSupplied(): void
+ public function test_accepts_duplicate_lines_within_max_per_order(): void
{
- $eventId = 1;
- $productId = 10;
- $priceId = 101;
-
- $this->setupMocks(
- eventId: $eventId,
- productId: $productId,
- priceIds: [$priceId],
- priceLabels: ['Promo Only'],
- availabilities: [
- ['price_id' => $priceId, 'quantity_available' => 50, 'quantity_reserved' => 0],
- ],
- isHiddenWithoutPromoCode: true,
+ $occurrence = $this->createOccurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
);
+ $this->setupOccurrenceLookup(1, 10, $occurrence);
+ $this->setupEventLookup(1);
+ $this->setupAvailability(1);
+ $this->setupProducts(1, 10, 100, maxPerOrder: 10);
+
$data = [
'products' => [
[
- 'product_id' => $productId,
- 'quantities' => [
- ['price_id' => $priceId, 'quantity' => 1],
- ],
+ 'product_id' => 10,
+ 'event_occurrence_id' => 10,
+ 'quantities' => [['price_id' => 100, 'quantity' => 5]],
+ ],
+ [
+ 'product_id' => 10,
+ 'event_occurrence_id' => 10,
+ 'quantities' => [['price_id' => 100, 'quantity' => 5]],
],
],
];
- $this->expectException(NotFoundHttpException::class);
- $this->service->validateRequestData($eventId, $data);
+ $this->service->validateRequestData(1, $data);
+ $this->assertTrue(true);
}
- public function testProductHiddenWithoutPromoCodeIsAllowedWithMatchingPromoCode(): void
+ public function test_accepts_duplicate_lines_combining_to_meet_min_per_order(): void
{
- $eventId = 1;
- $productId = 10;
- $priceId = 101;
-
- $this->setupMocks(
- eventId: $eventId,
- productId: $productId,
- priceIds: [$priceId],
- priceLabels: ['Promo Only'],
- availabilities: [
- ['price_id' => $priceId, 'quantity_available' => 50, 'quantity_reserved' => 0],
- ],
- isHiddenWithoutPromoCode: true,
+ $occurrence = $this->createOccurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
);
- $promoCode = Mockery::mock(PromoCodeDomainObject::class);
- $promoCode->shouldReceive('isValid')->andReturn(true);
- $promoCode->shouldReceive('appliesToProduct')->andReturn(true);
- $this->promoCodeRepository->shouldReceive('findFirstWhere')->andReturn($promoCode);
+ $this->setupOccurrenceLookup(1, 10, $occurrence);
+ $this->setupEventLookup(1);
+ $this->setupAvailability(1);
+ $this->setupProducts(1, 10, 100, minPerOrder: 5);
$data = [
- 'promo_code' => 'UNLOCK',
'products' => [
[
- 'product_id' => $productId,
- 'quantities' => [
- ['price_id' => $priceId, 'quantity' => 1],
- ],
+ 'product_id' => 10,
+ 'event_occurrence_id' => 10,
+ 'quantities' => [['price_id' => 100, 'quantity' => 3]],
+ ],
+ [
+ 'product_id' => 10,
+ 'event_occurrence_id' => 10,
+ 'quantities' => [['price_id' => 100, 'quantity' => 2]],
],
],
];
- $this->service->validateRequestData($eventId, $data);
+ $this->service->validateRequestData(1, $data);
$this->assertTrue(true);
}
- public function testProductHiddenWithoutPromoCodeIsRejectedWhenPromoCodeDoesNotApply(): void
+ public function test_rejects_duplicate_lines_combining_below_min_per_order(): void
{
- $eventId = 1;
- $productId = 10;
- $priceId = 101;
-
- $this->setupMocks(
- eventId: $eventId,
- productId: $productId,
- priceIds: [$priceId],
- priceLabels: ['Promo Only'],
- availabilities: [
- ['price_id' => $priceId, 'quantity_available' => 50, 'quantity_reserved' => 0],
- ],
- isHiddenWithoutPromoCode: true,
+ $occurrence = $this->createOccurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
);
- $promoCode = Mockery::mock(PromoCodeDomainObject::class);
- $promoCode->shouldReceive('isValid')->andReturn(true);
- $promoCode->shouldReceive('appliesToProduct')->andReturn(false);
- $this->promoCodeRepository->shouldReceive('findFirstWhere')->andReturn($promoCode);
+ $this->setupOccurrenceLookup(1, 10, $occurrence);
+ $this->setupEventLookup(1);
+ $this->setupAvailability(1);
+ $this->setupProducts(1, 10, 100, minPerOrder: 5);
$data = [
- 'promo_code' => 'WRONGPRODUCT',
'products' => [
[
- 'product_id' => $productId,
- 'quantities' => [
- ['price_id' => $priceId, 'quantity' => 1],
- ],
+ 'product_id' => 10,
+ 'event_occurrence_id' => 10,
+ 'quantities' => [['price_id' => 100, 'quantity' => 2]],
+ ],
+ [
+ 'product_id' => 10,
+ 'event_occurrence_id' => 10,
+ 'quantities' => [['price_id' => 100, 'quantity' => 2]],
],
],
];
- $this->expectException(NotFoundHttpException::class);
- $this->service->validateRequestData($eventId, $data);
+ $this->expectException(ValidationException::class);
+ $this->expectExceptionMessage('must order at least');
+
+ $this->service->validateRequestData(1, $data);
}
- public function testHiddenPriceTierIsRejected(): void
+ public function test_max_per_order_applies_per_occurrence_for_recurring_events(): void
{
- $eventId = 1;
- $productId = 10;
- $visiblePriceId = 101;
- $hiddenPriceId = 102;
-
- $this->setupMocks(
- eventId: $eventId,
- productId: $productId,
- priceIds: [$visiblePriceId, $hiddenPriceId],
- priceLabels: ['General', 'Hidden VIP'],
- availabilities: [
- ['price_id' => $visiblePriceId, 'quantity_available' => 50, 'quantity_reserved' => 0],
- ['price_id' => $hiddenPriceId, 'quantity_available' => 50, 'quantity_reserved' => 0],
- ],
- hiddenPriceIds: [$hiddenPriceId],
+ $occurrence10 = $this->createOccurrence(
+ status: EventOccurrenceStatus::ACTIVE->name,
);
+ $occurrence20 = (new EventOccurrenceDomainObject)
+ ->setId(20)
+ ->setEventId(1)
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name)
+ ->setCapacity(null)
+ ->setUsedCapacity(0)
+ ->setStartDate(Carbon::now()->addMonths(2)->toDateTimeString());
+
+ $this->setupOccurrenceLookup(1, 10, $occurrence10);
+ $this->setupOccurrenceLookup(1, 20, $occurrence20);
+ $this->setupEventLookup(1, isRecurring: true);
+ $this->setupAvailability(1);
+ $this->setupProducts(1, 10, 100, maxPerOrder: 10);
+
$data = [
'products' => [
[
- 'product_id' => $productId,
- 'quantities' => [
- ['price_id' => $hiddenPriceId, 'quantity' => 1],
- ],
+ 'product_id' => 10,
+ 'event_occurrence_id' => 10,
+ 'quantities' => [['price_id' => 100, 'quantity' => 6]],
+ ],
+ [
+ 'product_id' => 10,
+ 'event_occurrence_id' => 20,
+ 'quantities' => [['price_id' => 100, 'quantity' => 6]],
],
],
];
- $this->expectException(ValidationException::class);
- $this->service->validateRequestData($eventId, $data);
- }
-
- private function setupMocks(
- int $eventId,
- int $productId,
- array $priceIds,
- array $priceLabels,
- array $availabilities,
- ?Collection $capacities = null,
- array $extraAvailabilities = [],
- bool $isHidden = false,
- bool $isHiddenWithoutPromoCode = false,
- array $hiddenPriceIds = [],
- ): void
+ $this->service->validateRequestData(1, $data);
+ $this->assertTrue(true);
+ }
+
+ private function createTicketProductStub(int $id): ProductDomainObject|MockInterface
+ {
+ $product = Mockery::mock(ProductDomainObject::class);
+ $product->shouldReceive('getId')->andReturn($id);
+ $product->shouldReceive('getProductType')->andReturn('TICKET');
+
+ return $product;
+ }
+
+ 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(Carbon::now()->addMonth()->toDateTimeString());
+ }
+
+ private function setupOccurrenceLookup(int $eventId, int $occurrenceId, ?EventOccurrenceDomainObject $occurrence): void
+ {
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->with([
+ 'id' => $occurrenceId,
+ 'event_id' => $eventId,
+ ])
+ ->andReturn($occurrence);
+ }
+
+ 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('isRecurring')->andReturn($isRecurring);
$event->shouldReceive('getCurrency')->andReturn('USD');
- $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);
- $price->shouldReceive('getIsHidden')->andReturn(in_array($priceId, $hiddenPriceIds, true));
- $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',
+ 'product_type' => 'TICKET',
+ '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, string $productType = 'TICKET', string $type = 'PAID', int $maxPerOrder = 10, int $minPerOrder = 1): void
+ {
+ $price = Mockery::mock(ProductPriceDomainObject::class);
+ $price->shouldReceive('getId')->andReturn($priceId);
+ $price->shouldReceive('getIsHidden')->andReturn(false);
+ $price->shouldReceive('getLabel')->andReturn(null);
$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('getMinPerOrder')->andReturn(1);
+ $product->shouldReceive('getMaxPerOrder')->andReturn($maxPerOrder);
+ $product->shouldReceive('getMinPerOrder')->andReturn($minPerOrder);
+ $product->shouldReceive('getType')->andReturn($type);
+ $product->shouldReceive('getPrice')->andReturn(10.0);
$product->shouldReceive('isSoldOut')->andReturn(false);
- $product->shouldReceive('getType')->andReturn(ProductPriceType::TIERED->name);
- $product->shouldReceive('getProductPrices')->andReturn($productPrices);
- $product->shouldReceive('getIsHidden')->andReturn($isHidden);
- $product->shouldReceive('getIsHiddenWithoutPromoCode')->andReturn($isHiddenWithoutPromoCode);
+ $product->shouldReceive('getProductPrices')->andReturn(collect([$price]));
+ $product->shouldReceive('getProductType')->andReturn($productType);
+ $product->shouldReceive('getIsHidden')->andReturn(false);
+ $product->shouldReceive('getIsHiddenWithoutPromoCode')->andReturn(false);
- $this->productRepository->shouldReceive('loadRelation')->andReturnSelf();
- $this->productRepository->shouldReceive('findWhereIn')->andReturn(new Collection([$product]));
+ $this->productRepository
+ ->shouldReceive('loadRelation')->andReturnSelf();
- $quantityDTOs = collect();
- foreach ($availabilities as $avail) {
- $quantityDTOs->push($this->makeQuantityDTO($avail, $productId));
- }
+ $this->productRepository
+ ->shouldReceive('findWhereIn')
+ ->andReturn(collect([$product]));
+ }
- foreach ($extraAvailabilities as $avail) {
- $quantityDTOs->push($this->makeQuantityDTO($avail, $productId));
+ private function createRequestData(int $occurrenceId, int $productId = 10, int $priceId = 100, int $quantity = 1, ?float $price = null): array
+ {
+ $quantityData = [
+ 'price_id' => $priceId,
+ 'quantity' => $quantity,
+ ];
+
+ if ($price !== null) {
+ $quantityData['price'] = $price;
}
- $this->availabilityService->shouldReceive('getAvailableProductQuantities')
- ->with($eventId, Mockery::any())
- ->andReturn(new AvailableProductQuantitiesResponseDTO(
- productQuantities: $quantityDTOs,
- capacities: $capacities ?? collect(),
- ));
+ return [
+ 'products' => [
+ [
+ 'product_id' => $productId,
+ 'event_occurrence_id' => $occurrenceId,
+ 'quantities' => [$quantityData],
+ ],
+ ],
+ ];
}
- private function makeQuantityDTO(array $availability, int $defaultProductId): AvailableProductQuantitiesDTO
+ protected function tearDown(): void
{
- return AvailableProductQuantitiesDTO::fromArray([
- 'product_id' => $availability['product_id'] ?? $defaultProductId,
- 'price_id' => $availability['price_id'],
- 'product_title' => 'Test Product',
- 'price_label' => null,
- 'quantity_available' => $availability['quantity_available'],
- 'quantity_reserved' => $availability['quantity_reserved'],
- 'initial_quantity_available' => 100,
- 'capacities' => collect(),
- ]);
+ Mockery::close();
+ parent::tearDown();
}
}
diff --git a/backend/tests/Unit/Services/Domain/Order/OrderDiscountAllocationServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderDiscountAllocationServiceTest.php
new file mode 100644
index 0000000000..e02b2fde0d
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Order/OrderDiscountAllocationServiceTest.php
@@ -0,0 +1,272 @@
+service = new OrderDiscountAllocationService;
+ }
+
+ public function test_single_line_single_quantity_gets_exact_discount(): void
+ {
+ $lines = collect([
+ $this->createLine(productId: 1, unitPrice: 50.00, quantity: 1),
+ ]);
+
+ $result = $this->service->allocate($lines, $this->createPromoCode(10.00), 'USD');
+
+ $this->assertSame([[10.00, 1]], $this->toArrays($result[0]));
+ }
+
+ public function test_indivisible_discount_splits_a_line_to_stay_exact(): void
+ {
+ $lines = collect([
+ $this->createLine(productId: 1, unitPrice: 20.00, quantity: 3),
+ ]);
+
+ $result = $this->service->allocate($lines, $this->createPromoCode(10.00), 'USD');
+
+ $this->assertSame([[3.34, 1], [3.33, 2]], $this->toArrays($result[0]));
+ $this->assertEqualsWithDelta(10.00, $this->totalAllocated($result), 0.0001);
+ }
+
+ public function test_discount_is_allocated_pro_rata_across_lines(): void
+ {
+ $lines = collect([
+ $this->createLine(productId: 1, unitPrice: 50.00, quantity: 2),
+ $this->createLine(productId: 2, unitPrice: 25.00, quantity: 4),
+ ]);
+
+ $result = $this->service->allocate($lines, $this->createPromoCode(30.00), 'USD');
+
+ $this->assertSame([[7.50, 2]], $this->toArrays($result[0]));
+ $this->assertSame([[3.75, 4]], $this->toArrays($result[1]));
+ }
+
+ public function test_remainder_is_distributed_exactly_across_lines(): void
+ {
+ $lines = collect([
+ $this->createLine(productId: 1, unitPrice: 10.00, quantity: 2),
+ $this->createLine(productId: 2, unitPrice: 10.00, quantity: 3),
+ ]);
+
+ $result = $this->service->allocate($lines, $this->createPromoCode(9.99), 'USD');
+
+ $this->assertSame([[2.01, 2]], $this->toArrays($result[0]));
+ $this->assertSame([[1.99, 3]], $this->toArrays($result[1]));
+ $this->assertEqualsWithDelta(9.99, $this->totalAllocated($result), 0.0001);
+ }
+
+ public function test_small_discount_on_large_quantity_stays_exact(): void
+ {
+ $lines = collect([
+ $this->createLine(productId: 1, unitPrice: 10.00, quantity: 250),
+ ]);
+
+ $result = $this->service->allocate($lines, $this->createPromoCode(1.00), 'USD');
+
+ $this->assertSame([[0.01, 100], [0.00, 150]], $this->toArrays($result[0]));
+ $this->assertEqualsWithDelta(1.00, $this->totalAllocated($result), 0.0001);
+ }
+
+ public function test_exhausted_headroom_falls_back_to_a_split_without_overshooting(): void
+ {
+ $lines = collect([
+ $this->createLine(productId: 1, unitPrice: 0.01, quantity: 3),
+ $this->createLine(productId: 2, unitPrice: 0.01, quantity: 5),
+ ]);
+
+ $result = $this->service->allocate($lines, $this->createPromoCode(0.04), 'USD');
+
+ $this->assertEqualsWithDelta(0.04, $this->totalAllocated($result), 0.0001);
+ }
+
+ public function test_zero_decimal_currency_allocates_whole_units(): void
+ {
+ $lines = collect([
+ $this->createLine(productId: 1, unitPrice: 1000.0, quantity: 3),
+ ]);
+
+ $result = $this->service->allocate($lines, $this->createPromoCode(1000.0), 'JPY');
+
+ $this->assertSame([[334.0, 1], [333.0, 2]], $this->toArrays($result[0]));
+ $this->assertEqualsWithDelta(1000.0, $this->totalAllocated($result), 0.0001);
+ }
+
+ public function test_discount_larger_than_subtotal_is_clamped_to_subtotal(): void
+ {
+ $lines = collect([
+ $this->createLine(productId: 1, unitPrice: 50.00, quantity: 1),
+ $this->createLine(productId: 2, unitPrice: 50.00, quantity: 2),
+ ]);
+
+ $result = $this->service->allocate($lines, $this->createPromoCode(500.00), 'USD');
+
+ $this->assertSame([[50.00, 1]], $this->toArrays($result[0]));
+ $this->assertSame([[50.00, 2]], $this->toArrays($result[1]));
+ }
+
+ public function test_discount_equal_to_subtotal_zeroes_every_line(): void
+ {
+ $lines = collect([
+ $this->createLine(productId: 1, unitPrice: 20.00, quantity: 2),
+ $this->createLine(productId: 2, unitPrice: 10.00, quantity: 1),
+ ]);
+
+ $result = $this->service->allocate($lines, $this->createPromoCode(50.00), 'USD');
+
+ $this->assertSame([[20.00, 2]], $this->toArrays($result[0]));
+ $this->assertSame([[10.00, 1]], $this->toArrays($result[1]));
+ }
+
+ public function test_lines_outside_applicable_products_get_no_discount(): void
+ {
+ $lines = collect([
+ $this->createLine(productId: 1, unitPrice: 50.00, quantity: 1),
+ $this->createLine(productId: 2, unitPrice: 50.00, quantity: 1),
+ ]);
+
+ $result = $this->service->allocate($lines, $this->createPromoCode(10.00, applicableProductIds: [1]), 'USD');
+
+ $this->assertSame([[10.00, 1]], $this->toArrays($result[0]));
+ $this->assertSame([[0.00, 1]], $this->toArrays($result[1]));
+ }
+
+ public function test_free_and_donation_lines_get_no_discount(): void
+ {
+ $lines = collect([
+ $this->createLine(productId: 1, unitPrice: 0.00, quantity: 1, type: ProductPriceType::FREE),
+ $this->createLine(productId: 2, unitPrice: 25.00, quantity: 1, type: ProductPriceType::DONATION),
+ $this->createLine(productId: 3, unitPrice: 50.00, quantity: 1),
+ ]);
+
+ $result = $this->service->allocate($lines, $this->createPromoCode(10.00), 'USD');
+
+ $this->assertSame([[0.00, 1]], $this->toArrays($result[0]));
+ $this->assertSame([[0.00, 1]], $this->toArrays($result[1]));
+ $this->assertSame([[10.00, 1]], $this->toArrays($result[2]));
+ }
+
+ public function test_no_eligible_lines_returns_zero_allocations(): void
+ {
+ $lines = collect([
+ $this->createLine(productId: 1, unitPrice: 0.00, quantity: 2, type: ProductPriceType::FREE),
+ $this->createLine(productId: 2, unitPrice: 25.00, quantity: 1, type: ProductPriceType::DONATION),
+ ]);
+
+ $result = $this->service->allocate($lines, $this->createPromoCode(10.00), 'USD');
+
+ $this->assertSame([[0.00, 2]], $this->toArrays($result[0]));
+ $this->assertSame([[0.00, 1]], $this->toArrays($result[1]));
+ }
+
+ public function test_allocation_is_deterministic(): void
+ {
+ $lines = collect([
+ $this->createLine(productId: 1, unitPrice: 13.33, quantity: 3),
+ $this->createLine(productId: 2, unitPrice: 7.77, quantity: 2),
+ $this->createLine(productId: 3, unitPrice: 19.99, quantity: 5),
+ ]);
+ $promoCode = $this->createPromoCode(25.00);
+
+ $this->assertEquals(
+ $this->service->allocate($lines, $promoCode, 'USD'),
+ $this->service->allocate($lines, $promoCode, 'USD'),
+ );
+ $this->assertEqualsWithDelta(
+ 25.00,
+ $this->totalAllocated($this->service->allocate($lines, $promoCode, 'USD')),
+ 0.0001,
+ );
+ }
+
+ public function test_per_unit_discount_never_exceeds_unit_price(): void
+ {
+ $lines = collect([
+ $this->createLine(productId: 1, unitPrice: 1.00, quantity: 2),
+ $this->createLine(productId: 2, unitPrice: 99.99, quantity: 1),
+ ]);
+
+ $result = $this->service->allocate($lines, $this->createPromoCode(75.00), 'USD');
+
+ foreach ($result[0] as $allocation) {
+ $this->assertLessThanOrEqual(1.00, $allocation->per_unit_discount);
+ }
+ foreach ($result[1] as $allocation) {
+ $this->assertLessThanOrEqual(99.99, $allocation->per_unit_discount);
+ }
+ $this->assertEqualsWithDelta(75.00, $this->totalAllocated($result), 0.0001);
+ }
+
+ /**
+ * @param array> $allocations
+ */
+ private function totalAllocated(array $allocations): float
+ {
+ $total = 0.0;
+ foreach ($allocations as $lineAllocations) {
+ foreach ($lineAllocations as $allocation) {
+ $total += $allocation->per_unit_discount * $allocation->quantity;
+ }
+ }
+
+ return $total;
+ }
+
+ /**
+ * @param array $lineAllocations
+ * @return array
+ */
+ private function toArrays(array $lineAllocations): array
+ {
+ return array_map(
+ static fn (OrderLineDiscountAllocationDTO $allocation) => [$allocation->per_unit_discount, $allocation->quantity],
+ $lineAllocations,
+ );
+ }
+
+ private function createLine(
+ int $productId,
+ float $unitPrice,
+ int $quantity,
+ ProductPriceType $type = ProductPriceType::PAID,
+ ): OrderItemPricingLineDTO {
+ $product = (new ProductDomainObject)
+ ->setId($productId)
+ ->setType($type->name);
+
+ return new OrderItemPricingLineDTO(
+ product: $product,
+ product_price: new OrderProductPriceDTO(quantity: $quantity, price_id: $productId * 100),
+ prices: new PriceDTO($unitPrice),
+ event_occurrence_id: null,
+ );
+ }
+
+ private function createPromoCode(float $discount, ?array $applicableProductIds = null): PromoCodeDomainObject
+ {
+ return (new PromoCodeDomainObject)
+ ->setDiscountType(PromoCodeDiscountTypeEnum::FIXED->name)
+ ->setDiscountAppliesTo(PromoCodeDiscountAppliesToEnum::ORDER->name)
+ ->setDiscount($discount)
+ ->setApplicableProductIds($applicableProductIds);
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/Order/OrderItemProcessingServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderItemProcessingServiceTest.php
new file mode 100644
index 0000000000..89d9a652c4
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Order/OrderItemProcessingServiceTest.php
@@ -0,0 +1,207 @@
+setDiscountType(PromoCodeDiscountTypeEnum::FIXED->name)
+ ->setDiscountAppliesTo(PromoCodeDiscountAppliesToEnum::ORDER->name)
+ ->setDiscount(30.00);
+
+ $orderItems = $this->processOrder($promoCode);
+
+ $this->assertCount(2, $orderItems);
+
+ [$first, $second] = $this->capturedOrderItems;
+
+ $this->assertEquals(42.50, $first['price']);
+ $this->assertEquals(50.00, $first['price_before_discount']);
+ $this->assertEquals(85.00, $first['total_before_additions']);
+
+ $this->assertEquals(21.25, $second['price']);
+ $this->assertEquals(25.00, $second['price_before_discount']);
+ $this->assertEquals(85.00, $second['total_before_additions']);
+
+ $this->assertEquals(8.50, $first['total_tax']);
+ $this->assertEquals(8.50, $second['total_tax']);
+ }
+
+ public function test_per_product_discount_is_applied_to_every_unit(): void
+ {
+ $promoCode = (new PromoCodeDomainObject)
+ ->setDiscountType(PromoCodeDiscountTypeEnum::FIXED->name)
+ ->setDiscountAppliesTo(PromoCodeDiscountAppliesToEnum::EACH_PRODUCT->name)
+ ->setDiscount(10.00);
+
+ $this->processOrder($promoCode);
+
+ [$first, $second] = $this->capturedOrderItems;
+
+ $this->assertEquals(40.00, $first['price']);
+ $this->assertEquals(50.00, $first['price_before_discount']);
+ $this->assertEquals(80.00, $first['total_before_additions']);
+
+ $this->assertEquals(15.00, $second['price']);
+ $this->assertEquals(25.00, $second['price_before_discount']);
+ $this->assertEquals(60.00, $second['total_before_additions']);
+ }
+
+ public function test_indivisible_order_level_discount_splits_a_line_into_exact_items(): void
+ {
+ $promoCode = (new PromoCodeDomainObject)
+ ->setDiscountType(PromoCodeDiscountTypeEnum::FIXED->name)
+ ->setDiscountAppliesTo(PromoCodeDiscountAppliesToEnum::ORDER->name)
+ ->setDiscount(10.00);
+
+ $orderItems = $this->processOrder($promoCode, [[10, 3]]);
+
+ $this->assertCount(2, $orderItems);
+
+ [$first, $second] = $this->capturedOrderItems;
+
+ $this->assertEquals(46.66, $first['price']);
+ $this->assertEquals(1, $first['quantity']);
+ $this->assertEquals(50.00, $first['price_before_discount']);
+ $this->assertEquals(46.66, $first['total_before_additions']);
+
+ $this->assertEquals(46.67, $second['price']);
+ $this->assertEquals(2, $second['quantity']);
+ $this->assertEquals(50.00, $second['price_before_discount']);
+ $this->assertEquals(93.34, $second['total_before_additions']);
+
+ $this->assertEqualsWithDelta(
+ 140.00,
+ $first['total_before_additions'] + $second['total_before_additions'],
+ 0.0001,
+ );
+ }
+
+ public function test_no_promo_code_leaves_prices_untouched(): void
+ {
+ $this->processOrder(null);
+
+ [$first, $second] = $this->capturedOrderItems;
+
+ $this->assertEquals(50.00, $first['price']);
+ $this->assertNull($first['price_before_discount']);
+ $this->assertEquals(100.00, $first['total_before_additions']);
+
+ $this->assertEquals(25.00, $second['price']);
+ $this->assertNull($second['price_before_discount']);
+ $this->assertEquals(100.00, $second['total_before_additions']);
+ }
+
+ private function processOrder(?PromoCodeDomainObject $promoCode, array $lines = [[10, 2], [20, 4]])
+ {
+ $event = (new EventDomainObject)
+ ->setId(1)
+ ->setCurrency('USD');
+
+ $order = (new OrderDomainObject)->setId(99);
+
+ $products = [
+ 10 => $this->createProduct(10, 50.00),
+ 20 => $this->createProduct(20, 25.00),
+ ];
+
+ $orderRepository = Mockery::mock(OrderRepositoryInterface::class);
+ $orderRepository->shouldReceive('addOrderItem')
+ ->andReturnUsing(function (array $data) {
+ $this->capturedOrderItems[] = $data;
+
+ return Mockery::mock(OrderItemDomainObject::class);
+ });
+
+ $productRepository = Mockery::mock(ProductRepositoryInterface::class);
+ $productRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $productRepository->shouldReceive('findFirstWhere')
+ ->andReturnUsing(static fn (array $where) => $products[$where['id']]);
+
+ $taxCalculationService = Mockery::mock(TaxAndFeeCalculationService::class);
+ $taxCalculationService->shouldReceive('calculateTaxAndFeesForProduct')
+ ->andReturnUsing(static fn ($product, float $price, int $quantity) => new TaxCalculationResponse(
+ feeTotal: 0.0,
+ taxTotal: round($price * 0.10 * $quantity, 2),
+ rollUp: [],
+ ));
+
+ $eventRepository = Mockery::mock(EventRepositoryInterface::class);
+ $eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $eventRepository->shouldReceive('findById')->andReturn($event);
+
+ $service = new OrderItemProcessingService(
+ orderRepository: $orderRepository,
+ productRepository: $productRepository,
+ taxCalculationService: $taxCalculationService,
+ productPriceService: new ProductPriceService(
+ Mockery::mock(ProductPriceOccurrenceOverrideRepositoryInterface::class)
+ ),
+ platformFeeService: Mockery::mock(OrderPlatformFeePassThroughService::class),
+ eventRepository: $eventRepository,
+ orderDiscountAllocationService: new OrderDiscountAllocationService,
+ );
+
+ return $service->process(
+ order: $order,
+ productsOrderDetails: collect(array_map(
+ static fn (array $line) => new ProductOrderDetailsDTO(
+ product_id: $line[0],
+ quantities: collect([new OrderProductPriceDTO(quantity: $line[1], price_id: $line[0] * 10)]),
+ ),
+ $lines,
+ )),
+ event: $event,
+ promoCode: $promoCode,
+ );
+ }
+
+ private function createProduct(int $id, float $price): ProductDomainObject
+ {
+ return (new ProductDomainObject)
+ ->setId($id)
+ ->setType(ProductPriceType::PAID->name)
+ ->setProductType(ProductType::TICKET->name)
+ ->setTitle('Product '.$id)
+ ->setProductPrices(collect([
+ (new ProductPriceDomainObject)
+ ->setId($id * 10)
+ ->setPrice($price),
+ ]));
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/Order/OrderPlatformFeePassThroughServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderPlatformFeePassThroughServiceTest.php
index 202b6a7b52..a6bbdbd2fd 100644
--- a/backend/tests/Unit/Services/Domain/Order/OrderPlatformFeePassThroughServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Order/OrderPlatformFeePassThroughServiceTest.php
@@ -3,8 +3,8 @@
namespace Tests\Unit\Services\Domain\Order;
use Brick\Money\Currency;
-use HiEvents\DomainObjects\AccountConfigurationDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
+use HiEvents\DomainObjects\OrganizerConfigurationDomainObject;
use HiEvents\Services\Domain\Order\OrderPlatformFeePassThroughService;
use HiEvents\Services\Infrastructure\CurrencyConversion\CurrencyConversionClientInterface;
use HiEvents\Values\MoneyValue;
@@ -14,7 +14,9 @@
class OrderPlatformFeePassThroughServiceTest extends TestCase
{
private Repository $config;
+
private CurrencyConversionClientInterface $currencyConversionClient;
+
private OrderPlatformFeePassThroughService $service;
protected function setUp(): void
@@ -23,7 +25,7 @@ protected function setUp(): void
$this->currencyConversionClient = $this->createMock(CurrencyConversionClientInterface::class);
$this->currencyConversionClient->method('convert')->willReturnCallback(
- fn(Currency $from, Currency $to, float $amount) => MoneyValue::fromFloat($amount, $to->getCurrencyCode())
+ fn (Currency $from, Currency $to, float $amount) => MoneyValue::fromFloat($amount, $to->getCurrencyCode())
);
$this->service = new OrderPlatformFeePassThroughService(
@@ -32,12 +34,13 @@ 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);
+
return $mock;
}
@@ -45,10 +48,11 @@ private function createEventSettings(bool $passPlatformFeeToBuyer = true): Event
{
$settings = $this->createMock(EventSettingDomainObject::class);
$settings->method('getPassPlatformFeeToBuyer')->willReturn($passPlatformFeeToBuyer);
+
return $settings;
}
- public function testIsEnabledReturnsFalseWhenSaasModeDisabled(): void
+ public function test_is_enabled_returns_false_when_saas_mode_disabled(): void
{
$this->config->method('get')->with('app.saas_mode_enabled')->willReturn(false);
@@ -57,7 +61,7 @@ public function testIsEnabledReturnsFalseWhenSaasModeDisabled(): void
$this->assertFalse($this->service->isEnabled($eventSettings));
}
- public function testIsEnabledReturnsFalseWhenEventSettingDisabled(): void
+ public function test_is_enabled_returns_false_when_event_setting_disabled(): void
{
$this->config->method('get')->with('app.saas_mode_enabled')->willReturn(true);
@@ -66,7 +70,7 @@ public function testIsEnabledReturnsFalseWhenEventSettingDisabled(): void
$this->assertFalse($this->service->isEnabled($eventSettings));
}
- public function testIsEnabledReturnsTrueWhenBothEnabled(): void
+ public function test_is_enabled_returns_true_when_both_enabled(): void
{
$this->config->method('get')->with('app.saas_mode_enabled')->willReturn(true);
@@ -75,7 +79,7 @@ public function testIsEnabledReturnsTrueWhenBothEnabled(): void
$this->assertTrue($this->service->isEnabled($eventSettings));
}
- public function testCalculatePlatformFeeReturnsZeroWhenDisabled(): void
+ public function test_calculate_platform_fee_returns_zero_when_disabled(): void
{
$this->config->method('get')->with('app.saas_mode_enabled')->willReturn(true);
@@ -87,7 +91,7 @@ public function testCalculatePlatformFeeReturnsZeroWhenDisabled(): void
$this->assertEquals(0.0, $result);
}
- public function testCalculatePlatformFeeReturnsZeroForZeroTotal(): void
+ public function test_calculate_platform_fee_returns_zero_for_zero_total(): void
{
$this->config->method('get')->willReturn(true);
@@ -99,7 +103,7 @@ public function testCalculatePlatformFeeReturnsZeroForZeroTotal(): void
$this->assertEquals(0.0, $result);
}
- public function testCalculatePlatformFeeBasicCalculation(): void
+ public function test_calculate_platform_fee_basic_calculation(): void
{
$this->config->method('get')->willReturn(true);
@@ -115,7 +119,7 @@ public function testCalculatePlatformFeeBasicCalculation(): void
$this->assertEqualsWithDelta(3.30, $result, 0.01);
}
- public function testCalculatePlatformFeeWithMultipleQuantity(): void
+ public function test_calculate_platform_fee_with_multiple_quantity(): void
{
$this->config->method('get')->willReturn(true);
@@ -132,7 +136,7 @@ public function testCalculatePlatformFeeWithMultipleQuantity(): void
$this->assertEqualsWithDelta(6.59, $result, 0.01);
}
- public function testPlatformFeeExactlyCoversStripeApplicationFee(): void
+ public function test_platform_fee_exactly_covers_stripe_application_fee(): void
{
$this->config->method('get')->willReturn(true);
@@ -166,7 +170,7 @@ public function testPlatformFeeExactlyCoversStripeApplicationFee(): void
);
}
- public function testPlatformFeeWithDifferentTotals(): void
+ public function test_platform_fee_with_different_totals(): void
{
$this->config->method('get')->willReturn(true);
@@ -204,7 +208,7 @@ public function testPlatformFeeWithDifferentTotals(): void
}
}
- public function testCurrencyConversionCalledForNonUsdCurrency(): void
+ public function test_currency_conversion_called_for_non_usd_currency(): void
{
$this->config->method('get')->willReturn(true);
@@ -213,8 +217,8 @@ public function testCurrencyConversionCalledForNonUsdCurrency(): void
$currencyConversionClient->expects($this->once())
->method('convert')
->with(
- $this->callback(fn(Currency $c) => $c->getCurrencyCode() === 'USD'),
- $this->callback(fn(Currency $c) => $c->getCurrencyCode() === 'EUR'),
+ $this->callback(fn (Currency $c) => $c->getCurrencyCode() === 'USD'),
+ $this->callback(fn (Currency $c) => $c->getCurrencyCode() === 'EUR'),
0.30
)
->willReturn(MoneyValue::fromFloat(0.27, 'EUR'));
@@ -232,7 +236,7 @@ public function testCurrencyConversionCalledForNonUsdCurrency(): void
$this->assertGreaterThan(0, $result);
}
- public function testNoCurrencyConversionForUsd(): void
+ public function test_no_currency_conversion_for_usd(): void
{
$this->config->method('get')->willReturn(true);
@@ -252,7 +256,7 @@ public function testNoCurrencyConversionForUsd(): void
$this->assertGreaterThan(0, $result);
}
- public function testNoConversionWhenOrderCurrencyMatchesFeeCurrency(): void
+ public function test_no_conversion_when_order_currency_matches_fee_currency(): void
{
$this->config->method('get')->willReturn(true);
@@ -273,7 +277,7 @@ public function testNoConversionWhenOrderCurrencyMatchesFeeCurrency(): void
$this->assertGreaterThan(0, $result);
}
- public function testCurrencyConversionFromEurToUsd(): void
+ public function test_currency_conversion_from_eur_to_usd(): void
{
$this->config->method('get')->willReturn(true);
@@ -282,8 +286,8 @@ public function testCurrencyConversionFromEurToUsd(): void
$currencyConversionClient->expects($this->once())
->method('convert')
->with(
- $this->callback(fn(Currency $c) => $c->getCurrencyCode() === 'EUR'),
- $this->callback(fn(Currency $c) => $c->getCurrencyCode() === 'USD'),
+ $this->callback(fn (Currency $c) => $c->getCurrencyCode() === 'EUR'),
+ $this->callback(fn (Currency $c) => $c->getCurrencyCode() === 'USD'),
0.30
)
->willReturn(MoneyValue::fromFloat(0.33, 'USD'));
@@ -302,7 +306,7 @@ public function testCurrencyConversionFromEurToUsd(): void
$this->assertGreaterThan(0, $result);
}
- public function testZeroFixedFeeOnlyPercentage(): void
+ public function test_zero_fixed_fee_only_percentage(): void
{
$this->config->method('get')->willReturn(true);
@@ -319,7 +323,7 @@ public function testZeroFixedFeeOnlyPercentage(): void
$this->assertEqualsWithDelta($platformFee, $stripeAppFee, 0.01);
}
- public function testZeroPercentageOnlyFixedFee(): void
+ public function test_zero_percentage_only_fixed_fee(): void
{
$this->config->method('get')->willReturn(true);
@@ -332,7 +336,7 @@ public function testZeroPercentageOnlyFixedFee(): void
$this->assertEquals(0.50, $platformFee);
}
- public function testBothFeesZero(): void
+ public function test_both_fees_zero(): void
{
$this->config->method('get')->willReturn(true);
diff --git a/backend/tests/Unit/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentFailedHandlerTest.php b/backend/tests/Unit/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentFailedHandlerTest.php
new file mode 100644
index 0000000000..a1769fad91
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentFailedHandlerTest.php
@@ -0,0 +1,135 @@
+log->calls++;
+ }
+}
+
+final class StripePaymentUpdateCallLog
+{
+ public int $calls = 0;
+}
+
+class PaymentIntentFailedHandlerTest extends TestCase
+{
+ private OrderRepositoryInterface|MockInterface $orderRepository;
+
+ private StripePaymentsRepository|MockInterface $stripePaymentsRepository;
+
+ private StripePaymentUpdateCallLog $updateLog;
+
+ private PaymentIntentFailedHandler $handler;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ Event::fake();
+
+ $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class);
+ $this->stripePaymentsRepository = Mockery::mock(StripePaymentsRepository::class);
+ $this->updateLog = new StripePaymentUpdateCallLog;
+
+ $databaseManager = Mockery::mock(DatabaseManager::class);
+ $databaseManager->shouldReceive('transaction')->andReturnUsing(fn ($callback) => $callback());
+
+ $this->handler = new PaymentIntentFailedHandler(
+ $this->orderRepository,
+ $this->stripePaymentsRepository,
+ $databaseManager,
+ new RecordingStripePaymentUpdateService($this->updateLog),
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ public function test_downgrades_an_order_that_is_still_awaiting_payment(): void
+ {
+ $stripePayment = (new StripePaymentDomainObject)
+ ->setOrderId(1)
+ ->setPaymentIntentId('pi_test');
+
+ $this->stripePaymentsRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->stripePaymentsRepository->shouldReceive('findFirstWhere')->andReturn($stripePayment);
+
+ $this->orderRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ [OrderDomainObjectAbstract::PAYMENT_STATUS => OrderPaymentStatus::PAYMENT_FAILED->name],
+ [
+ OrderDomainObjectAbstract::ID => 1,
+ OrderDomainObjectAbstract::PAYMENT_STATUS => OrderPaymentStatus::AWAITING_PAYMENT->name,
+ ],
+ )
+ ->andReturn(1);
+
+ $updatedOrder = (new OrderDomainObject)->setId(1);
+ $this->orderRepository->shouldReceive('loadRelation')->with(OrderItemDomainObject::class)->andReturnSelf();
+ $this->orderRepository->shouldReceive('findById')->with(1)->andReturn($updatedOrder);
+
+ $this->handler->handleEvent(PaymentIntent::constructFrom(['id' => 'pi_test']));
+
+ $this->assertSame(1, $this->updateLog->calls);
+ Event::assertDispatched(OrderStatusChangedEvent::class);
+ }
+
+ public function test_late_failure_does_not_clobber_a_paid_order(): void
+ {
+ $stripePayment = (new StripePaymentDomainObject)
+ ->setOrderId(1)
+ ->setPaymentIntentId('pi_test');
+
+ $this->stripePaymentsRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->stripePaymentsRepository->shouldReceive('findFirstWhere')->andReturn($stripePayment);
+
+ $this->orderRepository->shouldReceive('updateWhere')->once()->andReturn(0);
+ $this->orderRepository->shouldNotReceive('findById');
+
+ $this->handler->handleEvent(PaymentIntent::constructFrom(['id' => 'pi_test']));
+
+ Event::assertNotDispatched(OrderStatusChangedEvent::class);
+ }
+
+ public function test_returns_quietly_when_no_stripe_payment_exists(): void
+ {
+ $this->stripePaymentsRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->stripePaymentsRepository->shouldReceive('findFirstWhere')->andReturnNull();
+
+ $this->orderRepository->shouldNotReceive('updateWhere');
+
+ $this->handler->handleEvent(PaymentIntent::constructFrom(['id' => 'pi_unknown']));
+
+ $this->assertSame(0, $this->updateLog->calls);
+ Event::assertNotDispatched(OrderStatusChangedEvent::class);
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentSucceededHandlerTest.php b/backend/tests/Unit/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentSucceededHandlerTest.php
new file mode 100644
index 0000000000..ad279a53d2
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Payment/Stripe/EventHandlers/PaymentIntentSucceededHandlerTest.php
@@ -0,0 +1,191 @@
+log->orderIds[] = (int) $order->getId();
+ }
+}
+
+final class RefundCallLog
+{
+ /** @var list */
+ public array $orderIds = [];
+}
+
+class PaymentIntentSucceededHandlerTest extends TestCase
+{
+ private OrderRepositoryInterface|MockInterface $orderRepository;
+
+ private StripePaymentsRepository|MockInterface $stripePaymentsRepository;
+
+ private EventOccurrenceRepositoryInterface|MockInterface $occurrenceRepository;
+
+ private RefundCallLog $refundLog;
+
+ private PaymentIntentSucceededHandler $handler;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->orderRepository = Mockery::mock(OrderRepositoryInterface::class);
+ $this->stripePaymentsRepository = Mockery::mock(StripePaymentsRepository::class);
+ $this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->refundLog = new RefundCallLog;
+
+ $databaseManager = Mockery::mock(DatabaseManager::class);
+ $databaseManager->shouldReceive('transaction')->andReturnUsing(fn ($callback) => $callback());
+
+ $cache = Mockery::mock(Repository::class);
+ $cache->shouldReceive('has')->andReturn(false);
+ $cache->shouldReceive('put')->andReturnTrue();
+
+ $this->handler = new PaymentIntentSucceededHandler(
+ $this->orderRepository,
+ $this->stripePaymentsRepository,
+ Mockery::mock(AffiliateRepositoryInterface::class),
+ Mockery::mock(ProductQuantityUpdateService::class),
+ new RecordingRefundExpiredOrderService($this->refundLog),
+ Mockery::mock(AttendeeRepositoryInterface::class),
+ $databaseManager,
+ Mockery::mock(LoggerInterface::class),
+ $cache,
+ Mockery::mock(DomainEventDispatcherService::class),
+ Mockery::mock(OrderApplicationFeeService::class),
+ Mockery::mock(EventSettingsRepositoryInterface::class),
+ new OccurrenceStatusValidator($this->occurrenceRepository),
+ );
+ }
+
+ public function test_cancelled_order_is_refunded_and_not_revived(): void
+ {
+ $this->assertLatePaymentRefundedAndRejected(OrderStatus::CANCELLED->name);
+ }
+
+ public function test_abandoned_order_is_refunded_and_not_revived(): void
+ {
+ $this->assertLatePaymentRefundedAndRejected(OrderStatus::ABANDONED->name);
+ }
+
+ public function test_already_paid_cancelled_order_is_rejected_without_a_second_refund(): void
+ {
+ $order = (new OrderDomainObject)
+ ->setId(1)
+ ->setStatus(OrderStatus::CANCELLED->name)
+ ->setPaymentStatus(OrderPaymentStatus::PAYMENT_RECEIVED->name);
+
+ $stripePayment = (new StripePaymentDomainObject)
+ ->setOrderId(1)
+ ->setPaymentIntentId('pi_test')
+ ->setOrder($order);
+
+ $this->stripePaymentsRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->stripePaymentsRepository->shouldReceive('findFirstWhere')->andReturn($stripePayment);
+
+ $this->orderRepository->shouldNotReceive('updateFromArray');
+
+ try {
+ $this->handler->handleEvent(PaymentIntent::constructFrom(['id' => 'pi_test']));
+ $this->fail('Expected CannotAcceptPaymentException was not thrown');
+ } catch (CannotAcceptPaymentException) {
+ }
+
+ $this->assertSame([], $this->refundLog->orderIds, 'An already-paid order must not be refunded on a duplicate webhook');
+ }
+
+ public function test_late_payment_on_a_cancelled_occurrence_is_refunded_and_rejected(): void
+ {
+ $orderItem = (new OrderItemDomainObject)->setEventOccurrenceId(5);
+
+ $order = (new OrderDomainObject)
+ ->setId(1)
+ ->setStatus(OrderStatus::RESERVED->name)
+ ->setPaymentStatus(OrderPaymentStatus::AWAITING_PAYMENT->name);
+ $order->setOrderItems(collect([$orderItem]));
+
+ $stripePayment = (new StripePaymentDomainObject)
+ ->setOrderId(1)
+ ->setPaymentIntentId('pi_test')
+ ->setOrder($order);
+
+ $this->stripePaymentsRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->stripePaymentsRepository->shouldReceive('findFirstWhere')->andReturn($stripePayment);
+
+ $cancelledOccurrence = (new EventOccurrenceDomainObject)->setStatus(EventOccurrenceStatus::CANCELLED->name);
+ $this->occurrenceRepository
+ ->shouldReceive('findWhereIn')
+ ->with('id', [5])
+ ->andReturn(collect([$cancelledOccurrence]));
+
+ $this->orderRepository->shouldNotReceive('updateFromArray');
+
+ try {
+ $this->handler->handleEvent(PaymentIntent::constructFrom(['id' => 'pi_test']));
+ $this->fail('Expected CannotAcceptPaymentException was not thrown');
+ } catch (CannotAcceptPaymentException) {
+ }
+
+ $this->assertSame([1], $this->refundLog->orderIds, 'A late payment on a cancelled occurrence should be refunded exactly once');
+ }
+
+ private function assertLatePaymentRefundedAndRejected(string $orderStatus): void
+ {
+ $order = (new OrderDomainObject)
+ ->setId(1)
+ ->setStatus($orderStatus)
+ ->setPaymentStatus(OrderPaymentStatus::AWAITING_PAYMENT->name);
+
+ $stripePayment = (new StripePaymentDomainObject)
+ ->setOrderId(1)
+ ->setPaymentIntentId('pi_test')
+ ->setOrder($order);
+
+ $this->stripePaymentsRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->stripePaymentsRepository->shouldReceive('findFirstWhere')->andReturn($stripePayment);
+
+ $this->orderRepository->shouldNotReceive('loadRelation');
+ $this->orderRepository->shouldNotReceive('updateFromArray');
+
+ try {
+ $this->handler->handleEvent(PaymentIntent::constructFrom(['id' => 'pi_test']));
+ $this->fail('Expected CannotAcceptPaymentException was not thrown');
+ } catch (CannotAcceptPaymentException) {
+ }
+
+ $this->assertSame([1], $this->refundLog->orderIds, 'The late payment should have been refunded exactly once');
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/Payment/Stripe/EventHandlers/PayoutPaidHandlerTest.php b/backend/tests/Unit/Services/Domain/Payment/Stripe/EventHandlers/PayoutPaidHandlerTest.php
index a69b5f014a..87db159cd6 100644
--- a/backend/tests/Unit/Services/Domain/Payment/Stripe/EventHandlers/PayoutPaidHandlerTest.php
+++ b/backend/tests/Unit/Services/Domain/Payment/Stripe/EventHandlers/PayoutPaidHandlerTest.php
@@ -21,10 +21,15 @@
class PayoutPaidHandlerTest extends TestCase
{
private PayoutPaidHandler $handler;
+
private StripePaymentsRepository $stripePaymentsRepository;
+
private StripeClientFactory $stripeClientFactory;
+
private LoggerInterface $logger;
+
private StripeConfigurationService $stripeConfigurationService;
+
private StripePayoutService $stripePayoutService;
protected function setUp(): void
@@ -46,7 +51,7 @@ protected function setUp(): void
);
}
- public function testHandleEventReconcilesPayout(): void
+ public function test_handle_event_reconciles_payout(): void
{
$payout = Payout::constructFrom([
'id' => 'po_123',
@@ -125,7 +130,7 @@ public function testHandleEventReconcilesPayout(): void
$this->stripePaymentsRepository->shouldReceive('updateWhere')
->with(
- m::on(fn($attrs) => $attrs[StripePaymentDomainObjectAbstract::PAYOUT_ID] === 'po_123' &&
+ m::on(fn ($attrs) => $attrs[StripePaymentDomainObjectAbstract::PAYOUT_ID] === 'po_123' &&
$attrs[StripePaymentDomainObjectAbstract::BALANCE_TRANSACTION_ID] === 'txn_123' &&
$attrs[StripePaymentDomainObjectAbstract::PAYOUT_STRIPE_FEE] === 50 &&
$attrs[StripePaymentDomainObjectAbstract::PAYOUT_NET_AMOUNT] === 535 &&
@@ -138,7 +143,7 @@ public function testHandleEventReconcilesPayout(): void
$this->stripePaymentsRepository->shouldReceive('updateWhere')
->with(
- m::on(fn($attrs) => $attrs[StripePaymentDomainObjectAbstract::PAYOUT_ID] === 'po_123' &&
+ m::on(fn ($attrs) => $attrs[StripePaymentDomainObjectAbstract::PAYOUT_ID] === 'po_123' &&
$attrs[StripePaymentDomainObjectAbstract::BALANCE_TRANSACTION_ID] === 'txn_456' &&
$attrs[StripePaymentDomainObjectAbstract::PAYOUT_STRIPE_FEE] === 100 &&
$attrs[StripePaymentDomainObjectAbstract::PAYOUT_NET_AMOUNT] === 1070 &&
@@ -157,6 +162,7 @@ public function testHandleEventReconcilesPayout(): void
->once()
->with(m::on(function ($dto) {
$this->assertEquals('po_123', $dto->payoutId);
+
return true;
}));
@@ -165,7 +171,7 @@ public function testHandleEventReconcilesPayout(): void
$this->assertTrue(true);
}
- public function testHandleEventSkipsNonPaidPayout(): void
+ public function test_handle_event_skips_non_paid_payout(): void
{
$payout = Payout::constructFrom([
'id' => 'po_123',
@@ -187,7 +193,7 @@ public function testHandleEventSkipsNonPaidPayout(): void
$this->assertTrue(true);
}
- public function testHandleEventSkipsTransactionsWithNoChargeId(): void
+ public function test_handle_event_skips_transactions_with_no_charge_id(): void
{
$payout = Payout::constructFrom([
'id' => 'po_123',
@@ -244,14 +250,14 @@ public function testHandleEventSkipsTransactionsWithNoChargeId(): void
$this->stripePayoutService->shouldReceive('createOrUpdatePayout')
->once()
- ->with(m::on(fn($dto) => $dto->payoutId === 'po_123'));
+ ->with(m::on(fn ($dto) => $dto->payoutId === 'po_123'));
$this->handler->handleEvent($payout);
$this->assertTrue(true);
}
- public function testHandleEventLogsWarningWhenPaymentNotFound(): void
+ public function test_handle_event_logs_warning_when_payment_not_found(): void
{
$payout = Payout::constructFrom([
'id' => 'po_123',
@@ -311,7 +317,7 @@ public function testHandleEventLogsWarningWhenPaymentNotFound(): void
$this->stripePayoutService->shouldReceive('createOrUpdatePayout')
->once()
- ->with(m::on(fn($dto) => $dto->payoutId === 'po_123'));
+ ->with(m::on(fn ($dto) => $dto->payoutId === 'po_123'));
$this->handler->handleEvent($payout);
diff --git a/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeAccountSyncServiceTest.php b/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeAccountSyncServiceTest.php
index b31d1814c7..0015a25e0c 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\OrganizerRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrganizerStripePlatformRepositoryInterface;
+use HiEvents\Repository\Interfaces\OrganizerVatSettingRepositoryInterface;
use HiEvents\Services\Domain\Payment\Stripe\StripeAccountSyncService;
use Illuminate\Config\Repository;
use Mockery as m;
@@ -17,10 +17,17 @@
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,61 +36,71 @@ 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,
);
}
- public function testIsStripeAccountCompleteReturnsTrueWhenBothEnabled(): void
+ public function test_is_stripe_account_complete_returns_true_when_both_enabled(): void
{
- $stripeAccount = new Account();
+ $stripeAccount = new Account;
$stripeAccount->charges_enabled = true;
$stripeAccount->payouts_enabled = true;
- $result = $this->service->isStripeAccountComplete($stripeAccount);
-
- $this->assertTrue($result);
- }
-
- public function testIsStripeAccountCompleteReturnsFalseWhenChargesDisabled(): void
- {
- $stripeAccount = new Account();
- $stripeAccount->charges_enabled = false;
- $stripeAccount->payouts_enabled = true;
-
- $result = $this->service->isStripeAccountComplete($stripeAccount);
-
- $this->assertFalse($result);
+ $this->assertTrue($this->service->isStripeAccountComplete($stripeAccount));
}
- public function testIsStripeAccountCompleteReturnsFalseWhenPayoutsDisabled(): void
+ public function test_is_stripe_account_complete_returns_false_when_anything_disabled(): void
{
- $stripeAccount = new Account();
- $stripeAccount->charges_enabled = true;
- $stripeAccount->payouts_enabled = false;
-
- $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 testIsStripeAccountCompleteReturnsFalseWhenBothDisabled(): void
+ public function test_sync_by_account_id_updates_all_organizer_rows_and_stops_if_incomplete(): 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/Payment/Stripe/StripePaymentIntentRefundServiceTest.php b/backend/tests/Unit/Services/Domain/Payment/Stripe/StripePaymentIntentRefundServiceTest.php
new file mode 100644
index 0000000000..eb5cbb8c10
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Payment/Stripe/StripePaymentIntentRefundServiceTest.php
@@ -0,0 +1,54 @@
+setId(1)
+ ->setPaymentIntentId('pi_test_abc123');
+
+ $refunds = Mockery::mock(RefundService::class);
+ $refunds->shouldReceive('create')
+ ->once()
+ ->withArgs(function (array $params, array $opts) {
+ return $params['payment_intent'] === 'pi_test_abc123'
+ && $params['amount'] === 1250
+ && $opts['idempotency_key'] === 'refund_pi_test_abc123_amount_1250';
+ })
+ ->andReturn(Refund::constructFrom(['id' => 're_test']));
+
+ $stripeClient = Mockery::mock(StripeClient::class);
+ $stripeClient->shouldReceive('getService')->with('refunds')->andReturn($refunds);
+
+ $service = new StripePaymentIntentRefundService(
+ new Repository(['app' => ['saas_mode_enabled' => false]]),
+ );
+
+ $refund = $service->refundPayment(
+ MoneyValue::fromFloat(12.50, 'USD'),
+ $payment,
+ $stripeClient,
+ );
+
+ $this->assertSame('re_test', $refund->id);
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/Payment/Stripe/StripePaymentPlatformFeeExtractionServiceTest.php b/backend/tests/Unit/Services/Domain/Payment/Stripe/StripePaymentPlatformFeeExtractionServiceTest.php
index 0609f19968..6c04d6b7b2 100644
--- a/backend/tests/Unit/Services/Domain/Payment/Stripe/StripePaymentPlatformFeeExtractionServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Payment/Stripe/StripePaymentPlatformFeeExtractionServiceTest.php
@@ -12,14 +12,19 @@
use Mockery as m;
use Psr\Log\LoggerInterface;
use Stripe\Charge;
+use Stripe\StripeClient;
use Tests\TestCase;
class StripePaymentPlatformFeeExtractionServiceTest extends TestCase
{
private StripePaymentPlatformFeeExtractionService $service;
+
private StripeClientFactory $stripeClientFactory;
+
private OrderPaymentPlatformFeeService $orderPaymentPlatformFeeService;
+
private OrderPaymentPlatformFeeRepositoryInterface $orderPaymentPlatformFeeRepository;
+
private LoggerInterface $logger;
protected function setUp(): void
@@ -39,7 +44,7 @@ protected function setUp(): void
);
}
- public function testExtractAndStorePlatformFeeNoBalanceTransaction(): void
+ public function test_extract_and_store_platform_fee_no_balance_transaction(): void
{
$order = m::mock(OrderDomainObject::class);
$order->shouldReceive('getId')->andReturn(123);
@@ -72,7 +77,7 @@ public function testExtractAndStorePlatformFeeNoBalanceTransaction(): void
])
->once();
- $stripeClient = m::mock(\Stripe\StripeClient::class);
+ $stripeClient = m::mock(StripeClient::class);
$chargesService = m::mock();
$stripeClient->charges = $chargesService;
@@ -101,7 +106,7 @@ public function testExtractAndStorePlatformFeeNoBalanceTransaction(): void
$this->assertTrue(true);
}
- public function testExtractAndStorePlatformFeeWithConnectedAccount(): void
+ public function test_extract_and_store_platform_fee_with_connected_account(): void
{
$order = m::mock(OrderDomainObject::class);
$order->shouldReceive('getId')->andReturn(123);
@@ -134,7 +139,7 @@ public function testExtractAndStorePlatformFeeWithConnectedAccount(): void
])
->once();
- $stripeClient = m::mock(\Stripe\StripeClient::class);
+ $stripeClient = m::mock(StripeClient::class);
$chargesService = m::mock();
$stripeClient->charges = $chargesService;
@@ -167,7 +172,7 @@ public function testExtractAndStorePlatformFeeWithConnectedAccount(): void
$this->assertTrue(true);
}
- public function testExtractAndStorePlatformFeeHandlesException(): void
+ public function test_extract_and_store_platform_fee_handles_exception(): void
{
$order = m::mock(OrderDomainObject::class);
$order->shouldReceive('getId')->andReturn(123);
@@ -215,7 +220,7 @@ public function testExtractAndStorePlatformFeeHandlesException(): void
$this->service->extractAndStorePlatformFee($order, $charge, $stripePayment);
}
- public function testExtractAndStorePlatformFeeWithVatAndExchangeRate(): void
+ public function test_extract_and_store_platform_fee_with_vat_and_exchange_rate(): void
{
Config::set('app.tax.eu_vat_handling_enabled', true);
@@ -228,20 +233,20 @@ public function testExtractAndStorePlatformFeeWithVatAndExchangeRate(): void
$stripePayment->shouldReceive('getApplicationFeeVat')->andReturn(83);
$stripePayment->shouldReceive('getApplicationFeeVatRate')->andReturn(0.20);
- $balanceTransaction = (object)[
+ $balanceTransaction = (object) [
'id' => 'txn_123',
'fee' => 1000,
'net' => 9000,
'currency' => 'eur',
'exchange_rate' => 1.17,
'fee_details' => [
- (object)[
+ (object) [
'type' => 'stripe_fee',
'amount' => 500,
'currency' => 'eur',
'description' => 'Stripe processing fee',
],
- (object)[
+ (object) [
'type' => 'application_fee',
'amount' => 500,
'currency' => 'eur',
@@ -250,7 +255,7 @@ public function testExtractAndStorePlatformFeeWithVatAndExchangeRate(): void
],
];
- $paymentIntent = (object)[
+ $paymentIntent = (object) [
'metadata' => [
'application_fee_gross_amount' => 5.00,
'application_fee_net_amount' => 4.17,
@@ -308,7 +313,7 @@ public function testExtractAndStorePlatformFeeWithVatAndExchangeRate(): void
$this->assertTrue(true);
}
- public function testExtractAndStorePlatformFeeWithVatDisabled(): void
+ public function test_extract_and_store_platform_fee_with_vat_disabled(): void
{
Config::set('app.tax.eu_vat_handling_enabled', false);
@@ -321,20 +326,20 @@ public function testExtractAndStorePlatformFeeWithVatDisabled(): void
$stripePayment->shouldReceive('getApplicationFeeVat')->never();
$stripePayment->shouldReceive('getApplicationFeeVatRate')->andReturn(null);
- $balanceTransaction = (object)[
+ $balanceTransaction = (object) [
'id' => 'txn_123',
'fee' => 1000,
'net' => 9000,
'currency' => 'eur',
'exchange_rate' => null,
'fee_details' => [
- (object)[
+ (object) [
'type' => 'stripe_fee',
'amount' => 500,
'currency' => 'eur',
'description' => 'Stripe processing fee',
],
- (object)[
+ (object) [
'type' => 'application_fee',
'amount' => 500,
'currency' => 'eur',
diff --git a/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeRefundExpiredOrderServiceTest.php b/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeRefundExpiredOrderServiceTest.php
new file mode 100644
index 0000000000..c5c5064f8a
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeRefundExpiredOrderServiceTest.php
@@ -0,0 +1,95 @@
+setId(1)
+ ->setEventId(2)
+ ->setEmail('buyer@example.com')
+ ->setLocale('en');
+
+ $stripePayment = (new StripePaymentDomainObject)
+ ->setOrderId(1)
+ ->setPaymentIntentId('pi_test');
+
+ $eventSettings = new EventSettingDomainObject;
+ $eventSettings->setSupportEmail('support@example.com');
+
+ $organizer = new OrganizerDomainObject;
+
+ $event = new EventDomainObject;
+ $event->setId(2);
+ $event->setEventSettings($eventSettings);
+ $event->setOrganizer($organizer);
+
+ $eventRepository = Mockery::mock(EventRepositoryInterface::class);
+ $eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $eventRepository->shouldReceive('findById')->with(2)->andReturn($event);
+
+ $stripeClient = Mockery::mock(StripeClient::class);
+ $clientFactory = Mockery::mock(StripeClientFactory::class);
+ $clientFactory->shouldReceive('createForPlatform')->andReturn($stripeClient);
+
+ $refundService = Mockery::mock(StripePaymentIntentRefundService::class);
+ $refundService->shouldReceive('refundPayment')->once();
+
+ $capturedMail = null;
+ $mailer = Mockery::mock(Mailer::class);
+ $mailer->shouldReceive('to')->with('buyer@example.com')->andReturnSelf();
+ $mailer->shouldReceive('locale')->with('en')->andReturnSelf();
+ $mailer->shouldReceive('send')
+ ->once()
+ ->with(Mockery::on(function (PaymentSuccessButOrderExpiredMail $mail) use (&$capturedMail) {
+ $capturedMail = $mail;
+
+ return true;
+ }));
+
+ $logger = Mockery::mock(LoggerInterface::class);
+ $logger->shouldReceive('info');
+
+ $service = new StripeRefundExpiredOrderService(
+ $refundService,
+ $mailer,
+ $logger,
+ $eventRepository,
+ $clientFactory,
+ );
+
+ $service->refundExpiredOrder(
+ paymentIntent: PaymentIntent::constructFrom(['id' => 'pi_test', 'amount' => 1000, 'currency' => 'usd']),
+ stripePayment: $stripePayment,
+ order: $order,
+ );
+
+ $this->assertNotNull($capturedMail);
+ $this->assertFalse($capturedMail->afterCommit, 'Refund notification must dispatch beforeCommit or the rollback discards it');
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/Product/ProductFilterServiceTest.php b/backend/tests/Unit/Services/Domain/Product/ProductFilterServiceTest.php
new file mode 100644
index 0000000000..cd9a3557b6
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Product/ProductFilterServiceTest.php
@@ -0,0 +1,158 @@
+fetchAvailableProductQuantitiesService = Mockery::mock(AvailableProductQuantitiesFetchService::class);
+ $this->eventRepository = Mockery::mock(EventRepositoryInterface::class);
+
+ $this->service = new ProductFilterService(
+ taxCalculationService: Mockery::mock(TaxAndFeeCalculationService::class),
+ productPriceService: Mockery::mock(ProductPriceService::class),
+ fetchAvailableProductQuantitiesService: $this->fetchAvailableProductQuantitiesService,
+ platformFeeService: Mockery::mock(OrderPlatformFeePassThroughService::class),
+ eventRepository: $this->eventRepository,
+ productOccurrenceVisibilityRepository: Mockery::mock(ProductOccurrenceVisibilityRepositoryInterface::class),
+ );
+ }
+
+ public function test_filter_products_accepts_a_flat_product_collection(): void
+ {
+ $product = $this->createFreeProduct(id: 1, priceId: 100);
+
+ $this->expectAccountConfigurationLoad();
+ $this->expectQuantities([
+ $this->createQuantityDto(productId: 1, priceId: 100, quantityAvailable: 5),
+ ]);
+
+ $result = $this->service->filterProducts(
+ products: collect([$product]),
+ hideSoldOutProducts: false,
+ );
+
+ $this->assertSame([$product], $result->all());
+ $this->assertSame(5, $product->getProductPrices()->first()->getQuantityAvailable());
+ $this->assertTrue($product->getProductPrices()->first()->isAvailable());
+ }
+
+ public function test_filter_products_rejects_hidden_products_when_hiding(): void
+ {
+ $visible = $this->createFreeProduct(id: 1, priceId: 100);
+ $hidden = $this->createFreeProduct(id: 2, priceId: 200)->setIsHidden(true);
+
+ $this->expectAccountConfigurationLoad();
+ $this->expectQuantities([
+ $this->createQuantityDto(productId: 1, priceId: 100, quantityAvailable: 5),
+ $this->createQuantityDto(productId: 2, priceId: 200, quantityAvailable: 5),
+ ]);
+
+ $result = $this->service->filterProducts(collect([$visible, $hidden]));
+
+ $this->assertSame([$visible], $result->all());
+ }
+
+ public function test_filter_products_returns_empty_collection_untouched(): void
+ {
+ $result = $this->service->filterProducts(collect());
+
+ $this->assertTrue($result->isEmpty());
+ }
+
+ public function test_filter_attaches_filtered_products_to_visible_categories(): void
+ {
+ $product = $this->createFreeProduct(id: 1, priceId: 100, productCategoryId: 5);
+ $visibleCategory = (new ProductCategoryDomainObject)->setId(5);
+ $visibleCategory->setProducts(collect([$product]));
+ $hiddenCategory = (new ProductCategoryDomainObject)->setId(6)->setIsHidden(true);
+ $hiddenCategory->setProducts(collect());
+
+ $this->expectAccountConfigurationLoad();
+ $this->expectQuantities([
+ $this->createQuantityDto(productId: 1, priceId: 100, quantityAvailable: 5),
+ ]);
+
+ $result = $this->service->filter(
+ productsCategories: collect([$visibleCategory, $hiddenCategory]),
+ hideSoldOutProducts: false,
+ );
+
+ $this->assertSame([$visibleCategory], $result->all());
+ $this->assertSame([$product], $result->first()->getProducts()->values()->all());
+ }
+
+ private function createFreeProduct(int $id, int $priceId, int $productCategoryId = 5): ProductDomainObject
+ {
+ return (new ProductDomainObject)
+ ->setId($id)
+ ->setEventId(self::EVENT_ID)
+ ->setProductCategoryId($productCategoryId)
+ ->setType(ProductPriceType::FREE->name)
+ ->setProductPrices(collect([
+ (new ProductPriceDomainObject)
+ ->setId($priceId)
+ ->setPrice(0.00),
+ ]));
+ }
+
+ private function createQuantityDto(int $productId, int $priceId, int $quantityAvailable): AvailableProductQuantitiesDTO
+ {
+ return new AvailableProductQuantitiesDTO(
+ product_id: $productId,
+ price_id: $priceId,
+ product_title: 'Ticket',
+ price_label: null,
+ quantity_available: $quantityAvailable,
+ quantity_reserved: 0,
+ initial_quantity_available: null,
+ product_type: 'TICKET',
+ );
+ }
+
+ private function expectAccountConfigurationLoad(): void
+ {
+ $this->eventRepository->shouldReceive('loadRelation')->twice()->andReturnSelf();
+ $this->eventRepository->shouldReceive('findById')
+ ->once()
+ ->with(self::EVENT_ID)
+ ->andReturn((new EventDomainObject)->setId(self::EVENT_ID)->setCurrency('USD'));
+ }
+
+ private function expectQuantities(array $quantities): void
+ {
+ $this->fetchAvailableProductQuantitiesService
+ ->shouldReceive('getAvailableProductQuantities')
+ ->once()
+ ->andReturn(new AvailableProductQuantitiesResponseDTO(productQuantities: collect($quantities)));
+ }
+}
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..f94aa80ec6
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Product/ProductPriceServiceTest.php
@@ -0,0 +1,300 @@
+priceOverrideRepository = Mockery::mock(ProductPriceOccurrenceOverrideRepositoryInterface::class);
+ $this->service = new ProductPriceService($this->priceOverrideRepository);
+ }
+
+ public function test_get_price_uses_override_when_present(): 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 test_get_price_falls_back_to_base_when_no_override(): 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 test_get_price_skips_override_lookup_without_occurrence(): 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 test_get_price_applies_promo_code_after_override(): 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('isOrderLevelDiscount')->andReturn(false);
+ $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 test_get_price_applies_fixed_per_product_discount_to_unit_price(): void
+ {
+ $product = $this->createProduct(ProductPriceType::PAID->name, 50.00);
+ $orderDetail = new OrderProductPriceDTO(quantity: 1, price_id: 100);
+
+ $result = $this->service->getPrice(
+ $product,
+ $orderDetail,
+ $this->createFixedPromoCode(PromoCodeDiscountAppliesToEnum::EACH_PRODUCT),
+ );
+
+ $this->assertEquals(40.00, $result->price);
+ $this->assertEquals(50.00, $result->price_before_discount);
+ }
+
+ public function test_get_price_ignores_order_level_fixed_discount(): void
+ {
+ $product = $this->createProduct(ProductPriceType::PAID->name, 50.00);
+ $orderDetail = new OrderProductPriceDTO(quantity: 1, price_id: 100);
+
+ $result = $this->service->getPrice(
+ $product,
+ $orderDetail,
+ $this->createFixedPromoCode(PromoCodeDiscountAppliesToEnum::ORDER),
+ );
+
+ $this->assertEquals(50.00, $result->price);
+ $this->assertNull($result->price_before_discount);
+ }
+
+ public function test_percentage_discount_stored_as_order_level_still_discounts_unit_price(): void
+ {
+ $product = $this->createProduct(ProductPriceType::PAID->name, 50.00);
+ $orderDetail = new OrderProductPriceDTO(quantity: 1, price_id: 100);
+
+ $promoCode = (new PromoCodeDomainObject)
+ ->setDiscountType(PromoCodeDiscountTypeEnum::PERCENTAGE->name)
+ ->setDiscountAppliesTo(PromoCodeDiscountAppliesToEnum::ORDER->name)
+ ->setDiscount(10.00);
+
+ $result = $this->service->getPrice($product, $orderDetail, $promoCode);
+
+ $this->assertEquals(45.00, $result->price);
+ $this->assertEquals(50.00, $result->price_before_discount);
+ }
+
+ private function createFixedPromoCode(PromoCodeDiscountAppliesToEnum $appliesTo): PromoCodeDomainObject
+ {
+ return (new PromoCodeDomainObject)
+ ->setDiscountType(PromoCodeDiscountTypeEnum::FIXED->name)
+ ->setDiscountAppliesTo($appliesTo->name)
+ ->setDiscount(10.00);
+ }
+
+ public function test_donation_keeps_donor_amount_above_override_minimum(): void
+ {
+ $product = $this->createProduct(ProductPriceType::DONATION->name, 10.00);
+ $orderDetail = new OrderProductPriceDTO(quantity: 1, price_id: 100, price: 50.00);
+
+ $override = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+ $override->shouldReceive('getPrice')->andReturn('25.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(50.00, $result->price);
+ }
+
+ public function test_donation_enforces_override_as_minimum(): void
+ {
+ $product = $this->createProduct(ProductPriceType::DONATION->name, 10.00);
+ $orderDetail = new OrderProductPriceDTO(quantity: 1, price_id: 100, price: 10.00);
+
+ $override = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+ $override->shouldReceive('getPrice')->andReturn('25.00');
+
+ $this->priceOverrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn($override);
+
+ $result = $this->service->getPrice($product, $orderDetail, null, 5);
+
+ $this->assertEquals(25.00, $result->price);
+ }
+
+ public function test_donation_without_override_keeps_donor_amount(): void
+ {
+ $product = $this->createProduct(ProductPriceType::DONATION->name, 10.00);
+ $orderDetail = new OrderProductPriceDTO(quantity: 1, price_id: 100, price: 50.00);
+
+ $this->priceOverrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn(null);
+
+ $result = $this->service->getPrice($product, $orderDetail, null, 5);
+
+ $this->assertEquals(50.00, $result->price);
+ }
+
+ public function test_donation_without_override_enforces_product_price_minimum(): void
+ {
+ $product = $this->createProduct(ProductPriceType::DONATION->name, 10.00);
+ $orderDetail = new OrderProductPriceDTO(quantity: 1, price_id: 100, price: 5.00);
+
+ $this->priceOverrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn(null);
+
+ $result = $this->service->getPrice($product, $orderDetail, null, 5);
+
+ $this->assertEquals(10.00, $result->price);
+ }
+
+ public function test_donation_minimum_price_uses_override(): void
+ {
+ $product = $this->createProduct(ProductPriceType::DONATION->name, 10.00);
+
+ $override = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+ $override->shouldReceive('getPrice')->andReturn('25.00');
+
+ $this->priceOverrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->with([
+ 'event_occurrence_id' => 5,
+ 'product_price_id' => 100,
+ ])
+ ->andReturn($override);
+
+ $this->assertEquals(25.00, $this->service->getDonationMinimumPrice($product, 100, 5));
+ }
+
+ public function test_donation_minimum_price_falls_back_to_product_price(): void
+ {
+ $product = $this->createProduct(ProductPriceType::DONATION->name, 10.00);
+
+ $this->priceOverrideRepository
+ ->shouldReceive('findFirstWhere')
+ ->andReturn(null);
+
+ $this->assertEquals(10.00, $this->service->getDonationMinimumPrice($product, 100, 5));
+ }
+
+ public function test_donation_minimum_price_without_occurrence_skips_override_lookup(): void
+ {
+ $product = $this->createProduct(ProductPriceType::DONATION->name, 10.00);
+
+ $this->priceOverrideRepository->shouldNotReceive('findFirstWhere');
+
+ $this->assertEquals(10.00, $this->service->getDonationMinimumPrice($product, 100, null));
+ }
+
+ public function test_get_price_returns_free_for_free_product(): 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/ProductPriceUpdateServiceTest.php b/backend/tests/Unit/Services/Domain/Product/ProductPriceUpdateServiceTest.php
index f7dd8d99e9..a988152d81 100644
--- a/backend/tests/Unit/Services/Domain/Product/ProductPriceUpdateServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Product/ProductPriceUpdateServiceTest.php
@@ -20,6 +20,7 @@
class ProductPriceUpdateServiceTest extends TestCase
{
private ProductPriceRepository|MockInterface $productPriceRepository;
+
private ProductPriceUpdateService $service;
protected function setUp(): void
@@ -36,7 +37,7 @@ protected function tearDown(): void
parent::tearDown();
}
- public function testThrowsWhenInitialQuantityAvailableIsLessThanQuantitySold(): void
+ public function test_throws_when_initial_quantity_available_is_less_than_quantity_sold(): void
{
$existingPrices = new Collection([$this->createExistingPrice(id: 1, quantitySold: 10, label: 'Early Bird')]);
[$product, $event] = $this->createProductAndEvent($existingPrices);
@@ -49,7 +50,7 @@ public function testThrowsWhenInitialQuantityAvailableIsLessThanQuantitySold():
$this->service->updatePrices($product, $productsData, $existingPrices, $event);
}
- public function testAllowsInitialQuantityAvailableEqualToQuantitySold(): void
+ public function test_allows_initial_quantity_available_equal_to_quantity_sold(): void
{
$existingPrices = new Collection([$this->createExistingPrice(id: 1, quantitySold: 10, label: 'Early Bird')]);
[$product, $event] = $this->createProductAndEvent($existingPrices);
@@ -64,7 +65,7 @@ public function testAllowsInitialQuantityAvailableEqualToQuantitySold(): void
$this->assertTrue(true);
}
- public function testAllowsNullInitialQuantityAvailable(): void
+ public function test_allows_null_initial_quantity_available(): void
{
$existingPrices = new Collection([$this->createExistingPrice(id: 1, quantitySold: 10, label: 'Early Bird')]);
[$product, $event] = $this->createProductAndEvent($existingPrices);
@@ -79,7 +80,7 @@ public function testAllowsNullInitialQuantityAvailable(): void
$this->assertTrue(true);
}
- public function testThrowsForCorrectTierInTieredProduct(): void
+ public function test_throws_for_correct_tier_in_tiered_product(): void
{
$existingPrices = new Collection([
$this->createExistingPrice(id: 1, quantitySold: 5, label: 'Tier 1'),
@@ -109,6 +110,7 @@ private function createExistingPrice(int $id, int $quantitySold, string $label):
$price->shouldReceive('getId')->andReturn($id);
$price->shouldReceive('getQuantitySold')->andReturn($quantitySold);
$price->shouldReceive('getLabel')->andReturn($label);
+
return $price;
}
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..a1a6cf4b34
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Product/ProductQuantityUpdateServiceTest.php
@@ -0,0 +1,349 @@
+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 test_increase_quantity_sold_increments_occurrence_capacity(): 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 test_decrease_quantity_sold_decrements_occurrence_capacity(): 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 test_increase_quantity_sold_skips_occurrence_when_null(): 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 test_update_quantities_from_order_passes_occurrence_id(): 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 test_update_quantities_from_order_skips_occurrence_capacity_for_general_products(): void
+ {
+ $orderItem = (new OrderItemDomainObject)
+ ->setId(1)
+ ->setProductPriceId(100)
+ ->setQuantity(2)
+ ->setProductType(ProductType::GENERAL->name)
+ ->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->shouldNotReceive('updateWhere');
+ $this->occurrenceRepository->shouldNotReceive('findById');
+
+ $this->service->updateQuantitiesFromOrder($order);
+ }
+
+ public function test_increase_quantity_sold_only_updates_occurrence_used_capacity(): 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();
+ $this->occurrenceRepository->shouldNotReceive('findById');
+
+ $this->service->increaseQuantitySold($priceId, 1, $occurrenceId);
+ }
+
+ public function test_decrease_quantity_sold_only_updates_occurrence_used_capacity(): 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();
+ $this->occurrenceRepository->shouldNotReceive('findById');
+
+ $this->service->decreaseQuantitySold($priceId, 1, $occurrenceId);
+ }
+
+ protected function tearDown(): void
+ {
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/PromoCode/PromoCodeUsageValidationServiceTest.php b/backend/tests/Unit/Services/Domain/PromoCode/PromoCodeUsageValidationServiceTest.php
index bf6df6a681..624a7df988 100644
--- a/backend/tests/Unit/Services/Domain/PromoCode/PromoCodeUsageValidationServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/PromoCode/PromoCodeUsageValidationServiceTest.php
@@ -2,10 +2,10 @@
namespace Tests\Unit\Services\Domain\PromoCode;
+use Carbon\Carbon;
use HiEvents\DomainObjects\PromoCodeDomainObject;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Services\Domain\PromoCode\PromoCodeUsageValidationService;
-use Carbon\Carbon;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use Tests\TestCase;
@@ -16,16 +16,16 @@ class PromoCodeUsageValidationServiceTest extends TestCase
private const PROMO_CODE_ID = 5;
- public function testNullPromoCodeIsNotUsable(): void
+ public function test_null_promo_code_is_not_usable(): void
{
$service = new PromoCodeUsageValidationService(Mockery::mock(OrderRepositoryInterface::class));
$this->assertFalse($service->isPromoCodeUsable(null));
}
- public function testExpiredPromoCodeIsNotUsable(): void
+ public function test_expired_promo_code_is_not_usable(): void
{
- $promoCode = (new PromoCodeDomainObject())
+ $promoCode = (new PromoCodeDomainObject)
->setId(self::PROMO_CODE_ID)
->setExpiryDate(Carbon::now()->subDay()->toDateTimeString());
@@ -37,9 +37,9 @@ public function testExpiredPromoCodeIsNotUsable(): void
$this->assertFalse($service->isPromoCodeUsable($promoCode));
}
- public function testPromoCodeWithNoUsageLimitIsUsable(): void
+ public function test_promo_code_with_no_usage_limit_is_usable(): void
{
- $promoCode = (new PromoCodeDomainObject())
+ $promoCode = (new PromoCodeDomainObject)
->setId(self::PROMO_CODE_ID)
->setMaxAllowedUsages(null);
@@ -51,9 +51,9 @@ public function testPromoCodeWithNoUsageLimitIsUsable(): void
$this->assertTrue($service->isPromoCodeUsable($promoCode));
}
- public function testPromoCodeIsUsableWhenLiveCountIsUnderLimit(): void
+ public function test_promo_code_is_usable_when_live_count_is_under_limit(): void
{
- $promoCode = (new PromoCodeDomainObject())
+ $promoCode = (new PromoCodeDomainObject)
->setId(self::PROMO_CODE_ID)
->setMaxAllowedUsages(2)
->setOrderUsageCount(0);
@@ -68,10 +68,10 @@ public function testPromoCodeIsUsableWhenLiveCountIsUnderLimit(): void
$this->assertTrue($service->isPromoCodeUsable($promoCode));
}
- public function testPromoCodeIsNotUsableWhenLiveCountReachesLimit(): void
+ public function test_promo_code_is_not_usable_when_live_count_reaches_limit(): void
{
// Stale order_usage_count is 0 (passes isValid), but the live count has reached the limit.
- $promoCode = (new PromoCodeDomainObject())
+ $promoCode = (new PromoCodeDomainObject)
->setId(self::PROMO_CODE_ID)
->setMaxAllowedUsages(1)
->setOrderUsageCount(0);
diff --git a/backend/tests/Unit/Services/Domain/Report/Factory/OrganizerReportServiceFactoryTest.php b/backend/tests/Unit/Services/Domain/Report/Factory/OrganizerReportServiceFactoryTest.php
index 5f31d8e029..ede20156f4 100644
--- a/backend/tests/Unit/Services/Domain/Report/Factory/OrganizerReportServiceFactoryTest.php
+++ b/backend/tests/Unit/Services/Domain/Report/Factory/OrganizerReportServiceFactoryTest.php
@@ -4,10 +4,10 @@
use HiEvents\DomainObjects\Enums\OrganizerReportTypes;
use HiEvents\Services\Domain\Report\Factory\OrganizerReportServiceFactory;
+use HiEvents\Services\Domain\Report\OrganizerReports\CheckInSummaryReport;
use HiEvents\Services\Domain\Report\OrganizerReports\EventsPerformanceReport;
use HiEvents\Services\Domain\Report\OrganizerReports\RevenueSummaryReport;
use HiEvents\Services\Domain\Report\OrganizerReports\TaxSummaryReport;
-use HiEvents\Services\Domain\Report\OrganizerReports\CheckInSummaryReport;
use Tests\TestCase;
class OrganizerReportServiceFactoryTest extends TestCase
@@ -17,31 +17,31 @@ class OrganizerReportServiceFactoryTest extends TestCase
protected function setUp(): void
{
parent::setUp();
- $this->factory = new OrganizerReportServiceFactory();
+ $this->factory = new OrganizerReportServiceFactory;
}
- public function testCreateReturnsRevenueSummaryReport(): void
+ public function test_create_returns_revenue_summary_report(): void
{
$reportService = $this->factory->create(OrganizerReportTypes::REVENUE_SUMMARY);
$this->assertInstanceOf(RevenueSummaryReport::class, $reportService);
}
- public function testCreateReturnsEventsPerformanceReport(): void
+ public function test_create_returns_events_performance_report(): void
{
$reportService = $this->factory->create(OrganizerReportTypes::EVENTS_PERFORMANCE);
$this->assertInstanceOf(EventsPerformanceReport::class, $reportService);
}
- public function testCreateReturnsTaxSummaryReport(): void
+ public function test_create_returns_tax_summary_report(): void
{
$reportService = $this->factory->create(OrganizerReportTypes::TAX_SUMMARY);
$this->assertInstanceOf(TaxSummaryReport::class, $reportService);
}
- public function testCreateReturnsCheckInSummaryReport(): void
+ public function test_create_returns_check_in_summary_report(): void
{
$reportService = $this->factory->create(OrganizerReportTypes::CHECK_IN_SUMMARY);
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..0df65af47c
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Report/ReportServiceTest.php
@@ -0,0 +1,169 @@
+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 test_product_sales_report_generates_without_occurrence(): 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 test_product_sales_report_generates_with_occurrence(): 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 test_daily_sales_report_uses_event_daily_stats_without_occurrence(): 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 test_daily_sales_report_uses_occurrence_daily_stats_with_occurrence(): 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 test_promo_codes_report_generates_with_occurrence(): 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 test_promo_codes_report_generates_without_occurrence(): 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 test_occurrence_summary_report_generates(): void
+ {
+ $this->setupCachePassthrough();
+ $this->queryBuilder->shouldReceive('select')
+ ->once()
+ ->with(
+ Mockery::on(fn ($sql) => str_contains($sql, 'event_occurrences')
+ && str_contains($sql, 'event_occurrence_statistics')
+ && str_contains($sql, 'WHERE eo.event_id = :event_id')
+ && ! str_contains($sql, ':start_date')
+ && ! str_contains($sql, ':end_date')),
+ Mockery::on(fn ($bindings) => $bindings === ['event_id' => 1]),
+ )
+ ->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/OrderAuditLogServiceTest.php b/backend/tests/Unit/Services/Domain/SelfService/OrderAuditLogServiceTest.php
index 1f907ce078..46fb6b6408 100644
--- a/backend/tests/Unit/Services/Domain/SelfService/OrderAuditLogServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/SelfService/OrderAuditLogServiceTest.php
@@ -15,6 +15,7 @@
class OrderAuditLogServiceTest extends TestCase
{
private OrderAuditLogService $service;
+
private MockInterface|OrderAuditLogRepositoryInterface $orderAuditLogRepository;
protected function setUp(): void
@@ -28,7 +29,7 @@ protected function setUp(): void
);
}
- public function testLogAttendeeUpdateCreatesAuditLogEntry(): void
+ public function test_log_attendee_update_creates_audit_log_entry(): void
{
$attendee = Mockery::mock(AttendeeDomainObject::class);
$attendee->shouldReceive('getEventId')->andReturn(1);
@@ -53,7 +54,7 @@ public function testLogAttendeeUpdateCreatesAuditLogEntry(): void
$this->orderAuditLogRepository
->shouldReceive('create')
->once()
- ->withArgs(function ($data) use ($attendee, $oldValues, $newValues, $ipAddress, $userAgent) {
+ ->withArgs(function ($data) use ($oldValues, $newValues, $ipAddress, $userAgent) {
return $data['event_id'] === 1
&& $data['order_id'] === 123
&& $data['attendee_id'] === 456
@@ -77,7 +78,7 @@ public function testLogAttendeeUpdateCreatesAuditLogEntry(): void
$this->assertTrue(true);
}
- public function testLogOrderUpdateCreatesAuditLogEntry(): void
+ public function test_log_order_update_creates_audit_log_entry(): void
{
$order = Mockery::mock(OrderDomainObject::class);
$order->shouldReceive('getEventId')->andReturn(1);
@@ -101,7 +102,7 @@ public function testLogOrderUpdateCreatesAuditLogEntry(): void
$this->orderAuditLogRepository
->shouldReceive('create')
->once()
- ->withArgs(function ($data) use ($order, $oldValues, $newValues, $ipAddress, $userAgent) {
+ ->withArgs(function ($data) use ($oldValues, $newValues, $ipAddress, $userAgent) {
return $data['event_id'] === 1
&& $data['order_id'] === 123
&& $data['attendee_id'] === null
@@ -125,7 +126,7 @@ public function testLogOrderUpdateCreatesAuditLogEntry(): void
$this->assertTrue(true);
}
- public function testLogEmailResentForAttendee(): void
+ public function test_log_email_resent_for_attendee(): void
{
$action = OrderAuditAction::ATTENDEE_EMAIL_RESENT->value;
$eventId = 1;
@@ -164,7 +165,7 @@ public function testLogEmailResentForAttendee(): void
$this->assertTrue(true);
}
- public function testLogEmailResentForOrder(): void
+ public function test_log_email_resent_for_order(): void
{
$action = OrderAuditAction::ORDER_EMAIL_RESENT->value;
$eventId = 1;
diff --git a/backend/tests/Unit/Services/Domain/SelfService/SelfServiceEditAttendeeServiceTest.php b/backend/tests/Unit/Services/Domain/SelfService/SelfServiceEditAttendeeServiceTest.php
index 6b9364c230..951376f312 100644
--- a/backend/tests/Unit/Services/Domain/SelfService/SelfServiceEditAttendeeServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/SelfService/SelfServiceEditAttendeeServiceTest.php
@@ -22,9 +22,13 @@
class SelfServiceEditAttendeeServiceTest extends TestCase
{
private SelfServiceEditAttendeeService $service;
+
private MockInterface|AttendeeRepositoryInterface $attendeeRepository;
+
private MockInterface|EventRepositoryInterface $eventRepository;
+
private MockInterface|OrderAuditLogService $orderAuditLogService;
+
private MockInterface|SendAttendeeTicketService $sendAttendeeTicketService;
protected function setUp(): void
@@ -46,7 +50,7 @@ protected function setUp(): void
);
}
- public function testSuccessfulEditUpdatesAttendeeFields(): void
+ public function test_successful_edit_updates_attendee_fields(): void
{
$attendee = Mockery::mock(AttendeeDomainObject::class);
$attendee->shouldReceive('getId')->andReturn(456);
@@ -128,7 +132,7 @@ public function testSuccessfulEditUpdatesAttendeeFields(): void
});
}
- public function testEmailChangeTriggersShortIdRotation(): void
+ public function test_email_change_triggers_short_id_rotation(): void
{
$attendee = Mockery::mock(AttendeeDomainObject::class);
$attendee->shouldReceive('getId')->andReturn(456);
@@ -220,7 +224,7 @@ public function testEmailChangeTriggersShortIdRotation(): void
});
}
- public function testNoUpdateWhenNoFieldsChange(): void
+ public function test_no_update_when_no_fields_change(): void
{
$attendee = Mockery::mock(AttendeeDomainObject::class);
$attendee->shouldReceive('getId')->andReturn(456);
@@ -248,7 +252,7 @@ public function testNoUpdateWhenNoFieldsChange(): void
Mail::assertNothingSent();
}
- public function testMultipleFieldsUpdateTogether(): void
+ public function test_multiple_fields_update_together(): void
{
$attendee = Mockery::mock(AttendeeDomainObject::class);
$attendee->shouldReceive('getId')->andReturn(456);
diff --git a/backend/tests/Unit/Services/Domain/SelfService/SelfServiceEditOrderServiceTest.php b/backend/tests/Unit/Services/Domain/SelfService/SelfServiceEditOrderServiceTest.php
index be9619ab39..53a4b0b8c8 100644
--- a/backend/tests/Unit/Services/Domain/SelfService/SelfServiceEditOrderServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/SelfService/SelfServiceEditOrderServiceTest.php
@@ -20,9 +20,13 @@
class SelfServiceEditOrderServiceTest extends TestCase
{
private SelfServiceEditOrderService $service;
+
private MockInterface|OrderRepositoryInterface $orderRepository;
+
private MockInterface|EventRepositoryInterface $eventRepository;
+
private MockInterface|OrderAuditLogService $orderAuditLogService;
+
private MockInterface|SendOrderDetailsService $sendOrderDetailsService;
protected function setUp(): void
@@ -44,7 +48,7 @@ protected function setUp(): void
);
}
- public function testSuccessfulEditUpdatesOrderFields(): void
+ public function test_successful_edit_updates_order_fields(): void
{
$order = Mockery::mock(OrderDomainObject::class);
$order->shouldReceive('getId')->andReturn(123);
@@ -111,7 +115,7 @@ public function testSuccessfulEditUpdatesOrderFields(): void
});
}
- public function testEmailChangeTriggersShortIdRotation(): void
+ public function test_email_change_triggers_short_id_rotation(): void
{
$order = Mockery::mock(OrderDomainObject::class);
$order->shouldReceive('getId')->andReturn(123);
@@ -193,7 +197,7 @@ public function testEmailChangeTriggersShortIdRotation(): void
$this->assertTrue($result->emailChanged);
}
- public function testNoUpdateWhenNoFieldsChange(): void
+ public function test_no_update_when_no_fields_change(): void
{
$order = Mockery::mock(OrderDomainObject::class);
$order->shouldReceive('getId')->andReturn(123);
@@ -221,7 +225,7 @@ public function testNoUpdateWhenNoFieldsChange(): void
Mail::assertNothingSent();
}
- public function testMultipleFieldsUpdateTogether(): void
+ public function test_multiple_fields_update_together(): void
{
$order = Mockery::mock(OrderDomainObject::class);
$order->shouldReceive('getId')->andReturn(123);
@@ -311,7 +315,7 @@ public function testMultipleFieldsUpdateTogether(): void
$this->assertTrue($result->emailChanged);
}
- public function testOnlyEmailUpdate(): void
+ public function test_only_email_update(): void
{
$order = Mockery::mock(OrderDomainObject::class);
$order->shouldReceive('getId')->andReturn(123);
@@ -384,7 +388,7 @@ public function testOnlyEmailUpdate(): void
$this->assertTrue($result->emailChanged);
}
- public function testOnlyFirstNameUpdate(): void
+ public function test_only_first_name_update(): void
{
$order = Mockery::mock(OrderDomainObject::class);
$order->shouldReceive('getId')->andReturn(123);
diff --git a/backend/tests/Unit/Services/Domain/SelfService/SelfServiceResendEmailServiceTest.php b/backend/tests/Unit/Services/Domain/SelfService/SelfServiceResendEmailServiceTest.php
index ef83d435a8..8ebf1cc0f7 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;
@@ -75,7 +81,6 @@ public function testResendAttendeeTicketSuccessfully(): void
$this->attendeeRepository
->shouldReceive('loadRelation')
- ->once()
->with(Mockery::type(Relationship::class))
->andReturnSelf();
@@ -91,7 +96,6 @@ public function testResendAttendeeTicketSuccessfully(): void
$this->eventRepository
->shouldReceive('loadRelation')
- ->twice()
->andReturnSelf();
$this->eventRepository
@@ -134,7 +138,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 +161,6 @@ public function testResendOrderConfirmationSuccessfully(): void
$this->orderRepository
->shouldReceive('loadRelation')
- ->times(3)
->andReturnSelf();
$this->orderRepository
@@ -171,7 +174,6 @@ public function testResendOrderConfirmationSuccessfully(): void
$this->eventRepository
->shouldReceive('loadRelation')
- ->twice()
->andReturnSelf();
$this->eventRepository
@@ -213,7 +215,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;
@@ -231,12 +233,7 @@ public function testResendAttendeeTicketLoadsCorrectRelationships(): void
$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 +243,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 +269,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 +284,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 +295,6 @@ public function testResendOrderConfirmationLoadsCorrectRelationships(): void
$this->eventRepository
->shouldReceive('loadRelation')
- ->twice()
->andReturnSelf();
$this->eventRepository
diff --git a/backend/tests/Unit/Services/Domain/Sitemap/SitemapGeneratorServiceTest.php b/backend/tests/Unit/Services/Domain/Sitemap/SitemapGeneratorServiceTest.php
index 3c151e9cd2..4c2ad15d7c 100644
--- a/backend/tests/Unit/Services/Domain/Sitemap/SitemapGeneratorServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Sitemap/SitemapGeneratorServiceTest.php
@@ -19,10 +19,10 @@ class SitemapGeneratorServiceTest extends TestCase
protected function setUp(): void
{
parent::setUp();
- $this->service = new SitemapGeneratorService();
+ $this->service = new SitemapGeneratorService;
}
- public function testGenerateSitemapIndexWithSinglePage(): void
+ public function test_generate_sitemap_index_with_single_page(): void
{
$baseUrl = 'https://example.com';
$lastMod = '2025-01-15T10:30:00+00:00';
@@ -37,7 +37,7 @@ public function testGenerateSitemapIndexWithSinglePage(): void
$this->assertStringContainsString('', $xml);
}
- public function testGenerateSitemapIndexWithMultiplePages(): void
+ public function test_generate_sitemap_index_with_multiple_pages(): void
{
$baseUrl = 'https://example.com';
$lastMod = '2025-01-15T10:30:00+00:00';
@@ -53,17 +53,17 @@ public function testGenerateSitemapIndexWithMultiplePages(): void
$this->assertStringNotContainsString('sitemap-organizers-3.xml', $xml);
}
- public function testGenerateSitemapIndexIsValidXml(): void
+ public function test_generate_sitemap_index_is_valid_xml(): void
{
$xml = $this->service->generateSitemapIndex(2, 1, 'https://example.com', '2025-01-15T10:30:00+00:00');
- $dom = new \DOMDocument();
+ $dom = new \DOMDocument;
$result = $dom->loadXML($xml);
$this->assertTrue($result, 'Generated XML should be valid');
}
- public function testGenerateEventsSitemapWithUpcomingEvent(): void
+ public function test_generate_events_sitemap_with_upcoming_event(): void
{
Carbon::setTestNow('2025-01-15 10:00:00');
@@ -88,7 +88,7 @@ public function testGenerateEventsSitemapWithUpcomingEvent(): void
Carbon::setTestNow();
}
- public function testGenerateEventsSitemapWithPastEvent(): void
+ public function test_generate_events_sitemap_with_past_event(): void
{
Carbon::setTestNow('2025-01-15 10:00:00');
@@ -111,7 +111,7 @@ public function testGenerateEventsSitemapWithPastEvent(): void
Carbon::setTestNow();
}
- public function testGenerateEventsSitemapWithSpecialCharactersInTitle(): void
+ public function test_generate_events_sitemap_with_special_characters_in_title(): void
{
$event = $this->createMockEvent(
id: 789,
@@ -123,14 +123,14 @@ public function testGenerateEventsSitemapWithSpecialCharactersInTitle(): void
$events = new Collection([$event]);
$xml = $this->service->generateEventsSitemap($events, 'https://example.com');
- $dom = new \DOMDocument();
+ $dom = new \DOMDocument;
$result = $dom->loadXML($xml);
$this->assertTrue($result, 'XML with special characters should be valid');
$this->assertStringContainsString('event-with-special-characters-quotes', $xml);
}
- public function testGenerateEventsSitemapWithEmptySlugFallsBackToDefault(): void
+ public function test_generate_events_sitemap_with_empty_slug_falls_back_to_default(): void
{
$event = $this->createMockEvent(
id: 101,
@@ -144,11 +144,11 @@ public function testGenerateEventsSitemapWithEmptySlugFallsBackToDefault(): void
$this->assertStringContainsString('/event/101/', $xml);
- $dom = new \DOMDocument();
+ $dom = new \DOMDocument;
$this->assertTrue($dom->loadXML($xml));
}
- public function testGenerateEventsSitemapWithNullStartDate(): void
+ public function test_generate_events_sitemap_with_null_start_date(): void
{
Carbon::setTestNow('2025-01-15 10:00:00');
@@ -168,7 +168,7 @@ public function testGenerateEventsSitemapWithNullStartDate(): void
Carbon::setTestNow();
}
- public function testGenerateEventsSitemapIncludesLastModFromUpdatedAt(): void
+ public function test_generate_events_sitemap_includes_last_mod_from_updated_at(): void
{
Carbon::setTestNow(Carbon::parse('2025-01-15 10:00:00', 'UTC'));
@@ -185,13 +185,13 @@ public function testGenerateEventsSitemapIncludesLastModFromUpdatedAt(): void
$this->assertStringContainsString('', $xml);
$this->assertStringContainsString('2025-01-12', $xml);
- $dom = new \DOMDocument();
+ $dom = new \DOMDocument;
$this->assertTrue($dom->loadXML($xml));
Carbon::setTestNow();
}
- public function testGenerateEventsSitemapWithEmptyCollection(): void
+ public function test_generate_events_sitemap_with_empty_collection(): void
{
$events = new Collection([]);
$xml = $this->service->generateEventsSitemap($events, 'https://example.com');
@@ -200,11 +200,11 @@ public function testGenerateEventsSitemapWithEmptyCollection(): void
$this->assertStringContainsString('http://www.sitemaps.org/schemas/sitemap/0.9', $xml);
$this->assertStringNotContainsString('', $xml);
- $dom = new \DOMDocument();
+ $dom = new \DOMDocument;
$this->assertTrue($dom->loadXML($xml));
}
- public function testGenerateEventsSitemapWithMultipleEvents(): void
+ public function test_generate_events_sitemap_with_multiple_events(): void
{
Carbon::setTestNow('2025-01-15 10:00:00');
@@ -220,7 +220,7 @@ public function testGenerateEventsSitemapWithMultipleEvents(): void
$this->assertStringContainsString('https://example.com/event/2/event-two ', $xml);
$this->assertStringContainsString('https://example.com/event/3/event-three ', $xml);
- $dom = new \DOMDocument();
+ $dom = new \DOMDocument;
$dom->loadXML($xml);
$urls = $dom->getElementsByTagName('url');
$this->assertEquals(3, $urls->length);
@@ -228,7 +228,7 @@ public function testGenerateEventsSitemapWithMultipleEvents(): void
Carbon::setTestNow();
}
- public function testGenerateOrganizersSitemapWithOrganizer(): void
+ public function test_generate_organizers_sitemap_with_organizer(): void
{
$organizer = $this->createMockOrganizer(
id: 123,
@@ -248,7 +248,7 @@ public function testGenerateOrganizersSitemapWithOrganizer(): void
$this->assertStringContainsString('0.6 ', $xml);
}
- public function testGenerateOrganizersSitemapWithSpecialCharactersInName(): void
+ public function test_generate_organizers_sitemap_with_special_characters_in_name(): void
{
$organizer = $this->createMockOrganizer(
id: 789,
@@ -259,14 +259,14 @@ public function testGenerateOrganizersSitemapWithSpecialCharactersInName(): void
$organizers = new Collection([$organizer]);
$xml = $this->service->generateOrganizersSitemap($organizers, 'https://example.com');
- $dom = new \DOMDocument();
+ $dom = new \DOMDocument;
$result = $dom->loadXML($xml);
$this->assertTrue($result, 'XML with special characters should be valid');
$this->assertStringContainsString('organizer-with-special-characters-quotes', $xml);
}
- public function testGenerateOrganizersSitemapWithEmptySlugFallsBackToDefault(): void
+ public function test_generate_organizers_sitemap_with_empty_slug_falls_back_to_default(): void
{
$organizer = $this->createMockOrganizer(
id: 101,
@@ -279,11 +279,11 @@ public function testGenerateOrganizersSitemapWithEmptySlugFallsBackToDefault():
$this->assertStringContainsString('/events/101/', $xml);
- $dom = new \DOMDocument();
+ $dom = new \DOMDocument;
$this->assertTrue($dom->loadXML($xml));
}
- public function testGenerateOrganizersSitemapWithEmptyCollection(): void
+ public function test_generate_organizers_sitemap_with_empty_collection(): void
{
$organizers = new Collection([]);
$xml = $this->service->generateOrganizersSitemap($organizers, 'https://example.com');
@@ -292,11 +292,11 @@ public function testGenerateOrganizersSitemapWithEmptyCollection(): void
$this->assertStringContainsString('http://www.sitemaps.org/schemas/sitemap/0.9', $xml);
$this->assertStringNotContainsString('', $xml);
- $dom = new \DOMDocument();
+ $dom = new \DOMDocument;
$this->assertTrue($dom->loadXML($xml));
}
- public function testGenerateOrganizersSitemapWithMultipleOrganizers(): void
+ public function test_generate_organizers_sitemap_with_multiple_organizers(): void
{
$organizers = new Collection([
$this->createMockOrganizer(1, 'Organizer One', '2025-01-10 12:00:00'),
@@ -310,13 +310,13 @@ public function testGenerateOrganizersSitemapWithMultipleOrganizers(): void
$this->assertStringContainsString('https://example.com/events/2/organizer-two ', $xml);
$this->assertStringContainsString('https://example.com/events/3/organizer-three ', $xml);
- $dom = new \DOMDocument();
+ $dom = new \DOMDocument;
$dom->loadXML($xml);
$urls = $dom->getElementsByTagName('url');
$this->assertEquals(3, $urls->length);
}
- public function testGenerateOrganizersSitemapIncludesLastModFromUpdatedAt(): void
+ public function test_generate_organizers_sitemap_includes_last_mod_from_updated_at(): void
{
$organizer = $this->createMockOrganizer(
id: 303,
@@ -330,7 +330,7 @@ public function testGenerateOrganizersSitemapIncludesLastModFromUpdatedAt(): voi
$this->assertStringContainsString('', $xml);
$this->assertStringContainsString('2025-01-12', $xml);
- $dom = new \DOMDocument();
+ $dom = new \DOMDocument;
$this->assertTrue($dom->loadXML($xml));
}
diff --git a/backend/tests/Unit/Services/Domain/Waitlist/CancelWaitlistEntryServiceTest.php b/backend/tests/Unit/Services/Domain/Waitlist/CancelWaitlistEntryServiceTest.php
index 208249eb4c..5b8d995326 100644
--- a/backend/tests/Unit/Services/Domain/Waitlist/CancelWaitlistEntryServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Waitlist/CancelWaitlistEntryServiceTest.php
@@ -2,15 +2,16 @@
namespace Tests\Unit\Services\Domain\Waitlist;
+use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\DomainObjects\Status\OrderStatus;
use HiEvents\DomainObjects\Status\WaitlistEntryStatus;
use HiEvents\DomainObjects\WaitlistEntryDomainObject;
use HiEvents\Events\CapacityChangedEvent;
use HiEvents\Exceptions\ResourceConflictException;
use HiEvents\Exceptions\ResourceNotFoundException;
-use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\Repository\Interfaces\OrderRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface;
+use HiEvents\Repository\Interfaces\StripePaymentsRepositoryInterface;
use HiEvents\Repository\Interfaces\WaitlistEntryRepositoryInterface;
use HiEvents\Services\Domain\Waitlist\CancelWaitlistEntryService;
use Illuminate\Database\DatabaseManager;
@@ -22,11 +23,17 @@
class CancelWaitlistEntryServiceTest extends TestCase
{
private CancelWaitlistEntryService $service;
+
private MockInterface|WaitlistEntryRepositoryInterface $waitlistEntryRepository;
+
private MockInterface|OrderRepositoryInterface $orderRepository;
+
private MockInterface|DatabaseManager $databaseManager;
+
private MockInterface|ProductPriceRepositoryInterface $productPriceRepository;
+ private MockInterface|StripePaymentsRepositoryInterface $stripePaymentsRepository;
+
protected function setUp(): void
{
parent::setUp();
@@ -35,6 +42,7 @@ protected function setUp(): void
$this->orderRepository = Mockery::mock(OrderRepositoryInterface::class);
$this->databaseManager = Mockery::mock(DatabaseManager::class);
$this->productPriceRepository = Mockery::mock(ProductPriceRepositoryInterface::class);
+ $this->stripePaymentsRepository = Mockery::mock(StripePaymentsRepositoryInterface::class);
$this->databaseManager
->shouldReceive('transaction')
@@ -42,7 +50,7 @@ protected function setUp(): void
return $callback();
});
- $productPrice = new ProductPriceDomainObject();
+ $productPrice = new ProductPriceDomainObject;
$productPrice->setId(20);
$productPrice->setProductId(99);
@@ -56,26 +64,31 @@ protected function setUp(): void
orderRepository: $this->orderRepository,
databaseManager: $this->databaseManager,
productPriceRepository: $this->productPriceRepository,
+ stripePaymentsRepository: $this->stripePaymentsRepository,
);
}
- public function testSuccessfullyCancelsByToken(): void
- {
- Event::fake();
-
- $cancelToken = 'valid-cancel-token-123';
-
+ private function makeEntry(
+ int $id,
+ string $status,
+ ?int $orderId = null,
+ int $eventId = 10,
+ int $productPriceId = 20,
+ int $eventOccurrenceId = 30,
+ ): MockInterface|WaitlistEntryDomainObject {
$entry = Mockery::mock(WaitlistEntryDomainObject::class);
- $entry->shouldReceive('getId')->andReturn(1);
- $entry->shouldReceive('getStatus')->andReturn(WaitlistEntryStatus::WAITING->name);
- $entry->shouldReceive('getOrderId')->andReturn(null);
+ $entry->shouldReceive('getId')->andReturn($id);
+ $entry->shouldReceive('getStatus')->andReturn($status);
+ $entry->shouldReceive('getOrderId')->andReturn($orderId);
+ $entry->shouldReceive('getEventId')->andReturn($eventId);
+ $entry->shouldReceive('getProductPriceId')->andReturn($productPriceId);
+ $entry->shouldReceive('getEventOccurrenceId')->andReturn($eventOccurrenceId);
- $this->waitlistEntryRepository
- ->shouldReceive('findFirstWhere')
- ->once()
- ->with(['cancel_token' => $cancelToken])
- ->andReturn($entry);
+ return $entry;
+ }
+ private function expectStatusUpdate(int $entryId): void
+ {
$this->waitlistEntryRepository
->shouldReceive('updateWhere')
->once()
@@ -85,18 +98,40 @@ public function testSuccessfullyCancelsByToken(): void
&& isset($attributes['cancelled_at'])
&& $attributes['order_id'] === null;
}),
- ['id' => 1],
+ ['id' => $entryId],
);
- $cancelledEntry = new WaitlistEntryDomainObject();
- $cancelledEntry->setId(1);
+ $cancelledEntry = new WaitlistEntryDomainObject;
+ $cancelledEntry->setId($entryId);
$cancelledEntry->setStatus(WaitlistEntryStatus::CANCELLED->name);
$this->waitlistEntryRepository
->shouldReceive('findById')
->once()
- ->with(1)
+ ->with($entryId)
->andReturn($cancelledEntry);
+ }
+
+ public function test_successfully_cancels_by_token(): void
+ {
+ Event::fake();
+
+ $cancelToken = 'valid-cancel-token-123';
+ $entry = $this->makeEntry(1, WaitlistEntryStatus::WAITING->name);
+
+ $this->waitlistEntryRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['cancel_token' => $cancelToken])
+ ->andReturn($entry);
+
+ $this->waitlistEntryRepository
+ ->shouldReceive('findByIdLocked')
+ ->once()
+ ->with(1)
+ ->andReturn($entry);
+
+ $this->expectStatusUpdate(1);
$result = $this->service->cancelByToken($cancelToken);
@@ -106,19 +141,13 @@ public function testSuccessfullyCancelsByToken(): void
Event::assertNotDispatched(CapacityChangedEvent::class);
}
- public function testSuccessfullyCancelsByTokenWhenStatusIsOfferedDeletesOrder(): void
+ public function test_cancelling_offered_entry_without_stripe_payment_deletes_order(): void
{
Event::fake();
$cancelToken = 'valid-cancel-token-456';
$orderId = 100;
-
- $entry = Mockery::mock(WaitlistEntryDomainObject::class);
- $entry->shouldReceive('getId')->andReturn(2);
- $entry->shouldReceive('getStatus')->andReturn(WaitlistEntryStatus::OFFERED->name);
- $entry->shouldReceive('getOrderId')->andReturn($orderId);
- $entry->shouldReceive('getEventId')->andReturn(10);
- $entry->shouldReceive('getProductPriceId')->andReturn(20);
+ $entry = $this->makeEntry(2, WaitlistEntryStatus::OFFERED->name, $orderId);
$this->waitlistEntryRepository
->shouldReceive('findFirstWhere')
@@ -126,6 +155,18 @@ public function testSuccessfullyCancelsByTokenWhenStatusIsOfferedDeletesOrder():
->with(['cancel_token' => $cancelToken])
->andReturn($entry);
+ $this->waitlistEntryRepository
+ ->shouldReceive('findByIdLocked')
+ ->once()
+ ->with(2)
+ ->andReturn($entry);
+
+ $this->stripePaymentsRepository
+ ->shouldReceive('countWhere')
+ ->once()
+ ->with(['order_id' => $orderId])
+ ->andReturn(0);
+
$this->orderRepository
->shouldReceive('deleteWhere')
->once()
@@ -134,48 +175,95 @@ public function testSuccessfullyCancelsByTokenWhenStatusIsOfferedDeletesOrder():
'status' => OrderStatus::RESERVED->name,
]);
+ $this->expectStatusUpdate(2);
+
+ $result = $this->service->cancelByToken($cancelToken);
+
+ $this->assertEquals(WaitlistEntryStatus::CANCELLED->name, $result->getStatus());
+
+ Event::assertDispatched(CapacityChangedEvent::class, function ($event) {
+ return $event->eventId === 10
+ && $event->productId === 99
+ && $event->eventOccurrenceId === 30;
+ });
+ }
+
+ public function test_cancelling_offered_entry_with_stripe_payment_abandons_order_instead_of_deleting(): void
+ {
+ Event::fake();
+
+ $cancelToken = 'stripe-payment-token';
+ $orderId = 200;
+ $entry = $this->makeEntry(3, WaitlistEntryStatus::OFFERED->name, $orderId);
+
+ $this->waitlistEntryRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['cancel_token' => $cancelToken])
+ ->andReturn($entry);
+
$this->waitlistEntryRepository
+ ->shouldReceive('findByIdLocked')
+ ->once()
+ ->with(3)
+ ->andReturn($entry);
+
+ $this->stripePaymentsRepository
+ ->shouldReceive('countWhere')
+ ->once()
+ ->with(['order_id' => $orderId])
+ ->andReturn(1);
+
+ $this->orderRepository
->shouldReceive('updateWhere')
->once()
->with(
- Mockery::on(function ($attributes) {
- return $attributes['status'] === WaitlistEntryStatus::CANCELLED->name
- && isset($attributes['cancelled_at'])
- && $attributes['order_id'] === null;
- }),
- ['id' => 2],
+ ['status' => OrderStatus::ABANDONED->name],
+ [
+ 'id' => $orderId,
+ 'status' => OrderStatus::RESERVED->name,
+ ],
);
- $cancelledEntry = new WaitlistEntryDomainObject();
- $cancelledEntry->setId(2);
- $cancelledEntry->setStatus(WaitlistEntryStatus::CANCELLED->name);
+ $this->orderRepository->shouldNotReceive('deleteWhere');
- $this->waitlistEntryRepository
- ->shouldReceive('findById')
- ->once()
- ->with(2)
- ->andReturn($cancelledEntry);
+ $this->expectStatusUpdate(3);
$result = $this->service->cancelByToken($cancelToken);
$this->assertEquals(WaitlistEntryStatus::CANCELLED->name, $result->getStatus());
- Event::assertDispatched(CapacityChangedEvent::class, function ($event) {
- return $event->eventId === 10 && $event->productId === 99;
- });
+ Event::assertDispatched(CapacityChangedEvent::class);
}
- public function testSuccessfullyCancelsById(): void
+ public function test_throws_when_entry_became_uncancellable_after_lock(): void
+ {
+ $entry = $this->makeEntry(4, WaitlistEntryStatus::OFFERED->name, 300);
+ $lockedEntry = $this->makeEntry(4, WaitlistEntryStatus::PURCHASED->name, 300);
+
+ $this->waitlistEntryRepository
+ ->shouldReceive('findByIdLocked')
+ ->once()
+ ->with(4)
+ ->andReturn($lockedEntry);
+
+ $this->orderRepository->shouldNotReceive('deleteWhere');
+ $this->orderRepository->shouldNotReceive('updateWhere');
+ $this->waitlistEntryRepository->shouldNotReceive('updateWhere');
+
+ $this->expectException(ResourceConflictException::class);
+ $this->expectExceptionMessage('This waitlist entry cannot be cancelled');
+
+ $this->service->cancelEntry($entry);
+ }
+
+ public function test_successfully_cancels_by_id(): void
{
Event::fake();
$entryId = 5;
$eventId = 1;
-
- $entry = Mockery::mock(WaitlistEntryDomainObject::class);
- $entry->shouldReceive('getId')->andReturn($entryId);
- $entry->shouldReceive('getStatus')->andReturn(WaitlistEntryStatus::WAITING->name);
- $entry->shouldReceive('getOrderId')->andReturn(null);
+ $entry = $this->makeEntry($entryId, WaitlistEntryStatus::WAITING->name, eventId: $eventId);
$this->waitlistEntryRepository
->shouldReceive('findFirstWhere')
@@ -187,26 +275,12 @@ public function testSuccessfullyCancelsById(): void
->andReturn($entry);
$this->waitlistEntryRepository
- ->shouldReceive('updateWhere')
- ->once()
- ->with(
- Mockery::on(function ($attributes) {
- return $attributes['status'] === WaitlistEntryStatus::CANCELLED->name
- && isset($attributes['cancelled_at'])
- && $attributes['order_id'] === null;
- }),
- ['id' => $entryId],
- );
-
- $cancelledEntry = new WaitlistEntryDomainObject();
- $cancelledEntry->setId($entryId);
- $cancelledEntry->setStatus(WaitlistEntryStatus::CANCELLED->name);
-
- $this->waitlistEntryRepository
- ->shouldReceive('findById')
+ ->shouldReceive('findByIdLocked')
->once()
->with($entryId)
- ->andReturn($cancelledEntry);
+ ->andReturn($entry);
+
+ $this->expectStatusUpdate($entryId);
$result = $this->service->cancelById($entryId, $eventId);
@@ -216,7 +290,7 @@ public function testSuccessfullyCancelsById(): void
Event::assertNotDispatched(CapacityChangedEvent::class);
}
- public function testThrowsExceptionForInvalidToken(): void
+ public function test_throws_exception_for_invalid_token(): void
{
$invalidToken = 'invalid-token-does-not-exist';
@@ -232,7 +306,7 @@ public function testThrowsExceptionForInvalidToken(): void
$this->service->cancelByToken($invalidToken);
}
- public function testThrowsExceptionForInvalidEntryId(): void
+ public function test_throws_exception_for_invalid_entry_id(): void
{
$entryId = 999;
$eventId = 1;
@@ -252,7 +326,7 @@ public function testThrowsExceptionForInvalidEntryId(): void
$this->service->cancelById($entryId, $eventId);
}
- public function testThrowsExceptionForAlreadyCancelledEntry(): void
+ public function test_throws_exception_for_already_cancelled_entry(): void
{
$cancelToken = 'already-cancelled-token';
@@ -271,7 +345,7 @@ public function testThrowsExceptionForAlreadyCancelledEntry(): void
$this->service->cancelByToken($cancelToken);
}
- public function testThrowsExceptionForPurchasedEntry(): void
+ public function test_throws_exception_for_purchased_entry(): void
{
$cancelToken = 'purchased-token';
@@ -290,7 +364,7 @@ public function testThrowsExceptionForPurchasedEntry(): void
$this->service->cancelByToken($cancelToken);
}
- public function testThrowsExceptionForExpiredOfferEntry(): void
+ public function test_throws_exception_for_expired_offer_entry(): void
{
$cancelToken = 'expired-offer-token';
diff --git a/backend/tests/Unit/Services/Domain/Waitlist/CreateWaitlistEntryServiceTest.php b/backend/tests/Unit/Services/Domain/Waitlist/CreateWaitlistEntryServiceTest.php
index 815481c98d..0527d9f839 100644
--- a/backend/tests/Unit/Services/Domain/Waitlist/CreateWaitlistEntryServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Waitlist/CreateWaitlistEntryServiceTest.php
@@ -7,10 +7,10 @@
use HiEvents\DomainObjects\Status\WaitlistEntryStatus;
use HiEvents\DomainObjects\WaitlistEntryDomainObject;
use HiEvents\Exceptions\ResourceConflictException;
+use HiEvents\Helper\EmailHelper;
use HiEvents\Jobs\Waitlist\SendWaitlistConfirmationEmailJob;
use HiEvents\Repository\Interfaces\WaitlistEntryRepositoryInterface;
use HiEvents\Services\Application\Handlers\Waitlist\DTO\CreateWaitlistEntryDTO;
-use HiEvents\Helper\EmailHelper;
use HiEvents\Services\Domain\Waitlist\CreateWaitlistEntryService;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Facades\Bus;
@@ -21,7 +21,9 @@
class CreateWaitlistEntryServiceTest extends TestCase
{
private CreateWaitlistEntryService $service;
+
private MockInterface|WaitlistEntryRepositoryInterface $waitlistEntryRepository;
+
private MockInterface|DatabaseManager $databaseManager;
protected function setUp(): void
@@ -43,7 +45,7 @@ protected function setUp(): void
);
}
- public function testSuccessfullyCreatesWaitlistEntryWithCorrectPosition(): void
+ public function test_successfully_creates_waitlist_entry_with_correct_position(): void
{
Bus::fake();
@@ -56,7 +58,7 @@ public function testSuccessfullyCreatesWaitlistEntryWithCorrectPosition(): void
locale: 'en',
);
- $eventSettings = new EventSettingDomainObject();
+ $eventSettings = new EventSettingDomainObject;
$eventSettings->setWaitlistEnabled(true);
$product = Mockery::mock(ProductDomainObject::class);
@@ -70,21 +72,22 @@ 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();
+ $createdEntry = new WaitlistEntryDomainObject;
$createdEntry->setId(1);
$createdEntry->setEventId(1);
$createdEntry->setProductPriceId(10);
@@ -100,12 +103,13 @@ 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'
&& $attributes['status'] === WaitlistEntryStatus::WAITING->name
&& $attributes['position'] === 4
- && !empty($attributes['cancel_token'])
+ && ! empty($attributes['cancel_token'])
&& $attributes['locale'] === 'en';
}))
->andReturn($createdEntry);
@@ -118,7 +122,7 @@ public function testSuccessfullyCreatesWaitlistEntryWithCorrectPosition(): void
Bus::assertDispatched(SendWaitlistConfirmationEmailJob::class);
}
- public function testPreventsDuplicateEntryForSameEmailAndProduct(): void
+ public function test_prevents_duplicate_entry_for_same_email_and_product(): void
{
$dto = new CreateWaitlistEntryDTO(
event_id: 1,
@@ -128,7 +132,7 @@ public function testPreventsDuplicateEntryForSameEmailAndProduct(): void
last_name: 'Doe',
);
- $eventSettings = new EventSettingDomainObject();
+ $eventSettings = new EventSettingDomainObject;
$eventSettings->setWaitlistEnabled(true);
$product = Mockery::mock(ProductDomainObject::class);
@@ -139,7 +143,7 @@ public function testPreventsDuplicateEntryForSameEmailAndProduct(): void
$this->waitlistEntryRepository
->shouldReceive('lockForProductPrice')
->once()
- ->with(10);
+ ->with(10, null);
$this->waitlistEntryRepository
->shouldReceive('findFirstWhere')
@@ -149,6 +153,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);
@@ -158,7 +163,7 @@ public function testPreventsDuplicateEntryForSameEmailAndProduct(): void
$this->service->createEntry($dto, $eventSettings, $product);
}
- public function testDispatchesSendWaitlistConfirmationEmailJob(): void
+ public function test_dispatches_send_waitlist_confirmation_email_job(): void
{
Bus::fake();
@@ -170,7 +175,7 @@ public function testDispatchesSendWaitlistConfirmationEmailJob(): void
last_name: 'Test',
);
- $eventSettings = new EventSettingDomainObject();
+ $eventSettings = new EventSettingDomainObject;
$eventSettings->setWaitlistEnabled(true);
$product = Mockery::mock(ProductDomainObject::class);
@@ -184,14 +189,15 @@ 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();
+ $createdEntry = new WaitlistEntryDomainObject;
$createdEntry->setId(1);
$this->waitlistEntryRepository
@@ -204,7 +210,7 @@ public function testDispatchesSendWaitlistConfirmationEmailJob(): void
Bus::assertDispatched(SendWaitlistConfirmationEmailJob::class);
}
- public function testPreventsDuplicateEntryWithPlusAlias(): void
+ public function test_prevents_duplicate_entry_with_plus_alias(): void
{
$dto = new CreateWaitlistEntryDTO(
event_id: 1,
@@ -214,7 +220,7 @@ public function testPreventsDuplicateEntryWithPlusAlias(): void
last_name: 'Doe',
);
- $eventSettings = new EventSettingDomainObject();
+ $eventSettings = new EventSettingDomainObject;
$eventSettings->setWaitlistEnabled(true);
$product = Mockery::mock(ProductDomainObject::class);
@@ -225,7 +231,7 @@ public function testPreventsDuplicateEntryWithPlusAlias(): void
$this->waitlistEntryRepository
->shouldReceive('lockForProductPrice')
->once()
- ->with(10);
+ ->with(10, null);
$this->waitlistEntryRepository
->shouldReceive('findFirstWhere')
@@ -235,6 +241,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);
@@ -244,7 +251,7 @@ public function testPreventsDuplicateEntryWithPlusAlias(): void
$this->service->createEntry($dto, $eventSettings, $product);
}
- public function testNormalizeEmailStripsPlusForKnownProviders(): void
+ public function test_normalize_email_strips_plus_for_known_providers(): void
{
$this->assertEquals('user@gmail.com', EmailHelper::normalize('user+tag@gmail.com'));
$this->assertEquals('user@gmail.com', EmailHelper::normalize('User+Tag@Gmail.com'));
@@ -252,18 +259,18 @@ public function testNormalizeEmailStripsPlusForKnownProviders(): void
$this->assertEquals('user@proton.me', EmailHelper::normalize('user+bar@proton.me'));
}
- public function testNormalizeEmailPreservesPlusForUnknownProviders(): void
+ public function test_normalize_email_preserves_plus_for_unknown_providers(): void
{
$this->assertEquals('user+tag@company.com', EmailHelper::normalize('user+tag@company.com'));
$this->assertEquals('user+tag@myisp.net', EmailHelper::normalize('User+Tag@MyISP.net'));
}
- public function testNormalizeEmailTrimsAndLowercases(): void
+ public function test_normalize_email_trims_and_lowercases(): void
{
$this->assertEquals('user@example.com', EmailHelper::normalize(' User@Example.com '));
}
- public function testThrowsExceptionWhenWaitlistNotEnabledOnProduct(): void
+ public function test_throws_exception_when_waitlist_not_enabled_on_product(): void
{
$dto = new CreateWaitlistEntryDTO(
event_id: 1,
@@ -273,7 +280,7 @@ public function testThrowsExceptionWhenWaitlistNotEnabledOnProduct(): void
last_name: 'User',
);
- $eventSettings = new EventSettingDomainObject();
+ $eventSettings = new EventSettingDomainObject;
$eventSettings->setWaitlistEnabled(true);
$product = Mockery::mock(ProductDomainObject::class);
@@ -285,6 +292,27 @@ public function testThrowsExceptionWhenWaitlistNotEnabledOnProduct(): void
$this->service->createEntry($dto, $eventSettings, $product);
}
+ public function test_throws_exception_when_product_waitlist_was_never_enabled(): void
+ {
+ $dto = new CreateWaitlistEntryDTO(
+ event_id: 1,
+ product_price_id: 10,
+ email: 'test@example.com',
+ first_name: 'Test',
+ last_name: 'User',
+ );
+
+ $eventSettings = new EventSettingDomainObject;
+
+ $product = Mockery::mock(ProductDomainObject::class);
+ $product->shouldReceive('getWaitlistEnabled')->andReturn(null);
+
+ $this->expectException(ResourceConflictException::class);
+ $this->expectExceptionMessage('Waitlist is not enabled for this product');
+
+ $this->service->createEntry($dto, $eventSettings, $product);
+ }
+
protected function tearDown(): void
{
Mockery::close();
diff --git a/backend/tests/Unit/Services/Domain/Waitlist/ProcessWaitlistServiceTest.php b/backend/tests/Unit/Services/Domain/Waitlist/ProcessWaitlistServiceTest.php
index e307ebba3f..07900098a4 100644
--- a/backend/tests/Unit/Services/Domain/Waitlist/ProcessWaitlistServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Waitlist/ProcessWaitlistServiceTest.php
@@ -2,7 +2,10 @@
namespace Tests\Unit\Services\Domain\Waitlist;
+use HiEvents\DomainObjects\Enums\EventType;
+use HiEvents\DomainObjects\Enums\ProductType;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\EventSettingDomainObject;
use HiEvents\DomainObjects\OrderDomainObject;
use HiEvents\DomainObjects\OrderItemDomainObject;
@@ -14,9 +17,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 +38,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 +68,31 @@ 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);
+
+ $defaultEligibilityOccurrence = new EventOccurrenceDomainObject;
+ $defaultEligibilityOccurrence->setId(50);
+ $defaultEligibilityOccurrence->setEventId(1);
+ $this->eligibilityService
+ ->shouldReceive('assertOccurrencePurchasable')
+ ->zeroOrMoreTimes()
+ ->andReturn($defaultEligibilityOccurrence)
+ ->byDefault();
+ $this->eligibilityService
+ ->shouldReceive('assertProductsVisibleOnOccurrence')
+ ->zeroOrMoreTimes()
+ ->andReturnNull()
+ ->byDefault();
+
+ $defaultProductPrice = new ProductPriceDomainObject;
+ $defaultProductPrice->setId(0);
+ $defaultProductPrice->setProductId(0);
+ $this->productPriceRepository
+ ->shouldReceive('findById')
+ ->zeroOrMoreTimes()
+ ->andReturn($defaultProductPrice)
+ ->byDefault();
$this->waitlistEntryRepository
->shouldReceive('lockForProductPrice')
@@ -65,6 +106,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 +124,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(
@@ -106,6 +162,7 @@ private function mockAvailableQuantities(int $eventId, int $priceId, int $quanti
quantity_available: $quantityAvailable,
quantity_reserved: 0,
initial_quantity_available: $quantityAvailable,
+ product_type: ProductType::TICKET->name,
),
])
));
@@ -113,7 +170,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 +179,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 +192,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 +203,7 @@ private function mockOrderCreation(): OrderDomainObject
->shouldReceive('findById')
->andReturn($product);
- $orderItem = new OrderItemDomainObject();
+ $orderItem = new OrderItemDomainObject;
$this->orderItemProcessingService
->shouldReceive('process')
->once()
@@ -159,7 +217,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 +239,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 +255,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 +263,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 +284,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 +312,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 +329,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 +355,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 +377,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 +393,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 +411,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 +423,7 @@ public function testCreatesReservedOrderWhenOffering(): void
->with(10)
->andReturn($product);
- $orderItem = new OrderItemDomainObject();
+ $orderItem = new OrderItemDomainObject;
$this->orderItemProcessingService
->shouldReceive('process')
->once()
@@ -375,11 +438,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 +458,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 +574,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 +607,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 +621,7 @@ public function testCapsOffersAtAvailableCapacity(): void
->shouldReceive('updateWhere')
->once();
- $updatedEntry = new WaitlistEntryDomainObject();
+ $updatedEntry = new WaitlistEntryDomainObject;
$updatedEntry->setId(1);
$updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name);
@@ -471,7 +635,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 +745,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 +782,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 +799,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 +819,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 +836,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 +858,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 +884,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 +909,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 +923,7 @@ public function testOfferSpecificEntryThrowsWhenStatusNotOfferable(): void
return $callback();
});
- $entry = new WaitlistEntryDomainObject();
+ $entry = new WaitlistEntryDomainObject;
$entry->setId($entryId);
$entry->setStatus(WaitlistEntryStatus::PURCHASED->name);
@@ -667,7 +937,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 +954,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 +973,7 @@ public function testOfferSpecificEntryAllowsReOfferForExpiredEntries(): void
->shouldReceive('updateWhere')
->once();
- $updatedEntry = new WaitlistEntryDomainObject();
+ $updatedEntry = new WaitlistEntryDomainObject;
$updatedEntry->setId($entryId);
$updatedEntry->setStatus(WaitlistEntryStatus::OFFERED->name);
@@ -718,7 +988,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 +1003,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 +1022,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 +1037,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/Domain/Waitlist/RevertWaitlistOffersForCancelledOrderServiceTest.php b/backend/tests/Unit/Services/Domain/Waitlist/RevertWaitlistOffersForCancelledOrderServiceTest.php
new file mode 100644
index 0000000000..9bad676f11
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Waitlist/RevertWaitlistOffersForCancelledOrderServiceTest.php
@@ -0,0 +1,114 @@
+waitlistEntryRepository = Mockery::mock(WaitlistEntryRepositoryInterface::class);
+ $this->productPriceRepository = Mockery::mock(ProductPriceRepositoryInterface::class);
+
+ $this->service = new RevertWaitlistOffersForCancelledOrderService(
+ $this->waitlistEntryRepository,
+ $this->productPriceRepository,
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ public function test_reverts_offered_entries_to_waiting_and_returns_capacity_events(): void
+ {
+ $orderId = 500;
+
+ $entry = Mockery::mock(WaitlistEntryDomainObject::class);
+ $entry->shouldReceive('getId')->andReturn(77);
+ $entry->shouldReceive('getEventId')->andReturn(1);
+ $entry->shouldReceive('getProductPriceId')->andReturn(9);
+ $entry->shouldReceive('getEventOccurrenceId')->andReturn(3);
+
+ $this->waitlistEntryRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->with([
+ 'order_id' => $orderId,
+ ['status', 'in', [WaitlistEntryStatus::OFFERED->name]],
+ ])
+ ->andReturn(collect([$entry]));
+
+ $this->waitlistEntryRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ [
+ 'status' => WaitlistEntryStatus::WAITING->name,
+ 'order_id' => null,
+ 'offered_at' => null,
+ 'offer_expires_at' => null,
+ 'offer_token' => null,
+ ],
+ [
+ 'id' => 77,
+ 'status' => WaitlistEntryStatus::OFFERED->name,
+ ],
+ );
+
+ $productPrice = Mockery::mock(ProductPriceDomainObject::class);
+ $productPrice->shouldReceive('getProductId')->andReturn(42);
+
+ $this->productPriceRepository
+ ->shouldReceive('findById')
+ ->once()
+ ->with(9)
+ ->andReturn($productPrice);
+
+ $capacityEvents = $this->service->revertOffersForOrder($orderId);
+
+ $this->assertCount(1, $capacityEvents);
+
+ $capacityEvent = $capacityEvents[0];
+ $this->assertInstanceOf(CapacityChangedEvent::class, $capacityEvent);
+ $this->assertSame(1, $capacityEvent->eventId);
+ $this->assertSame(CapacityChangeDirection::INCREASED, $capacityEvent->direction);
+ $this->assertSame(42, $capacityEvent->productId);
+ $this->assertSame(9, $capacityEvent->productPriceId);
+ $this->assertSame(3, $capacityEvent->eventOccurrenceId);
+ }
+
+ public function test_returns_no_events_when_order_has_no_offered_entries(): void
+ {
+ $this->waitlistEntryRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturn(collect());
+
+ $this->waitlistEntryRepository->shouldNotReceive('updateWhere');
+ $this->productPriceRepository->shouldNotReceive('findById');
+
+ $this->assertSame([], $this->service->revertOffersForOrder(123));
+ }
+}
diff --git a/backend/tests/Unit/Services/Infrastructure/Email/LiquidTemplateRendererTest.php b/backend/tests/Unit/Services/Infrastructure/Email/LiquidTemplateRendererTest.php
index 11064ab4bc..9aff2b611a 100644
--- a/backend/tests/Unit/Services/Infrastructure/Email/LiquidTemplateRendererTest.php
+++ b/backend/tests/Unit/Services/Infrastructure/Email/LiquidTemplateRendererTest.php
@@ -13,7 +13,7 @@ class LiquidTemplateRendererTest extends TestCase
protected function setUp(): void
{
parent::setUp();
- $this->renderer = new LiquidTemplateRenderer();
+ $this->renderer = new LiquidTemplateRenderer;
}
public function test_can_render_simple_template_with_context(): void
@@ -21,8 +21,8 @@ public function test_can_render_simple_template_with_context(): void
$template = 'Hello {{ customer.name }}!';
$context = [
'customer' => [
- 'name' => 'John Doe'
- ]
+ 'name' => 'John Doe',
+ ],
];
$result = $this->renderer->render($template, $context);
@@ -36,11 +36,11 @@ public function test_can_render_complex_template_with_nested_context(): void
$context = [
'order' => [
'order_code' => 'ORD-123',
- 'total_gross_formatted' => '$49.99'
+ 'total_gross_formatted' => '$49.99',
],
'event' => [
- 'title' => 'Amazing Concert'
- ]
+ 'title' => 'Amazing Concert',
+ ],
];
$result = $this->renderer->render($template, $context);
@@ -55,9 +55,9 @@ public function test_can_render_template_with_loops(): void
'order' => [
'items' => [
['title' => 'General Admission', 'quantity' => 2],
- ['title' => 'VIP Pass', 'quantity' => 1]
- ]
- ]
+ ['title' => 'VIP Pass', 'quantity' => 1],
+ ],
+ ],
];
$result = $this->renderer->render($template, $context);
@@ -68,7 +68,7 @@ public function test_can_render_template_with_loops(): void
public function test_can_render_template_with_conditionals(): void
{
$template = '{% if customer.name %}Hello {{ customer.name }}{% else %}Hello Guest{% endif %}';
-
+
$contextWithName = ['customer' => ['name' => 'Jane']];
$contextWithoutName = ['customer' => []];
@@ -82,18 +82,18 @@ public function test_can_render_template_with_conditionals(): void
public function test_validates_correct_template_syntax(): void
{
$validTemplate = 'Hello {{ customer.name }}!';
-
+
$result = $this->renderer->validate($validTemplate);
-
+
$this->assertTrue($result);
}
public function test_validates_incorrect_template_syntax(): void
{
$invalidTemplate = 'Hello {% if %}'; // Invalid if syntax
-
+
$result = $this->renderer->validate($invalidTemplate);
-
+
$this->assertFalse($result);
}
@@ -103,7 +103,7 @@ public function test_returns_available_tokens_for_order_confirmation(): void
$this->assertIsArray($tokens);
$this->assertNotEmpty($tokens);
-
+
// Check that some expected tokens are present with new dot notation
$tokenStrings = array_column($tokens, 'token');
$this->assertContains('{{ order.number }}', $tokenStrings);
@@ -117,7 +117,7 @@ public function test_returns_available_tokens_for_attendee_ticket(): void
$this->assertIsArray($tokens);
$this->assertNotEmpty($tokens);
-
+
// Check that some expected tokens are present with new dot notation
$tokenStrings = array_column($tokens, 'token');
$this->assertContains('{{ attendee.name }}', $tokenStrings);
@@ -129,12 +129,12 @@ public function test_returns_available_tokens_for_attendee_ticket(): void
public function test_token_structure_contains_required_fields(): void
{
$tokens = $this->renderer->getAvailableTokens(EmailTemplateType::ORDER_CONFIRMATION);
-
+
foreach ($tokens as $token) {
$this->assertArrayHasKey('token', $token);
$this->assertArrayHasKey('description', $token);
$this->assertArrayHasKey('example', $token);
-
+
$this->assertIsString($token['token']);
$this->assertIsString($token['description']);
$this->assertIsString($token['example']);
@@ -156,7 +156,7 @@ public function test_renders_html_content_as_expected(): void
{
$template = 'Message: {{ message }}';
$context = [
- 'message' => ''
+ 'message' => '',
];
$result = $this->renderer->render($template, $context);
@@ -164,4 +164,4 @@ public function test_renders_html_content_as_expected(): void
// Test that the template renders the content
$this->assertStringContainsString('Message: ', $result);
}
-}
\ No newline at end of file
+}
diff --git a/backend/tests/Unit/Services/Infrastructure/Encryption/EncryptedPayloadServiceTest.php b/backend/tests/Unit/Services/Infrastructure/Encryption/EncryptedPayloadServiceTest.php
index 7a05535ae1..906896c314 100644
--- a/backend/tests/Unit/Services/Infrastructure/Encryption/EncryptedPayloadServiceTest.php
+++ b/backend/tests/Unit/Services/Infrastructure/Encryption/EncryptedPayloadServiceTest.php
@@ -14,6 +14,7 @@
class EncryptedPayloadServiceTest extends TestCase
{
private Encrypter $encrypter;
+
private EncryptedPayloadService $service;
protected function setUp(): void
@@ -24,7 +25,7 @@ protected function setUp(): void
$this->service = new EncryptedPayloadService($this->encrypter);
}
- public function testEncryptPayload(): void
+ public function test_encrypt_payload(): void
{
$payload = ['data' => 'test'];
$encryptedPayload = 'encryptedString';
@@ -37,7 +38,7 @@ public function testEncryptPayload(): void
$this->assertEquals($encryptedPayload, $result);
}
- public function testDecryptPayloadSuccess(): void
+ public function test_decrypt_payload_success(): void
{
$encryptedPayload = 'encryptedString';
$decryptedPayload = ['data' => 'test', 'exp' => Carbon::now()->addHours(1)->toIso8601String()];
@@ -49,7 +50,7 @@ public function testDecryptPayloadSuccess(): void
$this->assertEquals($decryptedPayload, $result);
}
- public function testDecryptPayloadExpiredException(): void
+ public function test_decrypt_payload_expired_exception(): void
{
$this->expectException(EncryptedPayloadExpiredException::class);
@@ -61,13 +62,13 @@ public function testDecryptPayloadExpiredException(): void
$this->service->decryptPayload($encryptedPayload);
}
- public function testDecryptPayloadDecryptionFailedException(): void
+ public function test_decrypt_payload_decryption_failed_exception(): void
{
$this->expectException(DecryptionFailedException::class);
$encryptedPayload = 'encryptedString';
- $this->encrypter->shouldReceive('decrypt')->once()->andThrow(new DecryptException());
+ $this->encrypter->shouldReceive('decrypt')->once()->andThrow(new DecryptException);
$this->service->decryptPayload($encryptedPayload);
}
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..31fc9f2cd0
--- /dev/null
+++ b/backend/tests/Unit/Services/Infrastructure/Geo/GooglePlacesGeoProviderTest.php
@@ -0,0 +1,250 @@
+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(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(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(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);
+ Http::assertSentCount(1);
+ }
+
+ public function test_cached_raw_place_details_available_after_lookup_without_extra_requests(): void
+ {
+ Http::fake([
+ 'places.googleapis.com/v1/places/*' => Http::response([
+ 'id' => 'ChIJ-raw',
+ 'displayName' => ['text' => 'Raw Place'],
+ 'types' => ['establishment'],
+ 'addressComponents' => [
+ ['types' => ['country'], 'shortText' => 'IE', 'longText' => 'Ireland'],
+ ],
+ ], 200),
+ ]);
+
+ $provider = $this->makeProvider();
+ $provider->getPlaceDetails('ChIJ-raw');
+
+ $raw = $provider->getCachedRawPlaceDetails('ChIJ-raw');
+
+ $this->assertSame('ChIJ-raw', $raw['id']);
+ Http::assertSentCount(1);
+ }
+
+ public function test_cached_raw_place_details_shared_across_locales(): void
+ {
+ Http::fake([
+ 'places.googleapis.com/v1/places/*' => Http::response([
+ 'id' => 'ChIJ-locale',
+ 'displayName' => ['text' => 'Locale Place'],
+ 'types' => ['establishment'],
+ 'addressComponents' => [
+ ['types' => ['country'], 'shortText' => 'DE', 'longText' => 'Germany'],
+ ],
+ ], 200),
+ ]);
+
+ $provider = $this->makeProvider();
+ $provider->getPlaceDetails('ChIJ-locale', locale: 'de');
+
+ $this->assertSame('ChIJ-locale', $provider->getCachedRawPlaceDetails('ChIJ-locale')['id']);
+ Http::assertSentCount(1);
+ }
+
+ public function test_cached_raw_place_details_returns_null_when_never_fetched(): void
+ {
+ $provider = $this->makeProvider();
+
+ $this->assertNull($provider->getCachedRawPlaceDetails('ChIJ-unknown'));
+ $this->assertNull($provider->getCachedRawPlaceDetails(null));
+ }
+
+ public function test_is_available(): void
+ {
+ $this->assertTrue($this->makeProvider()->isAvailable());
+ }
+}
diff --git a/backend/tests/Unit/Services/Infrastructure/Geo/NoOpGeoProviderTest.php b/backend/tests/Unit/Services/Infrastructure/Geo/NoOpGeoProviderTest.php
new file mode 100644
index 0000000000..e6b19e7b67
--- /dev/null
+++ b/backend/tests/Unit/Services/Infrastructure/Geo/NoOpGeoProviderTest.php
@@ -0,0 +1,20 @@
+assertSame([], $provider->autocomplete('anything'));
+ $this->assertNull($provider->getPlaceDetails('place_1'));
+ $this->assertNull($provider->getCachedRawPlaceDetails('place_1'));
+ $this->assertFalse($provider->isAvailable());
+ }
+}
diff --git a/backend/tests/Unit/Services/Infrastructure/Image/ImageMetadataServiceTest.php b/backend/tests/Unit/Services/Infrastructure/Image/ImageMetadataServiceTest.php
index 884e72a223..e98d615c95 100644
--- a/backend/tests/Unit/Services/Infrastructure/Image/ImageMetadataServiceTest.php
+++ b/backend/tests/Unit/Services/Infrastructure/Image/ImageMetadataServiceTest.php
@@ -11,6 +11,7 @@
class ImageMetadataServiceTest extends TestCase
{
private LoggerInterface $logger;
+
private ImageMetadataService $service;
protected function setUp(): void
@@ -21,7 +22,7 @@ protected function setUp(): void
$this->service = new ImageMetadataService($this->logger);
}
- public function testExtractMetadataReturnsNullWhenImagickNotAvailable(): void
+ public function test_extract_metadata_returns_null_when_imagick_not_available(): void
{
if (extension_loaded('imagick')) {
$this->markTestSkipped('This test requires Imagick to NOT be installed');
@@ -34,9 +35,9 @@ public function testExtractMetadataReturnsNullWhenImagickNotAvailable(): void
$this->assertNull($result);
}
- public function testExtractMetadataReturnsMetadataWhenImagickAvailable(): void
+ public function test_extract_metadata_returns_metadata_when_imagick_available(): void
{
- if (!extension_loaded('imagick')) {
+ if (! extension_loaded('imagick')) {
$this->markTestSkipped('This test requires Imagick to be installed');
}
@@ -54,9 +55,9 @@ public function testExtractMetadataReturnsMetadataWhenImagickAvailable(): void
unlink($testImagePath);
}
- public function testExtractMetadataLogsWarningOnFailure(): void
+ public function test_extract_metadata_logs_warning_on_failure(): void
{
- if (!extension_loaded('imagick')) {
+ if (! extension_loaded('imagick')) {
$this->markTestSkipped('This test requires Imagick to be installed');
}
@@ -76,11 +77,11 @@ public function testExtractMetadataLogsWarningOnFailure(): void
private function createTestImage(): string
{
- $imagick = new \Imagick();
+ $imagick = new \Imagick;
$imagick->newImage(100, 100, '#ff5500');
$imagick->setImageFormat('png');
- $tempPath = sys_get_temp_dir() . '/test_image_' . uniqid() . '.png';
+ $tempPath = sys_get_temp_dir().'/test_image_'.uniqid().'.png';
$imagick->writeImage($tempPath);
$imagick->destroy();
diff --git a/backend/tests/Unit/Services/Infrastructure/Session/CheckoutSessionManagementServiceTest.php b/backend/tests/Unit/Services/Infrastructure/Session/CheckoutSessionManagementServiceTest.php
index 7071328155..db184c2954 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
+ public function test_get_session_id_with_existing_cookie(): 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);
@@ -26,14 +26,14 @@ public function testGetSessionIdWithExistingCookie(): void
$this->assertEquals('existingSessionId', $service->getSessionId());
}
- public function testVerifySession(): void
+ public function test_verify_session(): 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);
@@ -42,14 +42,15 @@ public function testVerifySession(): void
$this->assertTrue($service->verifySession('existingSessionId'));
}
- public function testGetSessionCookie(): void
+ public function test_get_session_cookie(): 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')
@@ -65,5 +66,6 @@ public function testGetSessionCookie(): void
$this->assertEquals('existingSessionId', $cookie->getValue());
$this->assertTrue($cookie->isSecure());
$this->assertEquals('none', $cookie->getSameSite());
+ $this->assertTrue($cookie->isPartitioned());
}
}
diff --git a/backend/tests/Unit/Services/Infrastructure/Stripe/StripeClientFactoryTest.php b/backend/tests/Unit/Services/Infrastructure/Stripe/StripeClientFactoryTest.php
index 56845cb913..12de24752a 100644
--- a/backend/tests/Unit/Services/Infrastructure/Stripe/StripeClientFactoryTest.php
+++ b/backend/tests/Unit/Services/Infrastructure/Stripe/StripeClientFactoryTest.php
@@ -13,6 +13,7 @@
class StripeClientFactoryTest extends TestCase
{
private StripeClientFactory $factory;
+
private StripeConfigurationService $mockConfigService;
protected function setUp(): void
@@ -102,4 +103,4 @@ public function test_create_for_platform_throws_exception_for_ireland_platform_m
$this->factory->createForPlatform(StripePlatform::IRELAND);
}
-}
\ No newline at end of file
+}
diff --git a/backend/tests/Unit/Services/Infrastructure/Stripe/StripeConfigurationServiceTest.php b/backend/tests/Unit/Services/Infrastructure/Stripe/StripeConfigurationServiceTest.php
index cfbdf2b0a3..7e083ae3e1 100644
--- a/backend/tests/Unit/Services/Infrastructure/Stripe/StripeConfigurationServiceTest.php
+++ b/backend/tests/Unit/Services/Infrastructure/Stripe/StripeConfigurationServiceTest.php
@@ -13,15 +13,15 @@ class StripeConfigurationServiceTest extends TestCase
protected function setUp(): void
{
parent::setUp();
- $this->service = new StripeConfigurationService();
+ $this->service = new StripeConfigurationService;
}
public function test_get_secret_key_returns_default_when_no_platform(): void
{
config(['services.stripe.secret_key' => 'sk_default']);
-
+
$result = $this->service->getSecretKey();
-
+
$this->assertEquals('sk_default', $result);
}
@@ -29,11 +29,11 @@ public function test_get_secret_key_returns_canada_platform_key(): void
{
config([
'services.stripe.secret_key' => 'sk_default',
- 'services.stripe.ca_secret_key' => 'sk_canada'
+ 'services.stripe.ca_secret_key' => 'sk_canada',
]);
-
+
$result = $this->service->getSecretKey(StripePlatform::CANADA);
-
+
$this->assertEquals('sk_canada', $result);
}
@@ -41,11 +41,11 @@ public function test_get_secret_key_returns_ireland_platform_key(): void
{
config([
'services.stripe.secret_key' => 'sk_default',
- 'services.stripe.ie_secret_key' => 'sk_ireland'
+ 'services.stripe.ie_secret_key' => 'sk_ireland',
]);
-
+
$result = $this->service->getSecretKey(StripePlatform::IRELAND);
-
+
$this->assertEquals('sk_ireland', $result);
}
@@ -55,11 +55,11 @@ public function test_get_secret_key_returns_null_when_no_keys_configured(): void
config([
'services.stripe.secret_key' => null,
'services.stripe.ca_secret_key' => null,
- 'services.stripe.ie_secret_key' => null
+ 'services.stripe.ie_secret_key' => null,
]);
-
+
$result = $this->service->getSecretKey();
-
+
$this->assertNull($result);
}
@@ -68,9 +68,9 @@ public function test_get_public_key_returns_correct_platform_keys(): void
config([
'services.stripe.public_key' => 'pk_default',
'services.stripe.ca_public_key' => 'pk_canada',
- 'services.stripe.ie_public_key' => 'pk_ireland'
+ 'services.stripe.ie_public_key' => 'pk_ireland',
]);
-
+
$this->assertEquals('pk_default', $this->service->getPublicKey());
$this->assertEquals('pk_canada', $this->service->getPublicKey(StripePlatform::CANADA));
$this->assertEquals('pk_ireland', $this->service->getPublicKey(StripePlatform::IRELAND));
@@ -81,11 +81,11 @@ public function test_get_all_webhook_secrets_includes_all_platforms(): void
config([
'services.stripe.webhook_secret' => 'whsec_default',
'services.stripe.ca_webhook_secret' => 'whsec_canada',
- 'services.stripe.ie_webhook_secret' => 'whsec_ireland'
+ 'services.stripe.ie_webhook_secret' => 'whsec_ireland',
]);
-
+
$result = $this->service->getAllWebhookSecrets();
-
+
$this->assertEquals('whsec_default', $result['default']);
$this->assertEquals('whsec_canada', $result['ca']);
$this->assertEquals('whsec_ireland', $result['ie']);
@@ -94,27 +94,27 @@ public function test_get_all_webhook_secrets_includes_all_platforms(): void
public function test_get_primary_platform_returns_correct_enum(): void
{
config(['services.stripe.primary_platform' => 'ie']);
-
+
$result = $this->service->getPrimaryPlatform();
-
+
$this->assertEquals(StripePlatform::IRELAND, $result);
}
public function test_get_primary_platform_returns_null_when_not_configured(): void
{
config(['services.stripe.primary_platform' => null]);
-
+
$result = $this->service->getPrimaryPlatform();
-
+
$this->assertNull($result);
}
public function test_get_primary_platform_returns_null_for_invalid_platform(): void
{
config(['services.stripe.primary_platform' => 'invalid']);
-
+
$result = $this->service->getPrimaryPlatform();
-
+
$this->assertNull($result);
}
@@ -123,16 +123,16 @@ public function test_get_all_webhook_secrets_returns_filtered_secrets(): void
config([
'services.stripe.webhook_secret' => 'whsec_default',
'services.stripe.ca_webhook_secret' => 'whsec_canada',
- 'services.stripe.ie_webhook_secret' => null
+ 'services.stripe.ie_webhook_secret' => null,
]);
-
+
$result = $this->service->getAllWebhookSecrets();
-
+
$expected = [
'default' => 'whsec_default',
- 'ca' => 'whsec_canada'
+ 'ca' => 'whsec_canada',
];
-
+
$this->assertEquals($expected, $result);
}
@@ -142,11 +142,11 @@ public function test_get_all_webhook_secrets_orders_primary_platform_first(): vo
'services.stripe.webhook_secret' => 'whsec_default',
'services.stripe.ca_webhook_secret' => 'whsec_canada',
'services.stripe.ie_webhook_secret' => 'whsec_ireland',
- 'services.stripe.primary_platform' => 'ie'
+ 'services.stripe.primary_platform' => 'ie',
]);
-
+
$result = $this->service->getAllWebhookSecrets();
-
+
$keys = array_keys($result);
$this->assertEquals('ie', $keys[0], 'Primary platform should be first');
}
@@ -154,9 +154,9 @@ public function test_get_all_webhook_secrets_orders_primary_platform_first(): vo
public function test_get_primary_platform_handles_string_conversion(): void
{
config(['services.stripe.primary_platform' => 'ca']);
-
+
$result = $this->service->getPrimaryPlatform();
-
+
$this->assertEquals(StripePlatform::CANADA, $result);
}
-}
\ No newline at end of file
+}
diff --git a/backend/tests/Unit/Services/Infrastructure/User/EmailVerificationCodeServiceTest.php b/backend/tests/Unit/Services/Infrastructure/User/EmailVerificationCodeServiceTest.php
index 07f22efdc7..6ca5a4c50e 100644
--- a/backend/tests/Unit/Services/Infrastructure/User/EmailVerificationCodeServiceTest.php
+++ b/backend/tests/Unit/Services/Infrastructure/User/EmailVerificationCodeServiceTest.php
@@ -11,12 +11,13 @@
class EmailVerificationCodeServiceTest extends TestCase
{
private EmailVerificationCodeService $service;
+
private MockInterface|Repository $cacheRepository;
protected function setUp(): void
{
parent::setUp();
-
+
$this->cacheRepository = Mockery::mock(Repository::class);
$this->service = new EmailVerificationCodeService($this->cacheRepository);
}
@@ -27,19 +28,19 @@ protected function tearDown(): void
parent::tearDown();
}
- public function testStoreAndReturnCode(): void
+ public function test_store_and_return_code(): void
{
// Given
$email = 'test@example.com';
- $expectedCacheKey = 'email_verification_code:' . $email;
-
+ $expectedCacheKey = 'email_verification_code:'.$email;
+
// Expect
$this->cacheRepository
->shouldReceive('put')
->once()
->withArgs(function ($key, $code, $expiry) use ($expectedCacheKey) {
- return $key === $expectedCacheKey
- && $code >= 10000
+ return $key === $expectedCacheKey
+ && $code >= 10000
&& $code <= 99999
&& $expiry->greaterThan(now()->addMinutes(29))
&& $expiry->lessThanOrEqualTo(now()->addMinutes(30)->addSecond());
@@ -54,12 +55,12 @@ public function testStoreAndReturnCode(): void
$this->assertLessThanOrEqual(99999, $code);
}
- public function testVerifyCodeWithValidCode(): void
+ public function test_verify_code_with_valid_code(): void
{
// Given
$email = 'test@example.com';
$validCode = '12345';
- $expectedCacheKey = 'email_verification_code:' . $email;
+ $expectedCacheKey = 'email_verification_code:'.$email;
// Expect
$this->cacheRepository
@@ -80,13 +81,13 @@ public function testVerifyCodeWithValidCode(): void
$this->assertTrue($result);
}
- public function testVerifyCodeWithInvalidCode(): void
+ public function test_verify_code_with_invalid_code(): void
{
// Given
$email = 'test@example.com';
$storedCode = '12345';
$providedCode = '54321';
- $expectedCacheKey = 'email_verification_code:' . $email;
+ $expectedCacheKey = 'email_verification_code:'.$email;
// Expect
$this->cacheRepository
@@ -105,12 +106,12 @@ public function testVerifyCodeWithInvalidCode(): void
$this->assertFalse($result);
}
- public function testVerifyCodeWithNoStoredCode(): void
+ public function test_verify_code_with_no_stored_code(): void
{
// Given
$email = 'test@example.com';
$providedCode = '12345';
- $expectedCacheKey = 'email_verification_code:' . $email;
+ $expectedCacheKey = 'email_verification_code:'.$email;
// Expect
$this->cacheRepository
@@ -129,25 +130,25 @@ public function testVerifyCodeWithNoStoredCode(): void
$this->assertFalse($result);
}
- public function testMultipleVerificationCodesForDifferentEmails(): void
+ public function test_multiple_verification_codes_for_different_emails(): void
{
// Given
$email1 = 'user1@example.com';
$email2 = 'user2@example.com';
-
+
// Expect - Store codes for two different emails
$this->cacheRepository
->shouldReceive('put')
->once()
->withArgs(function ($key, $code, $expiry) use ($email1) {
- return $key === 'email_verification_code:' . $email1;
+ return $key === 'email_verification_code:'.$email1;
});
$this->cacheRepository
->shouldReceive('put')
->once()
->withArgs(function ($key, $code, $expiry) use ($email2) {
- return $key === 'email_verification_code:' . $email2;
+ return $key === 'email_verification_code:'.$email2;
});
// When
@@ -160,7 +161,7 @@ public function testMultipleVerificationCodesForDifferentEmails(): void
// Codes might be the same by chance, but they're generated independently
}
- public function testVerifyCodeIsCaseInsensitiveForEmail(): void
+ public function test_verify_code_is_case_insensitive_for_email(): void
{
// Given
$emailLower = 'test@example.com';
@@ -169,12 +170,12 @@ public function testVerifyCodeIsCaseInsensitiveForEmail(): void
// Note: The service uses emails as-is, so case sensitivity depends on implementation
// This test documents the current behavior
-
+
// Expect - Different cache keys for different cases
$this->cacheRepository
->shouldReceive('get')
->once()
- ->with('email_verification_code:' . $emailUpper)
+ ->with('email_verification_code:'.$emailUpper)
->andReturn(null);
// When
@@ -184,7 +185,7 @@ public function testVerifyCodeIsCaseInsensitiveForEmail(): void
$this->assertFalse($result);
}
- public function testStoreAndReturnCodeGeneratesUniqueCodesOnMultipleCalls(): void
+ public function test_store_and_return_code_generates_unique_codes_on_multiple_calls(): void
{
// Given
$email = 'test@example.com';
@@ -196,6 +197,7 @@ public function testStoreAndReturnCodeGeneratesUniqueCodesOnMultipleCalls(): voi
->times(10)
->withArgs(function ($key, $code) use (&$generatedCodes) {
$generatedCodes[] = $code;
+
return true;
});
@@ -213,12 +215,12 @@ public function testStoreAndReturnCodeGeneratesUniqueCodesOnMultipleCalls(): voi
}
}
- public function testVerifyCodeOnlyWorksOnce(): void
+ public function test_verify_code_only_works_once(): void
{
// Given
$email = 'test@example.com';
$code = '12345';
- $cacheKey = 'email_verification_code:' . $email;
+ $cacheKey = 'email_verification_code:'.$email;
// First verification attempt
$this->cacheRepository
@@ -252,4 +254,3 @@ public function testVerifyCodeOnlyWorksOnce(): void
$this->assertFalse($secondAttempt);
}
}
-
diff --git a/backend/tests/Unit/Services/Infrastructure/Utlitiy/Retry/RetrierTest.php b/backend/tests/Unit/Services/Infrastructure/Utlitiy/Retry/RetrierTest.php
index c76ff54edb..49d6b9fa9b 100644
--- a/backend/tests/Unit/Services/Infrastructure/Utlitiy/Retry/RetrierTest.php
+++ b/backend/tests/Unit/Services/Infrastructure/Utlitiy/Retry/RetrierTest.php
@@ -8,7 +8,7 @@
class RetrierTest extends TestCase
{
- public function testRetriesMultipleTimesBeforeFailing(): void
+ public function test_retries_multiple_times_before_failing(): void
{
$attempts = 0;
$maxAttempts = 3;
@@ -16,32 +16,33 @@ public function testRetriesMultipleTimesBeforeFailing(): void
$operation = function () use (&$attempts, $maxAttempts) {
$attempts++;
if ($attempts < $maxAttempts) {
- throw new Exception("Temporary failure");
+ throw new Exception('Temporary failure');
}
- return "Success";
+
+ return 'Success';
};
- $retrier = new Retrier();
+ $retrier = new Retrier;
$result = $retrier->retry($operation, $maxAttempts, 1);
- $this->assertEquals("Success", $result);
+ $this->assertEquals('Success', $result);
$this->assertEquals($maxAttempts, $attempts);
}
- public function testFailsAfterMaxAttempts(): void
+ public function test_fails_after_max_attempts(): void
{
$attempts = 0;
$maxAttempts = 3;
$operation = function () use (&$attempts) {
$attempts++;
- throw new Exception("Persistent failure");
+ throw new Exception('Persistent failure');
};
- $retrier = new Retrier();
+ $retrier = new Retrier;
$this->expectException(Exception::class);
- $this->expectExceptionMessage("Persistent failure");
+ $this->expectExceptionMessage('Persistent failure');
try {
$retrier->retry($operation, $maxAttempts, 1);
@@ -50,7 +51,7 @@ public function testFailsAfterMaxAttempts(): void
}
}
- public function testOnFailureCallbackIsCalled(): void
+ public function test_on_failure_callback_is_called(): void
{
$attempts = 0;
$maxAttempts = 3;
@@ -58,19 +59,19 @@ public function testOnFailureCallbackIsCalled(): void
$operation = function () use (&$attempts) {
$attempts++;
- throw new Exception("Persistent failure");
+ throw new Exception('Persistent failure');
};
$onFailure = function (int $attempt, Exception $e) use (&$onFailureCalled, $maxAttempts) {
$onFailureCalled = true;
$this->assertEquals($maxAttempts, $attempt);
- $this->assertEquals("Persistent failure", $e->getMessage());
+ $this->assertEquals('Persistent failure', $e->getMessage());
};
- $retrier = new Retrier();
+ $retrier = new Retrier;
$this->expectException(Exception::class);
- $this->expectExceptionMessage("Persistent failure");
+ $this->expectExceptionMessage('Persistent failure');
try {
$retrier->retry($operation, $maxAttempts, 1, onFailure: $onFailure);
diff --git a/backend/tests/Unit/Services/Infrastructure/Vat/ViesValidationServiceTest.php b/backend/tests/Unit/Services/Infrastructure/Vat/ViesValidationServiceTest.php
index 1439b0d6ae..e4c78becb1 100644
--- a/backend/tests/Unit/Services/Infrastructure/Vat/ViesValidationServiceTest.php
+++ b/backend/tests/Unit/Services/Infrastructure/Vat/ViesValidationServiceTest.php
@@ -3,17 +3,19 @@
namespace Tests\Unit\Services\Infrastructure\Vat;
use HiEvents\Services\Infrastructure\Vat\ViesValidationService;
+use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Factory as HttpClient;
use Illuminate\Http\Client\Response;
-use Illuminate\Http\Client\ConnectionException;
-use Psr\Log\LoggerInterface;
use Mockery;
+use Psr\Log\LoggerInterface;
use Tests\TestCase;
class ViesValidationServiceTest extends TestCase
{
private HttpClient $httpClient;
+
private LoggerInterface $logger;
+
private ViesValidationService $service;
protected function setUp(): void
@@ -25,7 +27,7 @@ protected function setUp(): void
$this->service = new ViesValidationService($this->httpClient, $this->logger);
}
- public function testValidVatNumberReturnsSuccessResponse(): void
+ public function test_valid_vat_number_returns_success_response(): void
{
$vatNumber = 'IE1234567A';
$response = Mockery::mock(Response::class);
@@ -69,7 +71,7 @@ public function testValidVatNumberReturnsSuccessResponse(): void
$this->assertEquals('IE', $result->countryCode);
}
- public function testInvalidVatNumberReturnsFailureResponse(): void
+ public function test_invalid_vat_number_returns_failure_response(): void
{
$vatNumber = 'IE9999999ZZ';
$response = Mockery::mock(Response::class);
@@ -101,7 +103,7 @@ public function testInvalidVatNumberReturnsFailureResponse(): void
$this->assertNull($result->businessName);
}
- public function testMsMaxConcurrentReqReturnsTransientError(): void
+ public function test_ms_max_concurrent_req_returns_transient_error(): void
{
$vatNumber = 'DE123456789';
$response = Mockery::mock(Response::class);
@@ -127,8 +129,8 @@ public function testMsMaxConcurrentReqReturnsTransientError(): void
->andReturn([
'actionSucceed' => false,
'errorWrappers' => [
- ['error' => 'MS_MAX_CONCURRENT_REQ']
- ]
+ ['error' => 'MS_MAX_CONCURRENT_REQ'],
+ ],
]);
$this->logger
@@ -143,7 +145,7 @@ public function testMsMaxConcurrentReqReturnsTransientError(): void
$this->assertNotNull($result->errorMessage);
}
- public function testMsUnavailableReturnsTransientError(): void
+ public function test_ms_unavailable_returns_transient_error(): void
{
$vatNumber = 'FR12345678901';
$response = Mockery::mock(Response::class);
@@ -169,8 +171,8 @@ public function testMsUnavailableReturnsTransientError(): void
->andReturn([
'actionSucceed' => false,
'errorWrappers' => [
- ['error' => 'MS_UNAVAILABLE']
- ]
+ ['error' => 'MS_UNAVAILABLE'],
+ ],
]);
$this->logger
@@ -183,7 +185,7 @@ public function testMsUnavailableReturnsTransientError(): void
$this->assertTrue($result->isTransientError);
}
- public function testInvalidInputReturnsNonTransientError(): void
+ public function test_invalid_input_returns_non_transient_error(): void
{
$vatNumber = 'XX12345678';
$response = Mockery::mock(Response::class);
@@ -209,8 +211,8 @@ public function testInvalidInputReturnsNonTransientError(): void
->andReturn([
'actionSucceed' => false,
'errorWrappers' => [
- ['error' => 'INVALID_INPUT']
- ]
+ ['error' => 'INVALID_INPUT'],
+ ],
]);
$this->logger
@@ -223,7 +225,7 @@ public function testInvalidInputReturnsNonTransientError(): void
$this->assertFalse($result->isTransientError);
}
- public function testHttpErrorReturnsTransientError(): void
+ public function test_http_error_returns_transient_error(): void
{
$vatNumber = 'DE123456789';
$response = Mockery::mock(Response::class);
@@ -263,7 +265,7 @@ public function testHttpErrorReturnsTransientError(): void
$this->assertTrue($result->isTransientError);
}
- public function testConnectionExceptionReturnsTransientError(): void
+ public function test_connection_exception_returns_transient_error(): void
{
$vatNumber = 'FR12345678901';
@@ -287,7 +289,7 @@ public function testConnectionExceptionReturnsTransientError(): void
$this->assertEquals('FR', $result->countryCode);
}
- public function testUnexpectedExceptionReturnsTransientError(): void
+ public function test_unexpected_exception_returns_transient_error(): void
{
$vatNumber = 'ES12345678';
diff --git a/backend/tests/Unit/Validators/Rules/NoInternalUrlRuleTest.php b/backend/tests/Unit/Validators/Rules/NoInternalUrlRuleTest.php
index ebe90737ab..c950a18ae3 100644
--- a/backend/tests/Unit/Validators/Rules/NoInternalUrlRuleTest.php
+++ b/backend/tests/Unit/Validators/Rules/NoInternalUrlRuleTest.php
@@ -8,12 +8,13 @@
class NoInternalUrlRuleTest extends TestCase
{
private NoInternalUrlRule $rule;
+
private array $failedMessages = [];
protected function setUp(): void
{
parent::setUp();
- $this->rule = new NoInternalUrlRule();
+ $this->rule = new NoInternalUrlRule;
$this->failedMessages = [];
}
@@ -27,10 +28,10 @@ private function validate(string $url): bool
$this->failedMessages[] = $message;
});
- return !$failed;
+ return ! $failed;
}
- public function testAcceptsValidExternalUrls(): void
+ public function test_accepts_valid_external_urls(): void
{
$this->assertTrue($this->validate('https://example.com/webhook'));
$this->assertTrue($this->validate('https://api.stripe.com/v1/webhooks'));
@@ -38,28 +39,28 @@ public function testAcceptsValidExternalUrls(): void
$this->assertTrue($this->validate('http://webhook.site/abc123'));
}
- public function testRejectsLocalhostUrls(): void
+ public function test_rejects_localhost_urls(): void
{
$this->assertFalse($this->validate('http://localhost/admin'));
$this->assertFalse($this->validate('http://localhost:8080/api'));
$this->assertFalse($this->validate('https://localhost/webhook'));
}
- public function testRejectsLoopbackIpUrls(): void
+ public function test_rejects_loopback_ip_urls(): void
{
$this->assertFalse($this->validate('http://127.0.0.1/admin'));
$this->assertFalse($this->validate('http://127.0.0.1:3000/api'));
$this->assertFalse($this->validate('https://127.0.0.1/webhook'));
}
- public function testRejectsCloudMetadataUrls(): void
+ public function test_rejects_cloud_metadata_urls(): void
{
$this->assertFalse($this->validate('http://169.254.169.254/latest/meta-data/'));
$this->assertFalse($this->validate('http://169.254.169.254/latest/meta-data/iam/security-credentials/'));
$this->assertFalse($this->validate('http://metadata.google.internal/computeMetadata/v1/'));
}
- public function testRejectsPrivateIpAddresses(): void
+ public function test_rejects_private_ip_addresses(): void
{
$this->assertFalse($this->validate('http://10.0.0.1/internal'));
$this->assertFalse($this->validate('http://10.255.255.255/api'));
@@ -69,30 +70,30 @@ public function testRejectsPrivateIpAddresses(): void
$this->assertFalse($this->validate('http://192.168.255.255/api'));
}
- public function testRejectsZeroIpAddress(): void
+ public function test_rejects_zero_ip_address(): void
{
$this->assertFalse($this->validate('http://0.0.0.0/'));
$this->assertFalse($this->validate('http://0.0.0.0:8080/webhook'));
}
- public function testRejectsLinkLocalAddresses(): void
+ public function test_rejects_link_local_addresses(): void
{
$this->assertFalse($this->validate('http://169.254.0.1/'));
$this->assertFalse($this->validate('http://169.254.255.254/'));
}
- public function testRejectsInvalidUrls(): void
+ public function test_rejects_invalid_urls(): void
{
$this->assertFalse($this->validate('not-a-url'));
$this->assertFalse($this->validate(''));
}
- public function testRejectsIpv6Localhost(): void
+ public function test_rejects_ipv6_localhost(): void
{
$this->assertFalse($this->validate('http://[::1]/webhook'));
}
- public function testRejectsIpv4MappedIpv6Addresses(): void
+ public function test_rejects_ipv4_mapped_ipv6_addresses(): void
{
$this->assertFalse($this->validate('http://[::ffff:127.0.0.1]/webhook'));
$this->assertFalse($this->validate('http://[::ffff:169.254.169.254]/latest/meta-data/'));
@@ -101,7 +102,7 @@ public function testRejectsIpv4MappedIpv6Addresses(): void
$this->assertFalse($this->validate('http://[::ffff:172.16.0.1]/api'));
}
- public function testRejectsNonHttpSchemes(): void
+ public function test_rejects_non_http_schemes(): void
{
$this->assertFalse($this->validate('file:///etc/passwd'));
$this->assertFalse($this->validate('gopher://localhost/'));
@@ -109,14 +110,14 @@ public function testRejectsNonHttpSchemes(): void
$this->assertFalse($this->validate('dict://localhost/'));
}
- public function testRejectsLocalhostTld(): void
+ public function test_rejects_localhost_tld(): void
{
$this->assertFalse($this->validate('http://app.localhost/webhook'));
$this->assertFalse($this->validate('https://api.localhost/'));
$this->assertFalse($this->validate('http://anything.localhost/'));
}
- public function testAcceptsHttpAndHttpsSchemes(): void
+ public function test_accepts_http_and_https_schemes(): void
{
$this->assertTrue($this->validate('http://example.com/webhook'));
$this->assertTrue($this->validate('https://example.com/webhook'));
diff --git a/backend/tests/Unit/Validators/Rules/ProductQuestionRuleTest.php b/backend/tests/Unit/Validators/Rules/ProductQuestionRuleTest.php
new file mode 100644
index 0000000000..d730fe778f
--- /dev/null
+++ b/backend/tests/Unit/Validators/Rules/ProductQuestionRuleTest.php
@@ -0,0 +1,62 @@
+setId(100);
+ $price->setProductId(1);
+
+ $product = new ProductDomainObject;
+ $product->setId(1);
+ $product->setProductPrices(new Collection([$price]));
+
+ return new ProductQuestionRule(
+ questions: new Collection,
+ products: new Collection([$product]),
+ );
+ }
+
+ public function test_missing_product_price_id_throws_validation_exception_instead_of_erroring(): void
+ {
+ $this->expectException(ValidationException::class);
+
+ $this->makeRule()->validate(
+ attribute: 'products',
+ value: [['product_id' => 1]],
+ fail: static fn () => null,
+ );
+ }
+
+ public function test_non_numeric_product_price_id_throws_validation_exception(): void
+ {
+ $this->expectException(ValidationException::class);
+
+ $this->makeRule()->validate(
+ attribute: 'products',
+ value: [['product_id' => 1, 'product_price_id' => 'abc']],
+ fail: static fn () => null,
+ );
+ }
+
+ public function test_unknown_product_price_id_throws_validation_exception_instead_of_null_dereference(): void
+ {
+ $this->expectException(ValidationException::class);
+
+ $this->makeRule()->validate(
+ attribute: 'products',
+ value: [['product_id' => 1, 'product_price_id' => 999]],
+ fail: static fn () => null,
+ );
+ }
+}
diff --git a/docker/all-in-one/.env.example b/docker/all-in-one/.env.example
index cce1ee6b76..03e0f21364 100644
--- a/docker/all-in-one/.env.example
+++ b/docker/all-in-one/.env.example
@@ -58,3 +58,7 @@ STRIPE_WEBHOOK_SECRET=whsec_test_123456789
REDIS_HOST=redis
REDIS_PASSWORD=
REDIS_PORT=6379
+
+# Google Places address autocomplete. Geo features are disabled until an API key is set.
+GEO_PROVIDER=google
+GOOGLE_MAPS_API_KEY=
diff --git a/docker/all-in-one/docker-compose.yml b/docker/all-in-one/docker-compose.yml
index 2febdb3554..2ea9c338e0 100644
--- a/docker/all-in-one/docker-compose.yml
+++ b/docker/all-in-one/docker-compose.yml
@@ -35,6 +35,8 @@ services:
- MAIL_VERIFY_PEER=${MAIL_VERIFY_PEER}
- MAIL_FROM_ADDRESS=${MAIL_FROM_ADDRESS}
- MAIL_FROM_NAME=${MAIL_FROM_NAME}
+ - GEO_PROVIDER=${GEO_PROVIDER}
+ - GOOGLE_MAPS_API_KEY=${GOOGLE_MAPS_API_KEY}
- FILESYSTEM_PUBLIC_DISK=${FILESYSTEM_PUBLIC_DISK}
- FILESYSTEM_PRIVATE_DISK=${FILESYSTEM_PRIVATE_DISK}
- DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-secret}@postgres:5432/${POSTGRES_DB:-hi-events}
diff --git a/docker/all-in-one/scripts/startup.sh b/docker/all-in-one/scripts/startup.sh
index 3ab2caadf8..68cfa7d6ee 100644
--- a/docker/all-in-one/scripts/startup.sh
+++ b/docker/all-in-one/scripts/startup.sh
@@ -6,7 +6,9 @@ if ! php artisan migrate --force; then
echo "============================================"
echo "ERROR: Migrations could not complete. Check the error above."
echo "Ensure DATABASE_URL is set."
+ echo "Aborting startup to avoid running a half-migrated application."
echo "============================================"
+ exit 1
fi
php artisan cache:clear
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..85d1ed2b74 100755
--- a/docker/development/start-dev.sh
+++ b/docker/development/start-dev.sh
@@ -5,36 +5,104 @@ 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"
+CERTS_GENERATED=false
+
+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}"
+}
-echo -e "${GREEN}${BG_BLACK}Installing Hi.Events...${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"
+ if ! openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout "$CERTS_DIR/localhost.key" -out "$CERTS_DIR/localhost.crt" -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1" > /dev/null 2>&1; then
+ fail "Certificate generation failed"
+ exit 1
+ fi
+ CERTS_GENERATED=true
+ 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"
+ if ! mkcert -key-file "$CERTS_DIR/localhost.key" -cert-file "$CERTS_DIR/localhost.crt" localhost 127.0.0.1 ::1 > /dev/null 2>&1; then
+ fail "Certificate generation failed"
+ exit 1
+ fi
+ CERTS_GENERATED=true
+ ok "Certificates generated"
fi
else
- echo -e "${GREEN}SSL certificates already exist, skipping generation...${NC}"
+ ok "SSL certificates already exist"
fi
}
@@ -47,33 +115,78 @@ 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}"
+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
+
+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"
-echo -e "${GREEN}Running composer install in the backend service...${NC}"
+if [ "$CERTS_GENERATED" = true ]; then
+ step "Restarting nginx to load the new certificates"
+ $COMPOSE_CMD restart nginx > /dev/null 2>&1
+ ok "nginx restarted"
+fi
-$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 +196,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,occurrences --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/docker/e2e/.env b/docker/e2e/.env
new file mode 100644
index 0000000000..909ce7a805
--- /dev/null
+++ b/docker/e2e/.env
@@ -0,0 +1,54 @@
+# Committed, throwaway values for the hermetic E2E stack. NEVER put real secrets here.
+# Consumed by the `backend` service in docker-compose.e2e.yml via `env_file`.
+
+APP_NAME=Hi.Events
+APP_ENV=e2e
+APP_KEY=base64:rasMRv+Gm0oDMcBq+j9MvRgR3a6JYPTZjpRD4rGG2wA=
+APP_DEBUG=true
+APP_URL=http://localhost:8123
+APP_FRONTEND_URL=http://localhost:8123
+APP_CDN_URL=http://localhost:8123/storage
+APP_DISABLE_REGISTRATION=false
+APP_API_RATE_LIMIT_PER_MINUTE=100000
+APP_SAAS_STRIPE_APPLICATION_FEE_PERCENT=0
+APP_SAAS_STRIPE_APPLICATION_FEE_FIXED=0
+
+LOG_CHANNEL=stderr
+LOG_LEVEL=debug
+
+DB_CONNECTION=pgsql
+DB_HOST=pgsql
+DB_PORT=5432
+DB_DATABASE=hievents_e2e
+DB_USERNAME=hievents
+DB_PASSWORD=hievents
+
+BROADCAST_DRIVER=log
+CACHE_DRIVER=redis
+QUEUE_CONNECTION=sync
+SESSION_DRIVER=redis
+SESSION_LIFETIME=120
+
+FILESYSTEM_PUBLIC_DISK=local
+FILESYSTEM_PRIVATE_DISK=local
+
+REDIS_HOST=redis
+REDIS_PASSWORD=null
+REDIS_PORT=6379
+
+MAIL_MAILER=smtp
+MAIL_HOST=mailpit
+MAIL_PORT=1025
+MAIL_USERNAME=null
+MAIL_PASSWORD=null
+MAIL_ENCRYPTION=null
+MAIL_AUTO_TLS=false
+MAIL_VERIFY_PEER=false
+MAIL_FROM_ADDRESS=hello@hievents.test
+MAIL_FROM_NAME=Hi.Events
+
+JWT_SECRET=e2e-jwt-secret-not-for-production-use-only-in-e2e-aaaaaaaaaa
+JWT_ALGO=HS256
+
+GEO_PROVIDER=google
+GOOGLE_MAPS_API_KEY=
diff --git a/docker/e2e/docker-compose.e2e.yml b/docker/e2e/docker-compose.e2e.yml
new file mode 100644
index 0000000000..084b559cbe
--- /dev/null
+++ b/docker/e2e/docker-compose.e2e.yml
@@ -0,0 +1,101 @@
+name: hievents-e2e
+
+services:
+ backend:
+ image: hievents-e2e-backend:${E2E_IMAGE_TAG:-local}
+ build:
+ context: ./../../backend
+ dockerfile: Dockerfile
+ env_file:
+ - .env
+ environment:
+ APP_SAAS_MODE_ENABLED: '${E2E_SAAS_MODE:-false}'
+ STRIPE_PUBLIC_KEY: '${STRIPE_PUBLIC_KEY:-}'
+ STRIPE_SECRET_KEY: '${STRIPE_SECRET_KEY:-}'
+ STRIPE_WEBHOOK_SECRET: '${STRIPE_WEBHOOK_SECRET:-whsec_e2e_local_secret}'
+ depends_on:
+ pgsql:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ mailpit:
+ condition: service_started
+ healthcheck:
+ test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8080/up"]
+ interval: 5s
+ timeout: 5s
+ retries: 20
+ networks:
+ - app
+
+ frontend:
+ image: hievents-e2e-frontend:${E2E_IMAGE_TAG:-local}
+ build:
+ context: ./../../frontend
+ dockerfile: Dockerfile.ssr
+ environment:
+ NODE_ENV: production
+ VITE_API_URL_CLIENT: 'http://localhost:8123/api'
+ VITE_API_URL_SERVER: 'http://backend:8080'
+ VITE_FRONTEND_URL: 'http://localhost:8123'
+ VITE_STRIPE_PUBLISHABLE_KEY: '${STRIPE_PUBLIC_KEY:-}'
+ VITE_APP_NAME: 'Hi.Events'
+ depends_on:
+ backend:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:5678/robots.txt"]
+ interval: 5s
+ timeout: 5s
+ retries: 20
+ networks:
+ - app
+
+ nginx:
+ image: nginx:alpine
+ ports:
+ - "${E2E_HTTP_PORT:-8123}:80"
+ volumes:
+ - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
+ depends_on:
+ backend:
+ condition: service_healthy
+ frontend:
+ condition: service_healthy
+ networks:
+ - app
+
+ pgsql:
+ image: 'postgres:15'
+ environment:
+ POSTGRES_DB: hievents_e2e
+ POSTGRES_USER: hievents
+ POSTGRES_PASSWORD: hievents
+ networks:
+ - app
+ healthcheck:
+ test: ["CMD", "pg_isready", "-q", "-d", "hievents_e2e", "-U", "hievents"]
+ interval: 5s
+ timeout: 5s
+ retries: 20
+
+ redis:
+ image: 'redis:alpine'
+ networks:
+ - app
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 20
+
+ mailpit:
+ image: 'axllent/mailpit:latest'
+ ports:
+ - "${E2E_MAILPIT_PORT:-8225}:8025"
+ networks:
+ - app
+
+networks:
+ app:
+ driver: bridge
diff --git a/docker/e2e/nginx/nginx.conf b/docker/e2e/nginx/nginx.conf
new file mode 100644
index 0000000000..f1f6f22322
--- /dev/null
+++ b/docker/e2e/nginx/nginx.conf
@@ -0,0 +1,27 @@
+events {}
+
+http {
+ server {
+ listen 80;
+
+ client_max_body_size 20M;
+
+ location / {
+ proxy_pass http://frontend:5678;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "Upgrade";
+ }
+
+ location /api/ {
+ proxy_pass http://backend:8080/;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+ }
+}
diff --git a/e2e/.env.example b/e2e/.env.example
new file mode 100644
index 0000000000..1aebf3922f
--- /dev/null
+++ b/e2e/.env.example
@@ -0,0 +1,26 @@
+# Copy to `.env` to override defaults when running locally.
+# All values below are the defaults baked into the suite — you only need this
+# file when pointing the suite at a non-default stack (e.g. the dev stack).
+
+# Public entry point of the stack under test.
+# Hermetic e2e stack: http://localhost:8123
+# Running dev stack: https://localhost:8443
+E2E_BASE_URL=http://localhost:8123
+
+# Mailpit HTTP API, for asserting on outbound email.
+# Hermetic e2e stack: http://localhost:8225 (8225, not 8025, so it can run
+# alongside the dev stack, which already uses 8025)
+# Running dev stack: http://localhost:8025
+MAILPIT_URL=http://localhost:8225
+
+# Whether the stack under test runs in SaaS mode. Must match the backend's
+# APP_SAAS_MODE_ENABLED. Drives email-verification handling in fixtures and
+# mode-gated specs.
+E2E_SAAS_MODE=false
+
+# Stripe TEST-MODE keys. When unset, the @stripe specs skip themselves.
+# run-e2e.sh loads this file (already-exported shell vars win) and passes the
+# keys to the docker stack, so a `--fresh` boot picks them up automatically.
+# The stack must BOOT with the keys — reusing a keyless stack won't work.
+STRIPE_PUBLIC_KEY=
+STRIPE_SECRET_KEY=
diff --git a/e2e/.gitignore b/e2e/.gitignore
new file mode 100644
index 0000000000..210359bbc7
--- /dev/null
+++ b/e2e/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+/test-results/
+/playwright-report/
+/playwright/.cache/
+/blob-report/
+.env
diff --git a/e2e/README.md b/e2e/README.md
new file mode 100644
index 0000000000..84688c7498
--- /dev/null
+++ b/e2e/README.md
@@ -0,0 +1,243 @@
+# Hi.Events E2E Suite
+
+End-to-end tests driving the real application (Laravel backend + SSR frontend + Postgres + Redis + Mailpit) with [Playwright](https://playwright.dev). Data is arranged through the REST API and flows are exercised through a real browser.
+
+## Layout
+
+```
+e2e/
+├── playwright.config.ts Config: baseURL, reporters, retries, artifacts
+├── fixtures/ Test fixtures (the extension point)
+│ ├── account.fixture.ts Registers + verifies an account (SaaS-aware)
+│ ├── auth.fixture.ts Builds the auth-cookie storage state
+│ └── index.ts `test` / `expect` with all fixtures merged
+├── api/ Typed API client for arranging data
+│ ├── api-client.ts Auth helpers + authorized ApiClient
+│ ├── factory.ts Composable seeders (live event + product, …)
+│ └── types.ts
+├── pages/ Page objects (thin, role/label/testid locators)
+├── utils/ env, unique ids, mode guards, Mailpit client
+└── tests/ Specs, grouped by feature
+ ├── auth/ registration
+ ├── events/ event creation
+ ├── checkout/ free + Stripe checkout
+ └── management/ promo codes, questions, messages,
+ check-in lists, webhooks, editing a ticket
+```
+
+Every spec asserts on real page content after the action (the created item
+appears in its list/table, the edited value shows), not just a URL change.
+
+## Running the tests
+
+### Hermetic stack (identical to CI) — one command
+
+`run-e2e.sh` starts the stack behind nginx on `http://localhost:8123`, migrates,
+provisions the superadmin, installs dependencies, and runs the suite.
+
+**Locally it reuses a running healthy stack by default** (fast iteration) and leaves
+it up afterwards; a missing or broken stack is recreated cleanly. In CI every run
+recreates the stack from scratch.
+
+```bash
+./e2e/run-e2e.sh # reuse (or create) the stack, run everything
+./e2e/run-e2e.sh --fresh # force a clean stack recreation first
+./e2e/run-e2e.sh --teardown # tear the stack down after the run
+./e2e/run-e2e.sh --skip-stack # run against an already-running stack (e.g. dev)
+./e2e/run-e2e.sh --skip-deps # skip npm ci / browser install (fast re-runs)
+./e2e/run-e2e.sh -- --grep @smoke # pass args through to `playwright test`
+```
+
+**Reuse caveat:** `docker compose up` never rebuilds images. After changing
+`backend/` or `frontend/` source, rebuild before running:
+
+```bash
+docker compose -f docker/e2e/docker-compose.e2e.yml build backend frontend
+./e2e/run-e2e.sh --fresh
+```
+
+The hermetic stack is designed to run **alongside the dev stack** — it uses nginx on
+`8123` and Mailpit on `8225` (the dev stack uses `8443`/`8025`), and nothing else is
+published to the host. If those two ports are also taken, remap them:
+
+```bash
+E2E_HTTP_PORT=9123 E2E_MAILPIT_PORT=9225 \
+ E2E_BASE_URL=http://localhost:9123 MAILPIT_URL=http://localhost:9225 \
+ ./e2e/run-e2e.sh
+```
+
+### Manual (if you want to drive the steps yourself)
+
+```bash
+docker compose -f docker/e2e/docker-compose.e2e.yml up -d --wait
+docker compose -f docker/e2e/docker-compose.e2e.yml exec backend php artisan migrate --force
+
+cd e2e
+npm ci
+npx playwright install --with-deps chromium
+npx playwright test
+
+docker compose -f docker/e2e/docker-compose.e2e.yml down -v
+```
+
+### Against the running dev stack
+
+The suite is data-isolated (unique emails per run), so it can target the dev stack directly —
+useful for testing uncommitted changes without rebuilding the hermetic images (the dev stack
+mounts source live). Note it leaves test data behind in the dev database.
+
+`E2E_SAAS_MODE=true` is required: the dev stack requires email verification, and the account
+fixture only confirms the code from Mailpit in SaaS mode. That also means a queue worker must
+be running to deliver the verification emails, and superadmin-dependent specs need the e2e
+superadmin provisioned once:
+
+```bash
+cd docker/development
+docker compose -f docker-compose.dev.yml exec -d backend php artisan queue:work
+docker compose -f docker-compose.dev.yml exec backend php artisan dev:bootstrap \
+ --email=superadmin@e2e.test --password='SuperAdminPass123!'
+```
+
+Then run specs directly (from `e2e/`), or the whole suite via the script (from the repo root):
+
+```bash
+E2E_BASE_URL=https://localhost:8443 MAILPIT_URL=http://localhost:8025 E2E_SAAS_MODE=true \
+ npx playwright test tests/events/recurring-event-checkout.spec.ts
+
+E2E_BASE_URL=https://localhost:8443 MAILPIT_URL=http://localhost:8025 E2E_SAAS_MODE=true \
+ ./e2e/run-e2e.sh --skip-stack --skip-deps
+```
+
+### Handy scripts
+
+```bash
+npm test # run everything
+npm run test:ui # Playwright UI mode (great for authoring)
+npm run test:headed # headed browser
+npm run test:smoke # only @smoke-tagged specs
+npm run report # open the last HTML report
+npm run typecheck # tsc --noEmit
+```
+
+## Configuration
+
+Copy `.env.example` to `.env` to override defaults. All are optional.
+
+| Variable | Default | Purpose |
+| ------------------- | ------------------------ | ---------------------------------------------------- |
+| `E2E_BASE_URL` | `http://localhost:8123` | Public entry point of the stack under test |
+| `MAILPIT_URL` | `http://localhost:8225` | Mailpit HTTP API, for email assertions (8225 so it coexists with the dev stack's 8025) |
+| `E2E_SAAS_MODE` | `false` | Must match the backend's `APP_SAAS_MODE_ENABLED` |
+| `STRIPE_PUBLIC_KEY` | _(unset)_ | Stripe test-mode key; when unset the `@stripe` specs skip |
+
+### Stripe specs
+
+Tests that talk to the real Stripe test-mode API are tagged `@stripe` (paid checkout,
+decline/retry, refund) and skip unless `STRIPE_PUBLIC_KEY` is set. The stack must be
+**booted** with the keys (the backend needs `STRIPE_SECRET_KEY`, the frontend gets the
+publishable key via compose) — setting them only at test time is not enough.
+
+Easiest: put the test-mode keys in `e2e/.env` (gitignored; see `.env.example`) —
+`run-e2e.sh` loads it and passes them to the stack:
+
+```bash
+./e2e/run-e2e.sh --fresh --skip-deps -- --grep @stripe
+```
+
+(`--fresh` only the first time, so the containers boot with the keys; after that the
+reused stack keeps them.) Exported shell vars take precedence over `e2e/.env`.
+
+## Writing a new spec
+
+1. Import the shared harness: `import { test, expect } from '../../fixtures';`
+2. Arrange data through the API — reach for `api` / `account` fixtures and the
+ `factory` seeders rather than clicking through setup UI.
+3. Drive the flow under test with a page object; assert in the spec, not the page object.
+4. Tag fast, load-bearing checks with `{ tag: '@smoke' }`.
+
+```ts
+import { test, expect } from '../../fixtures';
+import { createLiveEventWithFreeTicket } from '../../api/factory';
+
+test('example', { tag: '@smoke' }, async ({ page, api, account }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ // …drive the browser, then assert…
+});
+```
+
+### Fixtures
+
+- **`account`** (worker-scoped) — a registered, verified account with an organizer
+ and an authorized `ApiClient`. Handles SaaS-mode email verification automatically.
+- **`api`** (worker-scoped) — the authorized `ApiClient` (alias of `account.api`).
+- **`authedPage`** — a `Page` pre-authenticated as `account` via injected auth cookie
+ (no UI login). Use it for organizer-facing flows.
+- **`page`** — the default unauthenticated page. Use it for public flows (checkout).
+- **`mailpit`** — client for asserting on outbound email.
+- **`publicApi`** — anonymous API context rooted at the API base URL, for buyer-side
+ public endpoints (`api/public-client.ts`: create/complete orders, join waitlist, …).
+- **`freshAccount`** — a brand-new account + organizer isolated to this test, with
+ `newAuthedPage()` for browsing as that account.
+- **`adminApi`** / **`superAdminPage`** — authenticated as the superadmin that
+ `run-e2e.sh` provisions via `php artisan dev:bootstrap`. If no superadmin exists
+ (e.g. `--skip-stack` against a stack that never provisioned one), these skip the
+ test locally and **hard-fail in CI** so admin coverage can't silently disappear.
+
+**Which isolation level to use:**
+
+- Event-scoped spec (products, orders, attendees, …) → shared `account` + a fresh
+ event per test via the `factory`.
+- Organizer-aggregate assertion (reports, settings, organizer webhooks, events list
+ contents) → `createFreshOrganizer(api)` — one API call, still on the shared account.
+- Account/auth-level mutation (team invites, password change, messaging tier,
+ impersonation target) → `freshAccount`.
+
+Don't default to `freshAccount` — worker-sharing the account is the main reason the
+suite is fast.
+
+### Selector policy
+
+Prefer `getByRole` / `getByLabel` — Mantine emits real `` associations and the
+default English (Lingui) strings are stable. `data-testid` is configured
+(`testIdAttribute: 'data-testid'`); add them to frontend source for interactive
+elements the suite drives — buttons (open-modal, submit), menu items, and custom
+widgets with no accessible label (e.g. `CustomSelect`, whose `dataTestId` prop lands
+on its target and auto-derives `-option-` on each option). Convention:
+kebab-case `-`. Don't add IDs to text inputs with a unique label —
+use `getByLabel(/^Label/)` (anchored regex dodges Mantine's required `*`). See the
+"Test IDs (E2E)" note in the repo `CLAUDE.md`.
+
+Watch for two things when asserting on list content: Mantine's `Truncate` hides the
+full text in a tooltip past its length limit (assert a short value, or target the
+visible node), and transient success toasts briefly contain the same text (target the
+specific row/heading, e.g. `getByRole('heading', { name })`, not bare `getByText`).
+
+## SaaS mode
+
+The suite is SaaS-aware but runs against non-SaaS by default (`E2E_SAAS_MODE=false`).
+To run against a SaaS stack, bring the stack up with `E2E_SAAS_MODE=true` (sets the
+backend's `APP_SAAS_MODE_ENABLED`) and run with `E2E_SAAS_MODE=true`. The account
+fixture then pulls the verification PIN from Mailpit and confirms the email before use.
+
+Known gap: SaaS-mode Stripe checkout requires a connected Stripe account
+(`organizer_stripe_platforms`), which cannot be onboarded headlessly. The Stripe spec
+targets non-SaaS platform-account charges until a seeded-connected-account helper exists.
+
+## CI
+
+`.github/workflows/e2e.yml` builds the backend and frontend images (GHA layer cache),
+starts the stack, runs migrations, provisions the superadmin, and executes the suite.
+The HTML report, traces, and stack logs are uploaded as artifacts on every run.
+
+Lanes:
+
+- **Pull requests** run the `@smoke` lane only (`--grep @smoke`) — one fast,
+ load-bearing check per feature area. Tag discipline matters: exactly one `@smoke`
+ test per area.
+- **Pushes to `main`/`develop`/release branches** and a **nightly cron** run the full
+ suite, sharded across two parallel jobs (each with its own hermetic stack).
+- Adding the **`full-e2e` label** to a PR forces the full sharded lane on that PR.
+
+The Stripe checkout spec runs only when the repository secrets `STRIPE_TEST_PUBLIC_KEY`
+and `STRIPE_TEST_SECRET_KEY` (Stripe **test-mode** keys) are set; otherwise it skips,
+keeping fork PRs green.
diff --git a/e2e/api/api-client.ts b/e2e/api/api-client.ts
new file mode 100644
index 0000000000..7af2598e7f
--- /dev/null
+++ b/e2e/api/api-client.ts
@@ -0,0 +1,296 @@
+import type { APIRequestContext, APIResponse } from '@playwright/test';
+import type {
+ Affiliate,
+ AttendeeRecord,
+ CapacityAssignment,
+ CheckInList,
+ CreateAffiliatePayload,
+ CreateAttendeePayload,
+ CreateCapacityAssignmentPayload,
+ CreateCheckInListPayload,
+ CreateEmailTemplatePayload,
+ CreateEventPayload,
+ CreateOrganizerLocationPayload,
+ CreateProductCategoryPayload,
+ CreatePromoCodePayload,
+ CreateProductPayload,
+ CreateQuestionPayload,
+ CreateTaxOrFeePayload,
+ CreateWebhookPayload,
+ EmailTemplate,
+ EventRecord,
+ EventSettings,
+ EventStatus,
+ InviteUserPayload,
+ Me,
+ Occurrence,
+ OccurrencePriceOverridePayload,
+ OrderRecord,
+ Organizer,
+ ProductCategory,
+ ProductRecord,
+ PromoCode,
+ QuestionRecord,
+ RecurrenceRule,
+ RegisterPayload,
+ TaxOrFee,
+ UpdateOccurrencePayload,
+ Webhook,
+} from './types';
+
+const jsonHeaders = { 'Content-Type': 'application/json', Accept: 'application/json' };
+
+const unwrap = async (promise: Promise): Promise => {
+ const response = await promise;
+ if (!response.ok()) {
+ throw new Error(`API ${response.url()} → ${response.status()}: ${await response.text()}`);
+ }
+ const body = (await response.json()) as { data: T };
+ return body.data;
+};
+
+const check = async (promise: Promise): Promise => {
+ const response = await promise;
+ if (!response.ok()) {
+ throw new Error(`API ${response.url()} → ${response.status()}: ${await response.text()}`);
+ }
+};
+
+export async function registerAccount(request: APIRequestContext, payload: RegisterPayload): Promise {
+ const response = await request.post('auth/register', { headers: jsonHeaders, data: payload });
+ if (!response.ok()) {
+ throw new Error(`register → ${response.status()}: ${await response.text()}`);
+ }
+}
+
+export interface LoginResult {
+ token: string;
+ user: Me;
+}
+
+export async function login(request: APIRequestContext, email: string, password: string): Promise {
+ const response = await request.post('auth/login', { headers: jsonHeaders, data: { email, password } });
+ if (!response.ok()) {
+ throw new Error(`login → ${response.status()}: ${await response.text()}`);
+ }
+ const body = (await response.json()) as { token?: string; user?: Me };
+ if (!body.token || !body.user) {
+ throw new Error('login succeeded but response was missing token/user');
+ }
+ return { token: body.token, user: body.user };
+}
+
+export async function confirmEmailWithCode(
+ request: APIRequestContext,
+ token: string,
+ userId: number,
+ code: string,
+): Promise {
+ const response = await request.post(`users/${userId}/confirm-email-with-code`, {
+ headers: { ...jsonHeaders, Authorization: `Bearer ${token}` },
+ data: { code },
+ });
+ if (!response.ok()) {
+ throw new Error(`confirm-email-with-code → ${response.status()}: ${await response.text()}`);
+ }
+}
+
+export class ApiClient {
+ constructor(private readonly request: APIRequestContext) {}
+
+ getAccount(): Promise<{ id: number }> {
+ return unwrap<{ id: number }>(this.request.get('accounts', { headers: jsonHeaders }));
+ }
+
+ createOrganizer(name: string, opts: { email?: string; currency?: string; timezone?: string } = {}): Promise {
+ return unwrap(
+ this.request.post('organizers', {
+ headers: jsonHeaders,
+ data: {
+ name,
+ email: opts.email ?? 'organizer@hievents.test',
+ currency: opts.currency ?? 'USD',
+ timezone: opts.timezone ?? 'UTC',
+ },
+ }),
+ );
+ }
+
+ updateOrganizerStatus(organizerId: number, status: 'LIVE' | 'DRAFT'): Promise {
+ return check(this.request.put(`organizers/${organizerId}/status`, { headers: jsonHeaders, data: { status } }));
+ }
+
+ createEvent(payload: CreateEventPayload): Promise {
+ return unwrap(this.request.post('events', { headers: jsonHeaders, data: payload }));
+ }
+
+ listProductCategories(eventId: number): Promise {
+ return unwrap(this.request.get(`events/${eventId}/product-categories`, { headers: jsonHeaders }));
+ }
+
+ createProductCategory(eventId: number, payload: CreateProductCategoryPayload): Promise {
+ return unwrap(
+ this.request.post(`events/${eventId}/product-categories`, { headers: jsonHeaders, data: payload }),
+ );
+ }
+
+ createProduct(eventId: number, payload: CreateProductPayload): Promise {
+ return unwrap(
+ this.request.post(`events/${eventId}/products`, { headers: jsonHeaders, data: payload }),
+ );
+ }
+
+ getProduct(eventId: number, productId: number): Promise {
+ return unwrap(
+ this.request.get(`events/${eventId}/products/${productId}`, { headers: jsonHeaders }),
+ );
+ }
+
+ setEventStatus(eventId: number, status: EventStatus): Promise {
+ return check(this.request.put(`events/${eventId}/status`, { headers: jsonHeaders, data: { status } }));
+ }
+
+ publishEvent(eventId: number): Promise {
+ return this.setEventStatus(eventId, 'LIVE');
+ }
+
+ getEventSettings(eventId: number): Promise {
+ return unwrap(this.request.get(`events/${eventId}/settings`, { headers: jsonHeaders }));
+ }
+
+ updateEventSettings(eventId: number, settings: Partial): Promise {
+ return check(this.request.patch(`events/${eventId}/settings`, { headers: jsonHeaders, data: settings }));
+ }
+
+ createPromoCode(eventId: number, payload: CreatePromoCodePayload): Promise {
+ return unwrap(
+ this.request.post(`events/${eventId}/promo-codes`, {
+ headers: jsonHeaders,
+ data: { applicable_product_ids: [], ...payload },
+ }),
+ );
+ }
+
+ createQuestion(eventId: number, payload: CreateQuestionPayload): Promise {
+ return unwrap(
+ this.request.post(`events/${eventId}/questions`, { headers: jsonHeaders, data: payload }),
+ );
+ }
+
+ createTaxOrFee(accountId: number, payload: CreateTaxOrFeePayload): Promise {
+ return unwrap(
+ this.request.post(`accounts/${accountId}/taxes-and-fees`, {
+ headers: jsonHeaders,
+ data: { description: null, ...payload },
+ }),
+ );
+ }
+
+ createCheckInList(eventId: number, payload: CreateCheckInListPayload): Promise {
+ return unwrap(
+ this.request.post(`events/${eventId}/check-in-lists`, { headers: jsonHeaders, data: payload }),
+ );
+ }
+
+ createAttendee(eventId: number, payload: CreateAttendeePayload): Promise {
+ return unwrap(
+ this.request.post(`events/${eventId}/attendees`, { headers: jsonHeaders, data: payload }),
+ );
+ }
+
+ createCapacityAssignment(eventId: number, payload: CreateCapacityAssignmentPayload): Promise {
+ return unwrap(
+ this.request.post(`events/${eventId}/capacity-assignments`, { headers: jsonHeaders, data: payload }),
+ );
+ }
+
+ createAffiliate(eventId: number, payload: CreateAffiliatePayload): Promise {
+ return unwrap(
+ this.request.post(`events/${eventId}/affiliates`, { headers: jsonHeaders, data: payload }),
+ );
+ }
+
+ createWebhook(eventId: number, payload: CreateWebhookPayload): Promise {
+ return unwrap(
+ this.request.post(`events/${eventId}/webhooks`, { headers: jsonHeaders, data: payload }),
+ );
+ }
+
+ createEventEmailTemplate(eventId: number, payload: CreateEmailTemplatePayload): Promise {
+ return unwrap(
+ this.request.post(`events/${eventId}/email-templates`, { headers: jsonHeaders, data: payload }),
+ );
+ }
+
+ inviteUser(payload: InviteUserPayload): Promise<{ id: number }> {
+ return unwrap<{ id: number }>(this.request.post('users', { headers: jsonHeaders, data: payload }));
+ }
+
+ listOrders(eventId: number): Promise {
+ return unwrap(this.request.get(`events/${eventId}/orders`, { headers: jsonHeaders }));
+ }
+
+ async findOrderIdByShortId(eventId: number, orderShortId: string): Promise {
+ const orders = await this.listOrders(eventId);
+ const order = orders.find((candidate) => candidate.short_id === orderShortId);
+ if (!order) {
+ throw new Error(`Order ${orderShortId} not found among ${orders.length} orders for event ${eventId}`);
+ }
+ return order.id;
+ }
+
+ markOrderAsPaid(eventId: number, orderId: number): Promise {
+ return check(this.request.post(`events/${eventId}/orders/${orderId}/mark-as-paid`, { headers: jsonHeaders }));
+ }
+
+ cancelOrder(eventId: number, orderId: number): Promise {
+ return check(this.request.post(`events/${eventId}/orders/${orderId}/cancel`, { headers: jsonHeaders }));
+ }
+
+ generateOccurrences(eventId: number, recurrenceRule: RecurrenceRule): Promise {
+ return unwrap(
+ this.request.post(`events/${eventId}/occurrences/generate`, {
+ headers: jsonHeaders,
+ data: { recurrence_rule: recurrenceRule },
+ }),
+ );
+ }
+
+ listOccurrences(eventId: number): Promise {
+ return unwrap(this.request.get(`events/${eventId}/occurrences`, { headers: jsonHeaders }));
+ }
+
+ createOrganizerLocation(organizerId: number, payload: CreateOrganizerLocationPayload): Promise<{ id: number }> {
+ return unwrap<{ id: number }>(
+ this.request.post(`organizers/${organizerId}/locations`, { headers: jsonHeaders, data: payload }),
+ );
+ }
+
+ updateOccurrence(eventId: number, occurrenceId: number, payload: UpdateOccurrencePayload): Promise {
+ return check(
+ this.request.put(`events/${eventId}/occurrences/${occurrenceId}`, { headers: jsonHeaders, data: payload }),
+ );
+ }
+
+ setOccurrencePriceOverride(eventId: number, occurrenceId: number, payload: OccurrencePriceOverridePayload): Promise {
+ return check(
+ this.request.put(`events/${eventId}/occurrences/${occurrenceId}/price-overrides`, {
+ headers: jsonHeaders,
+ data: payload,
+ }),
+ );
+ }
+}
+
+export class AdminApiClient {
+ constructor(private readonly request: APIRequestContext) {}
+
+ setMessagingTier(accountId: number, messagingTierId: number): Promise {
+ return check(
+ this.request.put(`admin/accounts/${accountId}/messaging-tier`, {
+ headers: jsonHeaders,
+ data: { messaging_tier_id: messagingTierId },
+ }),
+ );
+ }
+}
diff --git a/e2e/api/factory.ts b/e2e/api/factory.ts
new file mode 100644
index 0000000000..d10e9fa486
--- /dev/null
+++ b/e2e/api/factory.ts
@@ -0,0 +1,392 @@
+import type { APIRequestContext } from '@playwright/test';
+import type { ApiClient } from './api-client';
+import type { EventType, Occurrence, Organizer, ProductPriceType, PublicOrder, QuestionRecord } from './types';
+import {
+ awaitOfflinePayment,
+ completePublicOrder,
+ createPublicOrder,
+ getPublicOrder,
+ type QuestionAnswer,
+} from './public-client';
+import { uniqueEmail, uniqueName } from '../utils/unique';
+
+export interface SeededEvent {
+ eventId: number;
+ slug: string;
+ title: string;
+ productId: number;
+ productTitle: string;
+ priceId: number;
+}
+
+interface SeedOptions {
+ organizerId: number;
+ price?: number;
+ productType?: ProductPriceType;
+ eventType?: EventType;
+ category?: string;
+ title?: string;
+ productTitle?: string;
+ quantityAvailable?: number;
+ waitlistEnabled?: boolean;
+ taxIds?: number[];
+ prices?: { price: number; label?: string }[];
+}
+
+const futureStartDate = (): string => {
+ const date = new Date();
+ date.setDate(date.getDate() + 30);
+ date.setHours(21, 0, 0, 0);
+ return date.toISOString();
+};
+
+export async function createLiveEventWithProduct(api: ApiClient, opts: SeedOptions): Promise {
+ const {
+ organizerId,
+ price = 0,
+ productType = price > 0 ? 'PAID' : 'FREE',
+ eventType = 'SINGLE',
+ category = 'MUSIC',
+ title = uniqueName('E2E Event'),
+ productTitle = productType === 'FREE' ? 'Free Ticket' : 'Paid Ticket',
+ } = opts;
+
+ const event = await api.createEvent({
+ title,
+ type: eventType,
+ organizer_id: organizerId,
+ start_date: futureStartDate(),
+ category,
+ currency: 'USD',
+ timezone: 'UTC',
+ });
+
+ const categories = await api.listProductCategories(event.id);
+ const categoryId = categories[0].id;
+
+ const created = await api.createProduct(event.id, {
+ title: productTitle,
+ product_type: 'TICKET',
+ type: productType,
+ product_category_id: categoryId,
+ prices: (opts.prices ?? [{ price }]).map((priceEntry) => ({
+ ...(opts.quantityAvailable !== undefined ? { initial_quantity_available: opts.quantityAvailable } : {}),
+ ...priceEntry,
+ })),
+ ...(opts.waitlistEnabled !== undefined ? { waitlist_enabled: opts.waitlistEnabled } : {}),
+ ...(opts.taxIds ? { tax_and_fee_ids: opts.taxIds } : {}),
+ });
+
+ const product = await api.getProduct(event.id, created.id);
+ const priceId = product.prices?.[0]?.id;
+ if (!priceId) {
+ throw new Error(`Product ${created.id} has no prices in GET response`);
+ }
+
+ await api.publishEvent(event.id);
+
+ return { eventId: event.id, slug: event.slug, title, productId: created.id, productTitle, priceId };
+}
+
+export interface SeededDraftEvent {
+ eventId: number;
+ slug: string;
+ title: string;
+}
+
+export async function createDraftEvent(
+ api: ApiClient,
+ organizerId: number,
+ opts: { title?: string } = {},
+): Promise {
+ const title = opts.title ?? uniqueName('E2E Event');
+ const event = await api.createEvent({
+ title,
+ type: 'SINGLE',
+ organizer_id: organizerId,
+ start_date: futureStartDate(),
+ category: 'MUSIC',
+ currency: 'USD',
+ timezone: 'UTC',
+ });
+ return { eventId: event.id, slug: event.slug, title };
+}
+
+export async function createDraftEventWithTicket(
+ api: ApiClient,
+ organizerId: number,
+ opts: { title?: string; productTitle?: string; price?: number } = {},
+): Promise {
+ const { eventId, slug, title } = await createDraftEvent(api, organizerId, opts);
+ const categories = await api.listProductCategories(eventId);
+ const categoryId = categories[0].id;
+ const price = opts.price ?? 0;
+ const productTitle = opts.productTitle ?? 'General Admission';
+
+ const created = await api.createProduct(eventId, {
+ title: productTitle,
+ product_type: 'TICKET',
+ type: price > 0 ? 'PAID' : 'FREE',
+ product_category_id: categoryId,
+ prices: [{ price }],
+ });
+
+ const product = await api.getProduct(eventId, created.id);
+ const priceId = product.prices?.[0]?.id;
+ if (!priceId) {
+ throw new Error(`Product ${created.id} has no prices in GET response`);
+ }
+
+ return { eventId, slug, title, productId: created.id, productTitle, categoryId, priceId };
+}
+
+export const createLiveEventWithFreeTicket = (api: ApiClient, organizerId: number): Promise =>
+ createLiveEventWithProduct(api, { organizerId, price: 0 });
+
+export const createLiveEventWithPaidTicket = (
+ api: ApiClient,
+ organizerId: number,
+ price = 25,
+): Promise => createLiveEventWithProduct(api, { organizerId, price });
+
+export interface SeededOrder {
+ orderShortId: string;
+ sessionId: string;
+ buyerEmail: string;
+ buyerFirstName: string;
+ buyerLastName: string;
+ attendees: { shortId: string; publicId: string }[];
+}
+
+export interface OrderSeedOptions {
+ buyerEmail?: string;
+ buyerFirstName?: string;
+ buyerLastName?: string;
+ quantity?: number;
+ promoCode?: string;
+ affiliateCode?: string;
+ eventOccurrenceId?: number;
+ orderQuestions?: QuestionAnswer[];
+ attendeeQuestions?: QuestionAnswer[];
+}
+
+const mapAttendees = (order: PublicOrder): { shortId: string; publicId: string }[] =>
+ (order.attendees ?? []).map((attendee) => ({ shortId: attendee.short_id, publicId: attendee.public_id }));
+
+export async function createReservedOrder(
+ publicApi: APIRequestContext,
+ event: Pick,
+ opts: OrderSeedOptions = {},
+): Promise<{ orderShortId: string; sessionId: string }> {
+ const order = await createPublicOrder(publicApi, event.eventId, {
+ products: [
+ {
+ product_id: event.productId,
+ quantities: [{ price_id: event.priceId, quantity: opts.quantity ?? 1 }],
+ ...(opts.eventOccurrenceId ? { event_occurrence_id: opts.eventOccurrenceId } : {}),
+ },
+ ],
+ promoCode: opts.promoCode,
+ affiliateCode: opts.affiliateCode,
+ });
+ if (!order.session_identifier) {
+ throw new Error(`Created order ${order.short_id} has no session_identifier in the response`);
+ }
+ return { orderShortId: order.short_id, sessionId: order.session_identifier };
+}
+
+export async function createCompletedOrder(
+ publicApi: APIRequestContext,
+ event: Pick,
+ opts: OrderSeedOptions = {},
+): Promise {
+ const buyerEmail = opts.buyerEmail ?? uniqueEmail('buyer');
+ const buyerFirstName = opts.buyerFirstName ?? 'Test';
+ const buyerLastName = opts.buyerLastName ?? 'Buyer';
+ const quantity = opts.quantity ?? 1;
+
+ const { orderShortId, sessionId } = await createReservedOrder(publicApi, event, opts);
+
+ await completePublicOrder(publicApi, event.eventId, orderShortId, sessionId, {
+ order: {
+ first_name: buyerFirstName,
+ last_name: buyerLastName,
+ email: buyerEmail,
+ ...(opts.orderQuestions ? { questions: opts.orderQuestions } : {}),
+ },
+ attendees: Array.from({ length: quantity }, () => ({
+ product_id: event.productId,
+ product_price_id: event.priceId,
+ first_name: buyerFirstName,
+ last_name: buyerLastName,
+ email: buyerEmail,
+ ...(opts.attendeeQuestions ? { questions: opts.attendeeQuestions } : {}),
+ })),
+ });
+ const completed = await getPublicOrder(publicApi, event.eventId, orderShortId, sessionId);
+
+ return {
+ orderShortId,
+ sessionId,
+ buyerEmail,
+ buyerFirstName,
+ buyerLastName,
+ attendees: mapAttendees(completed),
+ };
+}
+
+export const OFFLINE_PAYMENT_INSTRUCTIONS = 'Pay by bank transfer to account 12345678 within 5 days.';
+
+export async function enableOfflinePayments(api: ApiClient, eventId: number): Promise {
+ await api.updateEventSettings(eventId, {
+ payment_providers: ['OFFLINE'],
+ offline_payment_instructions: OFFLINE_PAYMENT_INSTRUCTIONS,
+ });
+}
+
+export async function createAwaitingOfflineOrder(
+ api: ApiClient,
+ publicApi: APIRequestContext,
+ event: Pick,
+ opts: OrderSeedOptions = {},
+): Promise {
+ await enableOfflinePayments(api, event.eventId);
+ const seeded = await createCompletedOrder(publicApi, event, opts);
+ await awaitOfflinePayment(publicApi, event.eventId, seeded.orderShortId, seeded.sessionId);
+ const orderId = await api.findOrderIdByShortId(event.eventId, seeded.orderShortId);
+ return { ...seeded, orderId };
+}
+
+export async function createCompletedPaidOrder(
+ api: ApiClient,
+ publicApi: APIRequestContext,
+ event: Pick,
+ opts: OrderSeedOptions = {},
+): Promise {
+ const seeded = await createAwaitingOfflineOrder(api, publicApi, event, opts);
+ await api.markOrderAsPaid(event.eventId, seeded.orderId);
+ const completed = await getPublicOrder(publicApi, event.eventId, seeded.orderShortId, seeded.sessionId);
+ return { ...seeded, attendees: mapAttendees(completed) };
+}
+
+export async function createSoldOutEvent(
+ api: ApiClient,
+ publicApi: APIRequestContext,
+ organizerId: number,
+ opts: { waitlist?: boolean; title?: string } = {},
+): Promise {
+ const event = await createLiveEventWithProduct(api, {
+ organizerId,
+ price: 0,
+ title: opts.title,
+ quantityAvailable: 1,
+ waitlistEnabled: opts.waitlist ?? false,
+ });
+ const consumedOrder = await createCompletedOrder(publicApi, event);
+ return { ...event, consumedOrder };
+}
+
+export async function createRecurringLiveEvent(
+ api: ApiClient,
+ organizerId: number,
+ opts: { count?: number; price?: number; title?: string } = {},
+): Promise {
+ const count = opts.count ?? 3;
+ const price = opts.price ?? 0;
+ const title = opts.title ?? uniqueName('E2E Recurring');
+
+ const event = await api.createEvent({
+ title,
+ type: 'RECURRING',
+ organizer_id: organizerId,
+ start_date: futureStartDate(),
+ category: 'MUSIC',
+ currency: 'USD',
+ timezone: 'UTC',
+ });
+
+ await api.generateOccurrences(event.id, {
+ frequency: 'weekly',
+ range: { type: 'count', count },
+ days_of_week: ['friday'],
+ times_of_day: ['19:00'],
+ duration_minutes: 120,
+ });
+ const occurrences = await api.listOccurrences(event.id);
+
+ const categories = await api.listProductCategories(event.id);
+ const created = await api.createProduct(event.id, {
+ title: price > 0 ? 'Paid Ticket' : 'Free Ticket',
+ product_type: 'TICKET',
+ type: price > 0 ? 'PAID' : 'FREE',
+ product_category_id: categories[0].id,
+ prices: [{ price }],
+ });
+ const product = await api.getProduct(event.id, created.id);
+ const priceId = product.prices?.[0]?.id;
+ if (!priceId) {
+ throw new Error(`Product ${created.id} has no prices in GET response`);
+ }
+
+ await api.publishEvent(event.id);
+
+ return {
+ eventId: event.id,
+ slug: event.slug,
+ title,
+ productId: created.id,
+ productTitle: created.title,
+ priceId,
+ occurrences,
+ };
+}
+
+export async function createEventWithQuestions(
+ api: ApiClient,
+ organizerId: number,
+ opts: { orderQuestionTitle?: string; attendeeQuestionTitle?: string } = {},
+): Promise {
+ const event = await createLiveEventWithProduct(api, { organizerId, price: 0 });
+ const orderQuestion = await api.createQuestion(event.eventId, {
+ title: opts.orderQuestionTitle ?? 'How did you hear about us?',
+ type: 'SINGLE_LINE_TEXT',
+ belongs_to: 'ORDER',
+ product_ids: [],
+ required: true,
+ is_hidden: false,
+ });
+ const attendeeQuestion = await api.createQuestion(event.eventId, {
+ title: opts.attendeeQuestionTitle ?? 'Shirt size',
+ type: 'RADIO',
+ belongs_to: 'PRODUCT',
+ product_ids: [event.productId],
+ options: ['Small', 'Medium', 'Large'],
+ required: true,
+ is_hidden: false,
+ });
+ return { ...event, orderQuestion, attendeeQuestion };
+}
+
+export async function createEventWithAttendee(
+ api: ApiClient,
+ organizerId: number,
+ opts: { attendeeEmail?: string; live?: boolean } = {},
+): Promise {
+ const event = await createLiveEventWithProduct(api, { organizerId, price: 0 });
+ const attendeeEmail = opts.attendeeEmail ?? uniqueEmail('attendee');
+ const attendee = await api.createAttendee(event.eventId, {
+ product_id: event.productId,
+ product_price_id: event.priceId,
+ email: attendeeEmail,
+ first_name: 'Seeded',
+ last_name: 'Attendee',
+ amount_paid: 0,
+ send_confirmation_email: false,
+ locale: 'en',
+ });
+ return { ...event, attendeeEmail, attendeeId: attendee.id };
+}
+
+export function createFreshOrganizer(api: ApiClient, name?: string): Promise {
+ return api.createOrganizer(name ?? uniqueName('E2E Org'), { email: uniqueEmail('organizer') });
+}
diff --git a/e2e/api/public-client.ts b/e2e/api/public-client.ts
new file mode 100644
index 0000000000..d6ebaf3680
--- /dev/null
+++ b/e2e/api/public-client.ts
@@ -0,0 +1,121 @@
+import type { APIRequestContext, APIResponse } from '@playwright/test';
+import type { PublicOrder } from './types';
+
+const jsonHeaders = { 'Content-Type': 'application/json', Accept: 'application/json' };
+
+const unwrap = async (promise: Promise): Promise => {
+ const response = await promise;
+ if (!response.ok()) {
+ throw new Error(`Public API ${response.url()} → ${response.status()}: ${await response.text()}`);
+ }
+ const body = (await response.json()) as { data: T };
+ return body.data;
+};
+
+export interface PublicOrderLine {
+ product_id: number;
+ quantities: { price_id: number; quantity: number; price?: number }[];
+ event_occurrence_id?: number;
+}
+
+export interface QuestionAnswer {
+ question_id: number;
+ response: { answer: string | string[] };
+}
+
+export interface BuyerInfo {
+ first_name: string;
+ last_name: string;
+ email: string;
+}
+
+export interface AttendeeEntry extends BuyerInfo {
+ product_id: number;
+ product_price_id: number;
+ questions?: QuestionAnswer[];
+}
+
+export function createPublicOrder(
+ request: APIRequestContext,
+ eventId: number,
+ opts: { products: PublicOrderLine[]; promoCode?: string; affiliateCode?: string },
+): Promise {
+ return unwrap(
+ request.post(`public/events/${eventId}/order`, {
+ headers: jsonHeaders,
+ data: {
+ products: opts.products,
+ ...(opts.promoCode ? { promo_code: opts.promoCode } : {}),
+ ...(opts.affiliateCode ? { affiliate_code: opts.affiliateCode } : {}),
+ },
+ }),
+ );
+}
+
+export function completePublicOrder(
+ request: APIRequestContext,
+ eventId: number,
+ orderShortId: string,
+ sessionId: string,
+ opts: { order: BuyerInfo & { questions?: QuestionAnswer[] }; attendees: AttendeeEntry[] },
+): Promise {
+ return unwrap(
+ request.put(`public/events/${eventId}/order/${orderShortId}?session_identifier=${sessionId}`, {
+ headers: jsonHeaders,
+ data: {
+ order: { ...opts.order, email_confirmation: opts.order.email },
+ products: opts.attendees.map((attendee) => ({ ...attendee, email_confirmation: attendee.email })),
+ },
+ }),
+ );
+}
+
+export function getPublicOrder(
+ request: APIRequestContext,
+ eventId: number,
+ orderShortId: string,
+ sessionId?: string,
+): Promise {
+ const query = sessionId ? `?session_identifier=${sessionId}` : '';
+ return unwrap(
+ request.get(`public/events/${eventId}/order/${orderShortId}${query}`, { headers: jsonHeaders }),
+ );
+}
+
+export function awaitOfflinePayment(
+ request: APIRequestContext,
+ eventId: number,
+ orderShortId: string,
+ sessionId: string,
+): Promise {
+ return unwrap(
+ request.post(
+ `public/events/${eventId}/order/${orderShortId}/await-offline-payment?session_identifier=${sessionId}`,
+ { headers: jsonHeaders },
+ ),
+ );
+}
+
+export async function joinWaitlist(
+ request: APIRequestContext,
+ eventId: number,
+ payload: {
+ product_price_id: number;
+ email: string;
+ first_name: string;
+ last_name?: string;
+ event_occurrence_id?: number;
+ },
+): Promise {
+ const response = await request.post(`public/events/${eventId}/waitlist`, { headers: jsonHeaders, data: payload });
+ if (!response.ok()) {
+ throw new Error(`join waitlist → ${response.status()}: ${await response.text()}`);
+ }
+}
+
+export async function sendTicketLookupEmail(request: APIRequestContext, email: string): Promise {
+ const response = await request.post('public/ticket-lookup', { headers: jsonHeaders, data: { email } });
+ if (!response.ok()) {
+ throw new Error(`ticket lookup → ${response.status()}: ${await response.text()}`);
+ }
+}
diff --git a/e2e/api/stripe.ts b/e2e/api/stripe.ts
new file mode 100644
index 0000000000..9f77328a3d
--- /dev/null
+++ b/e2e/api/stripe.ts
@@ -0,0 +1,61 @@
+import { createHmac, randomUUID } from 'node:crypto';
+import type { APIRequestContext } from '@playwright/test';
+import { STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET } from '../utils/env';
+
+export function parsePaymentReturnUrl(pageUrl: string): { orderShortId: string; sessionId: string } {
+ const url = new URL(pageUrl);
+ const match = url.pathname.match(/^\/checkout\/\d+\/([^/]+)\//);
+ if (!match) {
+ throw new Error(`Not a checkout URL: ${pageUrl}`);
+ }
+ return { orderShortId: match[1], sessionId: url.searchParams.get('session_identifier') ?? '' };
+}
+
+export async function deliverPaymentIntentSucceededWebhook(
+ request: APIRequestContext,
+ opts: { eventId: number; orderShortId: string; sessionId: string },
+): Promise {
+ const intentResponse = await request.get(
+ `public/events/${opts.eventId}/order/${opts.orderShortId}/stripe/payment_intent?session_identifier=${opts.sessionId}`,
+ );
+ if (!intentResponse.ok()) {
+ throw new Error(`get payment intent → ${intentResponse.status()}: ${await intentResponse.text()}`);
+ }
+ const { paymentIntentId } = (await intentResponse.json()) as { paymentIntentId: string };
+
+ const stripeResponse = await request.get(
+ `https://api.stripe.com/v1/payment_intents/${paymentIntentId}?expand[]=latest_charge`,
+ { headers: { Authorization: `Bearer ${STRIPE_SECRET_KEY}` } },
+ );
+ if (!stripeResponse.ok()) {
+ throw new Error(`stripe payment intent fetch → ${stripeResponse.status()}: ${await stripeResponse.text()}`);
+ }
+ const paymentIntent = await stripeResponse.json();
+
+ const timestamp = Math.floor(Date.now() / 1000);
+ const payload = JSON.stringify({
+ id: `evt_e2e_${randomUUID().replace(/-/g, '')}`,
+ object: 'event',
+ api_version: '2024-06-20',
+ created: timestamp,
+ type: 'payment_intent.succeeded',
+ data: { object: paymentIntent },
+ livemode: false,
+ pending_webhooks: 1,
+ request: { id: null, idempotency_key: null },
+ });
+ const signature = createHmac('sha256', STRIPE_WEBHOOK_SECRET)
+ .update(`${timestamp}.${payload}`)
+ .digest('hex');
+
+ const webhookResponse = await request.post('public/webhooks/stripe', {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Stripe-Signature': `t=${timestamp},v1=${signature}`,
+ },
+ data: payload,
+ });
+ if (!webhookResponse.ok()) {
+ throw new Error(`stripe webhook delivery → ${webhookResponse.status()}: ${await webhookResponse.text()}`);
+ }
+}
diff --git a/e2e/api/types.ts b/e2e/api/types.ts
new file mode 100644
index 0000000000..ecaf22178d
--- /dev/null
+++ b/e2e/api/types.ts
@@ -0,0 +1,317 @@
+export type EventType = 'SINGLE' | 'RECURRING';
+export type ProductKind = 'TICKET' | 'GENERAL';
+export type ProductPriceType = 'FREE' | 'PAID' | 'DONATION' | 'TIERED';
+export type EventStatus = 'DRAFT' | 'LIVE' | 'ARCHIVED';
+export type QuestionType =
+ | 'ADDRESS'
+ | 'PHONE'
+ | 'SINGLE_LINE_TEXT'
+ | 'MULTI_LINE_TEXT'
+ | 'CHECKBOX'
+ | 'RADIO'
+ | 'DROPDOWN'
+ | 'MULTI_SELECT_DROPDOWN'
+ | 'DATE';
+
+export interface RegisterPayload {
+ first_name: string;
+ last_name?: string;
+ email: string;
+ password: string;
+ password_confirmation: string;
+ timezone?: string;
+ currency_code?: string;
+ locale?: string;
+}
+
+export interface Me {
+ id: number;
+ email: string;
+ is_email_verified: boolean;
+}
+
+export interface Organizer {
+ id: number;
+ name: string;
+ slug: string;
+ email?: string;
+}
+
+export interface EventRecord {
+ id: number;
+ title: string;
+ slug: string;
+ status: EventStatus;
+}
+
+export interface ProductCategory {
+ id: number;
+ name: string;
+}
+
+export interface ProductPrice {
+ id: number;
+ price: number;
+ label?: string | null;
+}
+
+export interface ProductRecord {
+ id: number;
+ title: string;
+ type: ProductPriceType;
+ product_type: ProductKind;
+ price: number;
+ prices?: ProductPrice[];
+}
+
+export interface CreateEventPayload {
+ title: string;
+ type: EventType;
+ organizer_id: number;
+ start_date: string;
+ end_date?: string;
+ category: string;
+ currency?: string;
+ timezone?: string;
+}
+
+export interface CreateProductPricePayload {
+ price: number;
+ label?: string;
+ initial_quantity_available?: number;
+}
+
+export interface CreateProductPayload {
+ title: string;
+ product_type: ProductKind;
+ type: ProductPriceType;
+ product_category_id: number;
+ prices: CreateProductPricePayload[];
+ tax_and_fee_ids?: number[];
+ max_per_order?: number;
+ min_per_order?: number;
+ is_hidden?: boolean;
+ is_hidden_without_promo_code?: boolean;
+ sale_start_date?: string;
+ sale_end_date?: string;
+}
+
+export interface CreateProductCategoryPayload {
+ name: string;
+ description?: string;
+ is_hidden: boolean;
+ no_products_message?: string;
+}
+
+export interface PublicOrderAttendee {
+ short_id: string;
+ public_id: string;
+ first_name: string;
+ last_name: string;
+}
+
+export interface PublicOrder {
+ short_id: string;
+ session_identifier?: string;
+ status: string;
+ payment_status?: string | null;
+ total_gross: number;
+ attendees?: PublicOrderAttendee[];
+}
+
+export interface OrderRecord {
+ id: number;
+ short_id: string;
+ status: string;
+ payment_status?: string | null;
+ email: string;
+ total_gross: number;
+}
+
+export interface AttendeeRecord {
+ id: number;
+ short_id: string;
+ public_id: string;
+ first_name: string;
+ last_name: string;
+ email: string;
+ status: string;
+}
+
+export interface PromoCode {
+ id: number;
+ code: string;
+}
+
+export interface CheckInList {
+ id: number;
+ short_id: string;
+ name: string;
+}
+
+export interface Occurrence {
+ id: number;
+ start_date: string;
+ end_date: string | null;
+ status: string;
+}
+
+export interface CreateOrganizerLocationPayload {
+ name?: string;
+ structured_address: {
+ venue_name?: string;
+ address_line_1?: string;
+ city?: string;
+ state_or_region?: string;
+ zip_or_postal_code?: string;
+ country?: string;
+ };
+}
+
+export interface UpdateOccurrencePayload {
+ start_date: string;
+ end_date?: string | null;
+ label?: string;
+ event_location?:
+ | { type: 'IN_PERSON'; location_id: number }
+ | { type: 'ONLINE'; online_event_connection_details: string };
+}
+
+export interface OccurrencePriceOverridePayload {
+ product_price_id: number;
+ price: number;
+}
+
+export interface QuestionRecord {
+ id: number;
+ title: string;
+}
+
+export interface TaxOrFee {
+ id: number;
+ name: string;
+}
+
+export interface CapacityAssignment {
+ id: number;
+ name: string;
+}
+
+export interface Affiliate {
+ id: number;
+ code: string;
+}
+
+export interface Webhook {
+ id: number;
+ url: string;
+}
+
+export interface EmailTemplate {
+ id: number;
+ subject: string;
+}
+
+export interface EventSettings {
+ payment_providers?: string[];
+ offline_payment_instructions?: string | null;
+ waitlist_enabled?: boolean;
+ [key: string]: unknown;
+}
+
+export interface CreatePromoCodePayload {
+ code: string;
+ discount_type: 'NONE' | 'FIXED' | 'PERCENTAGE';
+ discount?: number;
+ discount_applies_to?: 'ORDER' | 'EACH_PRODUCT';
+ applicable_product_ids?: number[];
+ expiry_date?: string;
+ max_allowed_usages?: number;
+}
+
+export interface CreateQuestionPayload {
+ title: string;
+ type: QuestionType;
+ belongs_to: 'PRODUCT' | 'ORDER';
+ product_ids?: number[];
+ options?: string[];
+ required: boolean;
+ is_hidden: boolean;
+ description?: string;
+}
+
+export interface CreateTaxOrFeePayload {
+ name: string;
+ calculation_type: 'PERCENTAGE' | 'FIXED';
+ type: 'TAX' | 'FEE';
+ rate: number;
+ is_active: boolean;
+ is_default: boolean;
+ description?: string;
+}
+
+export interface CreateCheckInListPayload {
+ name: string;
+ description?: string;
+ expires_at?: string;
+ activates_at?: string;
+ product_ids?: number[];
+ event_occurrence_id?: number;
+}
+
+export interface CreateAttendeePayload {
+ product_id: number;
+ product_price_id?: number;
+ event_occurrence_id?: number;
+ email: string;
+ first_name: string;
+ last_name?: string;
+ amount_paid: number;
+ send_confirmation_email: boolean;
+ locale: string;
+}
+
+export interface CreateCapacityAssignmentPayload {
+ name: string;
+ capacity?: number;
+ status: 'ACTIVE' | 'INACTIVE';
+ product_ids: number[];
+}
+
+export interface CreateAffiliatePayload {
+ name: string;
+ code: string;
+ email?: string;
+ status?: 'ACTIVE' | 'INACTIVE';
+}
+
+export interface CreateWebhookPayload {
+ url: string;
+ event_types: string[];
+ status: 'ENABLED' | 'PAUSED';
+}
+
+export interface CreateEmailTemplatePayload {
+ template_type: 'order_confirmation' | 'attendee_ticket' | 'occurrence_cancellation';
+ subject: string;
+ body: string;
+ ctaLabel: string;
+ isActive?: boolean;
+}
+
+export interface InviteUserPayload {
+ first_name: string;
+ last_name?: string;
+ email: string;
+ role: 'ADMIN' | 'ORGANIZER';
+}
+
+export interface RecurrenceRule {
+ frequency: 'daily' | 'weekly' | 'monthly' | 'yearly';
+ interval?: number;
+ range: { type: 'count' | 'until'; count?: number; until?: string; start?: string };
+ days_of_week?: string[];
+ times_of_day?: (string | { time: string; label?: string; duration_minutes?: number })[];
+ duration_minutes?: number;
+ default_capacity?: number;
+}
diff --git a/e2e/fixtures/account.fixture.ts b/e2e/fixtures/account.fixture.ts
new file mode 100644
index 0000000000..7d4105cef1
--- /dev/null
+++ b/e2e/fixtures/account.fixture.ts
@@ -0,0 +1,44 @@
+import type { APIRequestContext } from '@playwright/test';
+import type { ApiClient } from '../api/api-client';
+import { confirmEmailWithCode, login, registerAccount } from '../api/api-client';
+import type { MailpitClient } from '../utils/mailpit';
+import { uniqueEmail } from '../utils/unique';
+import { IS_SAAS_MODE } from '../utils/env';
+
+export interface AccountIdentity {
+ email: string;
+ password: string;
+ token: string;
+ userId: number;
+}
+
+export interface AccountContext extends AccountIdentity {
+ organizerId: number;
+ api: ApiClient;
+}
+
+const PASSWORD = 'Password123!';
+
+export async function bootstrapAccount(anon: APIRequestContext, mailpit: MailpitClient): Promise {
+ const email = uniqueEmail();
+
+ await registerAccount(anon, {
+ first_name: 'E2E',
+ last_name: 'Organizer',
+ email,
+ password: PASSWORD,
+ password_confirmation: PASSWORD,
+ timezone: 'UTC',
+ currency_code: 'USD',
+ locale: 'en',
+ });
+
+ const { token, user } = await login(anon, email, PASSWORD);
+
+ if (IS_SAAS_MODE && !user.is_email_verified) {
+ const code = await mailpit.waitForVerificationCode(email);
+ await confirmEmailWithCode(anon, token, user.id, code);
+ }
+
+ return { email, password: PASSWORD, token, userId: user.id };
+}
diff --git a/e2e/fixtures/auth.fixture.ts b/e2e/fixtures/auth.fixture.ts
new file mode 100644
index 0000000000..60e95908fc
--- /dev/null
+++ b/e2e/fixtures/auth.fixture.ts
@@ -0,0 +1,29 @@
+import type { Browser, BrowserContextOptions, Page } from '@playwright/test';
+import { BASE_URL, cookieDomain } from '../utils/env';
+
+export function buildStorageState(token: string): BrowserContextOptions['storageState'] {
+ return {
+ cookies: [
+ {
+ name: 'token',
+ value: token,
+ domain: cookieDomain(),
+ path: '/',
+ expires: -1,
+ httpOnly: true,
+ secure: true,
+ sameSite: 'None',
+ },
+ ],
+ origins: [],
+ };
+}
+
+export async function openAuthedPage(browser: Browser, token: string): Promise {
+ const context = await browser.newContext({
+ baseURL: BASE_URL,
+ ignoreHTTPSErrors: true,
+ storageState: buildStorageState(token),
+ });
+ return context.newPage();
+}
diff --git a/e2e/fixtures/index.ts b/e2e/fixtures/index.ts
new file mode 100644
index 0000000000..3e9e953165
--- /dev/null
+++ b/e2e/fixtures/index.ts
@@ -0,0 +1,170 @@
+import { test as base, type APIRequest, type APIRequestContext, type BrowserContext, type Page } from '@playwright/test';
+import { AdminApiClient, ApiClient, login } from '../api/api-client';
+import { MailpitClient } from '../utils/mailpit';
+import { bootstrapAccount, type AccountContext, type AccountIdentity } from './account.fixture';
+import { buildStorageState, openAuthedPage } from './auth.fixture';
+import { API_BASE_URL, BASE_URL, SUPERADMIN_EMAIL, SUPERADMIN_PASSWORD } from '../utils/env';
+import { uniqueName } from '../utils/unique';
+
+export interface FreshAccount extends AccountIdentity {
+ organizerId: number;
+ api: ApiClient;
+ newAuthedPage(): Promise;
+}
+
+interface SuperAdminAuth {
+ token: string;
+}
+
+interface WorkerFixtures {
+ /** A registered, verified account with an organizer and an authorized API client. Shared per worker. */
+ account: AccountContext;
+ /** Authorized API client for arranging data. Alias of `account.api`. */
+ api: ApiClient;
+ /** Superadmin token from the provisioned dev:bootstrap account, or null if unavailable. */
+ superAdminAuth: SuperAdminAuth | null;
+}
+
+interface TestFixtures {
+ /** A page pre-authenticated as `account` (auth cookie injected — no UI login). */
+ authedPage: Page;
+ /** Mailpit client for asserting on outbound email. */
+ mailpit: MailpitClient;
+ /** Anonymous API context rooted at the API base URL, for public (buyer-side) endpoints. */
+ publicApi: APIRequestContext;
+ /** A brand-new account + organizer, isolated to this test. Use for account/auth-level mutations. */
+ freshAccount: FreshAccount;
+ /** Admin API client authenticated as the provisioned superadmin. Skips the test if unavailable locally. */
+ adminApi: AdminApiClient;
+ /** A page pre-authenticated as the superadmin. Skips the test if unavailable locally. */
+ superAdminPage: Page;
+}
+
+const newApiContext = (
+ playwright: { request: APIRequest },
+ extraHeaders: Record = {},
+): Promise =>
+ playwright.request.newContext({
+ baseURL: API_BASE_URL,
+ ignoreHTTPSErrors: true,
+ extraHTTPHeaders: { Accept: 'application/json', ...extraHeaders },
+ });
+
+export const test = base.extend({
+ account: [
+ async ({ playwright }, use) => {
+ const anon = await newApiContext(playwright);
+ const mailpit = new MailpitClient(anon);
+ const identity = await bootstrapAccount(anon, mailpit);
+
+ const authed = await newApiContext(playwright, { Authorization: `Bearer ${identity.token}` });
+ const api = new ApiClient(authed);
+ const organizer = await api.createOrganizer(uniqueName('E2E Org'));
+
+ await use({ ...identity, organizerId: organizer.id, api });
+
+ await anon.dispose();
+ await authed.dispose();
+ },
+ { scope: 'worker' },
+ ],
+
+ api: [
+ async ({ account }, use) => {
+ await use(account.api);
+ },
+ { scope: 'worker' },
+ ],
+
+ superAdminAuth: [
+ async ({ playwright }, use) => {
+ const anon = await newApiContext(playwright);
+ let auth: SuperAdminAuth | null = null;
+ try {
+ const { token } = await login(anon, SUPERADMIN_EMAIL, SUPERADMIN_PASSWORD);
+ auth = { token };
+ } catch (error) {
+ if (process.env.CI) {
+ throw new Error(
+ `Superadmin login failed in CI — check the "Provisioning superadmin" step in e2e/run-e2e.sh. ${error}`,
+ );
+ }
+ } finally {
+ await anon.dispose();
+ }
+ await use(auth);
+ },
+ { scope: 'worker' },
+ ],
+
+ mailpit: async ({ request }, use) => {
+ await use(new MailpitClient(request));
+ },
+
+ publicApi: async ({ playwright }, use) => {
+ const context = await newApiContext(playwright);
+ await use(context);
+ await context.dispose();
+ },
+
+ authedPage: async ({ browser, account }, use) => {
+ const context = await browser.newContext({
+ baseURL: BASE_URL,
+ ignoreHTTPSErrors: true,
+ storageState: buildStorageState(account.token),
+ });
+ const page = await context.newPage();
+ await use(page);
+ await context.close();
+ },
+
+ freshAccount: async ({ playwright, browser }, use) => {
+ const anon = await newApiContext(playwright);
+ const mailpit = new MailpitClient(anon);
+ const identity = await bootstrapAccount(anon, mailpit);
+
+ const authed = await newApiContext(playwright, { Authorization: `Bearer ${identity.token}` });
+ const api = new ApiClient(authed);
+ const organizer = await api.createOrganizer(uniqueName('E2E Fresh Org'));
+
+ const contexts: BrowserContext[] = [];
+ await use({
+ ...identity,
+ organizerId: organizer.id,
+ api,
+ newAuthedPage: async () => {
+ const page = await openAuthedPage(browser, identity.token);
+ contexts.push(page.context());
+ return page;
+ },
+ });
+
+ for (const context of contexts) {
+ await context.close();
+ }
+ await anon.dispose();
+ await authed.dispose();
+ },
+
+ adminApi: async ({ playwright, superAdminAuth }, use, testInfo) => {
+ testInfo.skip(
+ !superAdminAuth,
+ `No superadmin available — provision one with: php artisan dev:bootstrap --email=${SUPERADMIN_EMAIL}`,
+ );
+ const context = await newApiContext(playwright, { Authorization: `Bearer ${superAdminAuth!.token}` });
+ await use(new AdminApiClient(context));
+ await context.dispose();
+ },
+
+ superAdminPage: async ({ browser, superAdminAuth }, use, testInfo) => {
+ testInfo.skip(
+ !superAdminAuth,
+ `No superadmin available — provision one with: php artisan dev:bootstrap --email=${SUPERADMIN_EMAIL}`,
+ );
+ const page = await openAuthedPage(browser, superAdminAuth!.token);
+ await use(page);
+ await page.context().close();
+ },
+});
+
+export { expect } from '@playwright/test';
diff --git a/e2e/package-lock.json b/e2e/package-lock.json
new file mode 100644
index 0000000000..ef0c81bcbc
--- /dev/null
+++ b/e2e/package-lock.json
@@ -0,0 +1,117 @@
+{
+ "name": "hievents-e2e",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "hievents-e2e",
+ "version": "0.0.0",
+ "devDependencies": {
+ "@playwright/test": "^1.61.1",
+ "@types/node": "^22.10.0",
+ "dotenv": "^16.4.7",
+ "typescript": "^5.7.2"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
+ "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
+ "dev": true,
+ "dependencies": {
+ "playwright": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "22.20.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
+ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
+ "dev": true,
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
+ "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
+ "dev": true,
+ "dependencies": {
+ "playwright-core": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
+ "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
+ "dev": true,
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true
+ }
+ }
+}
diff --git a/e2e/package.json b/e2e/package.json
new file mode 100644
index 0000000000..78d5165eab
--- /dev/null
+++ b/e2e/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "hievents-e2e",
+ "version": "0.0.0",
+ "private": true,
+ "description": "End-to-end (Playwright) test suite for Hi.Events",
+ "type": "module",
+ "scripts": {
+ "test": "playwright test",
+ "test:ui": "playwright test --ui",
+ "test:headed": "playwright test --headed",
+ "test:smoke": "playwright test --grep @smoke",
+ "report": "playwright show-report",
+ "install:browsers": "playwright install --with-deps chromium",
+ "typecheck": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.61.1",
+ "@types/node": "^22.10.0",
+ "dotenv": "^16.4.7",
+ "typescript": "^5.7.2"
+ }
+}
diff --git a/e2e/pages/admin.page.ts b/e2e/pages/admin.page.ts
new file mode 100644
index 0000000000..3b868e339f
--- /dev/null
+++ b/e2e/pages/admin.page.ts
@@ -0,0 +1,27 @@
+import type { Locator, Page } from '@playwright/test';
+
+export class AdminPage {
+ constructor(private readonly page: Page) {}
+
+ async gotoDashboard(): Promise {
+ await this.page.goto('/admin');
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ async gotoSection(section: 'users' | 'events' | 'orders'): Promise {
+ await this.page.goto(`/admin/${section}`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ async openAccountsFromSidebar(): Promise {
+ await this.page.getByRole('link', { name: 'Accounts', exact: true }).click();
+ }
+
+ viewDetailsButtons(): Locator {
+ return this.page.getByRole('button', { name: 'View Details' });
+ }
+
+ async search(term: string): Promise {
+ await this.page.getByPlaceholder(/^Search by/).fill(term);
+ }
+}
diff --git a/e2e/pages/affiliate.page.ts b/e2e/pages/affiliate.page.ts
new file mode 100644
index 0000000000..d074b88e2c
--- /dev/null
+++ b/e2e/pages/affiliate.page.ts
@@ -0,0 +1,26 @@
+import type { Locator, Page } from '@playwright/test';
+
+export class AffiliatePage {
+ constructor(private readonly page: Page) {}
+
+ async goto(eventId: number): Promise {
+ await this.page.goto(`/manage/event/${eventId}/affiliates`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ rowByText(text: string): Locator {
+ return this.page.getByRole('row').filter({ hasText: text });
+ }
+
+ async createAffiliate(details: { name: string; code: string }): Promise {
+ await this.page.getByTestId('affiliate-create-button').click();
+ await this.page.getByRole('heading', { name: 'Create Affiliate' }).waitFor();
+ await this.page.getByLabel(/^Code/).fill(details.code);
+ await this.page.getByLabel(/^Name/).fill(details.name);
+ await this.page.getByRole('dialog').getByRole('button', { name: 'Create Affiliate' }).click();
+ }
+
+ async clickExport(): Promise {
+ await this.page.getByRole('button', { name: 'Export' }).click();
+ }
+}
diff --git a/e2e/pages/attendee.page.ts b/e2e/pages/attendee.page.ts
new file mode 100644
index 0000000000..c2d21de32d
--- /dev/null
+++ b/e2e/pages/attendee.page.ts
@@ -0,0 +1,53 @@
+import type { Locator, Page } from '@playwright/test';
+
+export class AttendeePage {
+ constructor(private readonly page: Page) {}
+
+ async goto(eventId: number): Promise {
+ await this.page.goto(`/manage/event/${eventId}/attendees`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ rowByText(text: string): Locator {
+ return this.page.getByRole('row').filter({ hasText: text });
+ }
+
+ async createAttendee(details: {
+ firstName: string;
+ lastName: string;
+ email: string;
+ productTitle: string;
+ }): Promise {
+ await this.page.getByTestId('attendee-create-button').click();
+ await this.page.getByRole('heading', { name: 'Manually Add Attendee' }).waitFor();
+ await this.page.getByLabel(/^First name/).fill(details.firstName);
+ await this.page.getByLabel(/^Last name/).fill(details.lastName);
+ await this.page.getByLabel(/^Email address/).fill(details.email);
+ await this.page.getByRole('combobox', { name: /^Ticket/ }).click();
+ await this.page.getByRole('option', { name: details.productTitle }).click();
+ await this.page.getByRole('button', { name: 'Create Attendee' }).click();
+ }
+
+ async openRowAction(rowText: string, action: string): Promise {
+ await this.rowByText(rowText).getByTestId('attendee-actions-trigger').click();
+ await this.page.getByRole('menuitem', { name: action }).click();
+ }
+
+ viewTab(): Locator {
+ return this.page.getByRole('tab', { name: 'View' });
+ }
+
+ async renameFirstName(firstName: string): Promise {
+ await this.page.getByRole('tab', { name: 'Edit' }).click();
+ await this.page.getByLabel(/^First name/).fill(firstName);
+ await this.page.getByRole('button', { name: 'Save Changes' }).click();
+ }
+
+ async closeDrawer(): Promise {
+ await this.page.keyboard.press('Escape');
+ }
+
+ async clickExport(): Promise {
+ await this.page.getByRole('button', { name: 'Export' }).click();
+ }
+}
diff --git a/e2e/pages/capacity.page.ts b/e2e/pages/capacity.page.ts
new file mode 100644
index 0000000000..909ad61c28
--- /dev/null
+++ b/e2e/pages/capacity.page.ts
@@ -0,0 +1,43 @@
+import type { Locator, Page } from '@playwright/test';
+
+export class CapacityPage {
+ constructor(private readonly page: Page) {}
+
+ async goto(eventId: number): Promise {
+ await this.page.goto(`/manage/event/${eventId}/capacity-assignments`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ cardByName(name: string): Locator {
+ return this.page.locator('[class*="capacityCard"]').filter({ hasText: name });
+ }
+
+ capacityInput(): Locator {
+ return this.page.getByLabel(/^Capacity/);
+ }
+
+ async createAssignment(details: { name: string; capacity: number; productTitle: string }): Promise {
+ await this.page.getByTestId('capacity-create-button').click();
+ await this.page.getByRole('heading', { name: 'Create Capacity Assignment' }).waitFor();
+ await this.page.getByLabel(/^Name/).fill(details.name);
+ await this.capacityInput().fill(String(details.capacity));
+ await this.page.getByRole('combobox', { name: /^What products should this capacity apply to/ }).click();
+ await this.page.getByRole('option', { name: details.productTitle }).click();
+ await this.page.getByRole('heading', { name: 'Create Capacity Assignment' }).click();
+ await this.page.getByRole('dialog').getByRole('button', { name: 'Create Capacity Assignment' }).click();
+ }
+
+ async openCardAction(name: string, action: string): Promise {
+ await this.cardByName(name).getByRole('button').click();
+ await this.page.getByRole('menuitem', { name: action }).click();
+ }
+
+ async submitEdit(capacity: number): Promise {
+ await this.capacityInput().fill(String(capacity));
+ await this.page.getByRole('dialog').getByRole('button', { name: 'Edit Capacity Assignment' }).click();
+ }
+
+ async confirmAction(): Promise {
+ await this.page.getByRole('button', { name: 'Confirm' }).click();
+ }
+}
diff --git a/e2e/pages/check-in-list.page.ts b/e2e/pages/check-in-list.page.ts
new file mode 100644
index 0000000000..33aa1e9c5f
--- /dev/null
+++ b/e2e/pages/check-in-list.page.ts
@@ -0,0 +1,36 @@
+import type { Locator, Page } from '@playwright/test';
+
+export class CheckInListPage {
+ constructor(private readonly page: Page) {}
+
+ async goto(eventId: number): Promise {
+ await this.page.goto(`/manage/event/${eventId}/check-in`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ async createList(name: string): Promise {
+ await this.page.getByTestId('checkin-list-create-button').click();
+ await this.page.getByLabel(/^Name/).fill(name);
+ await this.page.getByTestId('checkin-list-submit-button').click();
+
+ await this.page.getByRole('heading', { name: 'Check-In List Created' }).waitFor();
+ await this.page.getByRole('button', { name: 'Done' }).click();
+ }
+
+ row(name: string): Locator {
+ return this.page.getByRole('row').filter({ hasText: name });
+ }
+
+ async openRowAction(name: string, action: string): Promise {
+ await this.row(name).getByRole('button').click();
+ await this.page.getByRole('menuitem', { name: action }).click();
+ }
+
+ editNameInput(): Locator {
+ return this.page.getByRole('dialog').getByLabel(/^Name/);
+ }
+
+ async submitEdit(): Promise {
+ await this.page.getByRole('dialog').getByRole('button', { name: 'Edit Check-In List' }).click();
+ }
+}
diff --git a/e2e/pages/check-in.page.ts b/e2e/pages/check-in.page.ts
new file mode 100644
index 0000000000..67b948583b
--- /dev/null
+++ b/e2e/pages/check-in.page.ts
@@ -0,0 +1,46 @@
+import type { Locator, Page } from '@playwright/test';
+
+export class CheckInPage {
+ constructor(private readonly page: Page) {}
+
+ async goto(shortId: string): Promise {
+ await this.page.goto(`/check-in/${shortId}`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ private navButton(label: string): Locator {
+ return this.page
+ .getByRole('navigation', { name: 'Check-in navigation' })
+ .getByRole('button', { name: label });
+ }
+
+ async openSearchTab(): Promise {
+ await this.navButton('Search').click();
+ }
+
+ async openStatsTab(): Promise {
+ await this.navButton('Stats').click();
+ }
+
+ async search(query: string): Promise {
+ await this.page.getByLabel('Search attendees').fill(query);
+ }
+
+ attendeeRow(publicId: string): Locator {
+ return this.page
+ .getByRole('button', { name: /^View details for/ })
+ .filter({ hasText: publicId });
+ }
+
+ checkInButton(publicId: string): Locator {
+ return this.attendeeRow(publicId).getByRole('button', { name: /^Check in$/ });
+ }
+
+ checkOutButton(publicId: string): Locator {
+ return this.attendeeRow(publicId).getByRole('button', { name: /^Check out$/ });
+ }
+
+ progressChip(): Locator {
+ return this.page.getByLabel('Check-in progress');
+ }
+}
diff --git a/e2e/pages/checkout.page.ts b/e2e/pages/checkout.page.ts
new file mode 100644
index 0000000000..162e8a87c7
--- /dev/null
+++ b/e2e/pages/checkout.page.ts
@@ -0,0 +1,129 @@
+import { type Page } from '@playwright/test';
+
+export interface BuyerDetails {
+ firstName: string;
+ lastName: string;
+ email: string;
+}
+
+export class CheckoutPage {
+ constructor(private readonly page: Page) {}
+
+ async gotoPublicEvent(eventId: number, slug: string): Promise {
+ await this.page.goto(`/event/${eventId}/${slug}`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ async setFirstProductQuantity(quantity: number): Promise {
+ const input = this.page.locator('.hi-product-quantity-selector input').first();
+ await input.fill(String(quantity));
+ }
+
+ async setQuantityForProduct(productTitle: string, quantity: number): Promise {
+ const row = this.page.locator('.hi-product-row').filter({ hasText: productTitle });
+ await row.locator('.hi-product-quantity-selector input').fill(String(quantity));
+ }
+
+ async applyPromoCode(code: string): Promise {
+ await this.page.getByText('Have a promo code?').click();
+ await this.page.locator('.hi-promo-code-input').fill(code);
+ await this.page.getByTestId('promo-code-apply-button').click();
+ }
+
+ async answerTextQuestion(title: string, value: string): Promise {
+ await this.page.getByLabel(new RegExp(`^${title}`)).fill(value);
+ }
+
+ async chooseRadioOption(option: string): Promise {
+ await this.page.getByRole('radio', { name: option }).check();
+ }
+
+ async chooseOfflinePayment(): Promise {
+ const offlineTab = this.page.getByRole('button', { name: 'Offline' });
+ if (await offlineTab.isVisible()) {
+ await offlineTab.click();
+ }
+ await this.page.getByTestId('offline-payment-button').click();
+ await this.page.waitForURL(/\/checkout\/\d+\/[^/]+\/summary/);
+ await this.page.reload();
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ async continueToCheckout(): Promise {
+ await this.page.getByTestId('checkout-continue-button').click();
+ await this.page.waitForURL(/\/checkout\/\d+\/[^/]+\/details/);
+ }
+
+ private async fillContact(index: number, details: BuyerDetails): Promise {
+ await this.page.getByLabel(/^First Name/).nth(index).fill(details.firstName);
+ await this.page.getByLabel(/^Last Name/).nth(index).fill(details.lastName);
+ await this.page.getByLabel(/^Email Address/).nth(index).fill(details.email);
+ await this.page.getByLabel(/^Confirm Email Address/).nth(index).fill(details.email);
+ }
+
+ async fillOrderDetails(details: BuyerDetails): Promise {
+ await this.fillContact(0, details);
+ }
+
+ async fillFirstAttendee(details: BuyerDetails): Promise {
+ await this.fillContact(1, details);
+ }
+
+ async completeFreeOrder(): Promise {
+ await this.page.getByRole('button', { name: 'Complete Order' }).click();
+ await this.page.waitForURL(/\/checkout\/\d+\/[^/]+\/summary/);
+ await this.page.reload();
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ async continueToPayment(): Promise {
+ await this.page.getByRole('button', { name: 'Continue to Payment' }).click();
+ await this.page.waitForURL(/\/checkout\/\d+\/[^/]+\/payment/);
+ }
+
+ async fillStripeCard(card = '4242424242424242'): Promise {
+ const stripeFrame = this.page.frameLocator('iframe[title="Secure payment input frame"]');
+ await stripeFrame.getByPlaceholder('1234 1234 1234 1234').fill(card);
+ await stripeFrame.getByPlaceholder('MM / YY').fill('12 / 34');
+ await stripeFrame.getByPlaceholder('CVC').fill('123');
+ const country = stripeFrame.getByLabel('Country', { exact: true });
+ if (await country.count()) {
+ await country.selectOption({ label: 'United States' });
+ }
+ const zip = stripeFrame.getByPlaceholder('12345');
+ if (await zip.count()) {
+ await zip.fill('12345');
+ }
+ }
+
+ private async waitForStripeFrameToSettle(): Promise {
+ const frame = this.page.locator('iframe[title="Secure payment input frame"]');
+ await this.page.waitForTimeout(500);
+ let previousHeight = -1;
+ for (let attempt = 0; attempt < 20; attempt++) {
+ const height = (await frame.boundingBox())?.height ?? -1;
+ if (height === previousHeight) {
+ return;
+ }
+ previousHeight = height;
+ await this.page.waitForTimeout(250);
+ }
+ }
+
+ async clickPay(): Promise {
+ await this.page.getByRole('heading', { name: 'Payment' }).click();
+ await this.waitForStripeFrameToSettle();
+ await this.page.getByRole('button', { name: /^Pay\b/ }).dispatchEvent('click');
+ }
+
+ async payWithStripeTestCard(card = '4242424242424242'): Promise {
+ await this.fillStripeCard(card);
+ await this.clickPay();
+ try {
+ await this.page.waitForURL(/\/checkout\/\d+\/[^/]+\/(payment_return|summary)/, { timeout: 15_000 });
+ } catch {
+ await this.page.getByRole('button', { name: /^Pay\b/ }).dispatchEvent('click');
+ await this.page.waitForURL(/\/checkout\/\d+\/[^/]+\/(payment_return|summary)/, { timeout: 30_000 });
+ }
+ }
+}
diff --git a/e2e/pages/email-template.page.ts b/e2e/pages/email-template.page.ts
new file mode 100644
index 0000000000..5a832f451c
--- /dev/null
+++ b/e2e/pages/email-template.page.ts
@@ -0,0 +1,60 @@
+import type { Locator, Page } from '@playwright/test';
+
+export class EmailTemplatePage {
+ constructor(private readonly page: Page) {}
+
+ async gotoEventTemplates(eventId: number): Promise {
+ await this.page.goto(`/manage/event/${eventId}/settings#email-settings`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ async gotoOrganizerTemplates(organizerId: number): Promise {
+ await this.page.goto(`/manage/organizer/${organizerId}/settings#email-templates`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ templateCard(typeLabel: string): Locator {
+ return this.page
+ .locator('.mantine-Paper-root')
+ .filter({ has: this.page.getByText(typeLabel, { exact: true }) });
+ }
+
+ async openCreateEditor(typeLabel: string): Promise {
+ await this.templateCard(typeLabel).getByTestId('email-template-create-button').click();
+ }
+
+ async openEditEditor(typeLabel: string): Promise {
+ await this.templateCard(typeLabel).getByRole('button').first().click();
+ }
+
+ async deleteTemplate(typeLabel: string): Promise {
+ await this.templateCard(typeLabel).getByRole('button').nth(1).click();
+ await this.page.getByRole('button', { name: 'Delete Template' }).click();
+ }
+
+ editorDialog(): Locator {
+ return this.page.getByRole('dialog');
+ }
+
+ subjectInput(): Locator {
+ return this.editorDialog().getByLabel(/^Subject/);
+ }
+
+ bodyEditor(): Locator {
+ return this.editorDialog().locator('.ProseMirror');
+ }
+
+ async fillEditor(subject: string, body: string): Promise {
+ await this.subjectInput().fill(subject);
+ await this.bodyEditor().click();
+ await this.bodyEditor().fill(body);
+ }
+
+ async saveTemplate(): Promise {
+ await this.editorDialog().getByRole('button', { name: 'Save Template' }).click();
+ }
+
+ async openPreviewTab(): Promise {
+ await this.editorDialog().getByRole('tab', { name: 'Preview' }).click();
+ }
+}
diff --git a/e2e/pages/event-create.page.ts b/e2e/pages/event-create.page.ts
new file mode 100644
index 0000000000..294ee7aac4
--- /dev/null
+++ b/e2e/pages/event-create.page.ts
@@ -0,0 +1,35 @@
+import type { Page } from '@playwright/test';
+
+export class EventCreatePage {
+ constructor(private readonly page: Page) {}
+
+ async gotoDashboard(): Promise {
+ await this.page.goto('/manage/events');
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ async openCreateModal(): Promise {
+ const createNewMenu = this.page.getByRole('button', { name: 'Create new' });
+ const blankSlateButton = this.page.getByRole('button', { name: /^Create event$/i });
+ const eventMenuItem = this.page.getByRole('menuitem', { name: 'Event' });
+ const eventNameField = this.page.getByLabel(/^Event Name/);
+
+ await createNewMenu.or(blankSlateButton).first().click();
+ await eventMenuItem.or(eventNameField).first().waitFor({ state: 'visible' });
+ if (await eventMenuItem.isVisible()) {
+ await eventMenuItem.click();
+ }
+ await eventNameField.waitFor({ state: 'visible' });
+ }
+
+ async createSingleEvent(details: { title: string; category?: string }): Promise {
+ const { title, category = 'Music' } = details;
+ await this.page.getByLabel(/^Event Name/).fill(title);
+
+ await this.page.getByRole('combobox', { name: 'Event Category' }).click();
+ await this.page.getByRole('option', { name: new RegExp(category) }).click();
+
+ await this.page.getByRole('button', { name: 'Continue Setup' }).click();
+ await this.page.waitForURL(/\/manage\/event\/\d+\/dashboard/);
+ }
+}
diff --git a/e2e/pages/event-settings.page.ts b/e2e/pages/event-settings.page.ts
new file mode 100644
index 0000000000..d4f5082863
--- /dev/null
+++ b/e2e/pages/event-settings.page.ts
@@ -0,0 +1,50 @@
+import type { Locator, Page } from '@playwright/test';
+
+export class EventSettingsPage {
+ constructor(private readonly page: Page) {}
+
+ async goto(eventId: number): Promise {
+ await this.page.goto(`/manage/event/${eventId}/settings`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ section(id: string): Locator {
+ return this.page.locator(`#${id}`);
+ }
+
+ get detailsNameInput(): Locator {
+ return this.section('event-details').getByLabel(/^Name/);
+ }
+
+ get detailsDescriptionEditor(): Locator {
+ return this.section('event-details').locator('[contenteditable="true"]');
+ }
+
+ get seoTitleInput(): Locator {
+ return this.section('seo-settings').getByLabel(/^SEO Title/);
+ }
+
+ get selfServiceSwitch(): Locator {
+ return this.section('misc-settings').getByLabel('Enable attendee self-service');
+ }
+
+ get offlinePaymentsCheckbox(): Locator {
+ return this.section('payment-settings').getByLabel('Offline Payments', { exact: true });
+ }
+
+ get offlineInstructionsEditor(): Locator {
+ return this.section('payment-settings').locator('[contenteditable="true"]');
+ }
+
+ get deleteConfirmationInput(): Locator {
+ return this.section('danger-zone').getByPlaceholder('delete');
+ }
+
+ get deleteButton(): Locator {
+ return this.page.getByTestId('event-delete-button');
+ }
+
+ async saveSection(id: string): Promise {
+ await this.section(id).getByRole('button', { name: 'Save' }).click();
+ }
+}
diff --git a/e2e/pages/message.page.ts b/e2e/pages/message.page.ts
new file mode 100644
index 0000000000..c8118df9db
--- /dev/null
+++ b/e2e/pages/message.page.ts
@@ -0,0 +1,60 @@
+import type { Locator, Page } from '@playwright/test';
+
+export class MessagePage {
+ constructor(private readonly page: Page) {}
+
+ async goto(eventId: number): Promise {
+ await this.page.goto(`/manage/event/${eventId}/messages`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ async sendToAllAttendees(subject: string, body: string): Promise {
+ await this.openComposer();
+ await this.fillComposerForAllAttendees(subject, body);
+ await this.confirmAndSubmit();
+ }
+
+ async scheduleToAllAttendees(subject: string, body: string, scheduledAt: string): Promise {
+ await this.openComposer();
+ await this.fillComposerForAllAttendees(subject, body);
+ await this.page.getByRole('button', { name: 'Schedule for later' }).click();
+ await this.page.getByRole('button', { name: 'Custom date and time' }).click();
+ await this.page.getByLabel(/^Scheduled time/).fill(scheduledAt);
+ await this.confirmAndSubmit();
+ }
+
+ listItem(subject: string): Locator {
+ return this.page.getByRole('option').filter({ hasText: subject });
+ }
+
+ async openSentMessageRecipients(): Promise {
+ await this.page.getByRole('button', { name: 'All attendees', exact: true }).click();
+ await this.page.getByRole('heading', { name: 'Recipients' }).waitFor();
+ }
+
+ async cancelScheduledMessage(): Promise {
+ this.page.once('dialog', (dialog) => dialog.accept());
+ await this.page.getByRole('button', { name: 'Cancel', exact: true }).click();
+ }
+
+ private async openComposer(): Promise {
+ await this.page.getByTestId('message-compose-button').click();
+ await this.page.getByRole('heading', { name: 'Send a message' }).waitFor();
+ }
+
+ private async fillComposerForAllAttendees(subject: string, body: string): Promise {
+ await this.page.getByRole('combobox', { name: 'Recipients' }).click();
+ await this.page.getByRole('option', { name: 'All attendees of this event' }).click();
+
+ await this.page.getByLabel(/^Subject/).fill(subject);
+
+ const editor = this.page.locator('.ProseMirror').first();
+ await editor.click();
+ await editor.fill(body);
+ }
+
+ private async confirmAndSubmit(): Promise {
+ await this.page.getByRole('checkbox', { name: /I confirm this is a transactional message/ }).check();
+ await this.page.getByTestId('message-send-button').click();
+ }
+}
diff --git a/e2e/pages/my-tickets.page.ts b/e2e/pages/my-tickets.page.ts
new file mode 100644
index 0000000000..e810defdb3
--- /dev/null
+++ b/e2e/pages/my-tickets.page.ts
@@ -0,0 +1,18 @@
+import type { Locator, Page } from '@playwright/test';
+
+export class MyTicketsPage {
+ constructor(private readonly page: Page) {}
+
+ async open(lookupUrl: string): Promise {
+ await this.page.goto(lookupUrl);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ orderCardHeading(eventTitle: string): Locator {
+ return this.page.getByRole('heading', { name: eventTitle });
+ }
+
+ async viewOrder(): Promise {
+ await this.page.getByRole('link', { name: 'View Order' }).click();
+ }
+}
diff --git a/e2e/pages/occurrence.page.ts b/e2e/pages/occurrence.page.ts
new file mode 100644
index 0000000000..35c868323d
--- /dev/null
+++ b/e2e/pages/occurrence.page.ts
@@ -0,0 +1,147 @@
+import type { Locator, Page } from '@playwright/test';
+
+export class OccurrencePage {
+ constructor(private readonly page: Page) {}
+
+ async goto(eventId: number): Promise {
+ await this.page.goto(`/manage/event/${eventId}/occurrences`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ dialog(): Locator {
+ return this.page.getByRole('dialog');
+ }
+
+ async openScheduleSetup(): Promise {
+ await this.page.getByRole('button', { name: 'Set Up Schedule' }).click();
+ }
+
+ async pickWeekday(label: string): Promise {
+ await this.dialog().getByRole('checkbox', { name: label, exact: true }).check({ force: true });
+ }
+
+ async chooseFixedNumberOfDates(count: number): Promise {
+ await this.dialog().getByText('Set number of dates').click();
+ await this.dialog().getByLabel(/^Number of dates to create/).fill(String(count));
+ }
+
+ async submitSchedule(): Promise {
+ await this.dialog().getByRole('button', { name: 'Create Schedule' }).click();
+ }
+
+ occurrenceRows(): Locator {
+ return this.page.getByRole('row').filter({ has: this.page.getByTestId('occurrence-actions-menu') });
+ }
+
+ statusBadges(status: 'ACTIVE' | 'CANCELLED'): Locator {
+ return this.page.locator(`[class*="statusBadge"][data-status="${status}"]`);
+ }
+
+ rowWithStatus(status: 'ACTIVE' | 'CANCELLED'): Locator {
+ return this.occurrenceRows().filter({ has: this.statusBadges(status) });
+ }
+
+ async chooseRowAction(row: Locator, action: string): Promise {
+ await row.getByTestId('occurrence-actions-menu').click();
+ await this.page.getByRole('menuitem', { name: action, exact: true }).click();
+ }
+
+ async confirmModalAction(buttonName: string): Promise {
+ await this.dialog().getByRole('button', { name: buttonName }).click();
+ }
+
+ async openProductsTab(): Promise {
+ await this.page.getByRole('tab', { name: 'Products' }).click();
+ }
+
+ productCard(title: string): Locator {
+ return this.page.locator('[class*="productCard"]').filter({ hasText: title });
+ }
+
+ productSwitch(title: string): Locator {
+ return this.productCard(title).getByRole('switch');
+ }
+
+ overrideInput(): Locator {
+ return this.page.locator('[class*="overrideInput"] input');
+ }
+
+ async saveProductSettings(): Promise {
+ await this.page.locator('[class*="saveButton"]').click();
+ }
+
+ async closeModal(): Promise {
+ await this.page.keyboard.press('Escape');
+ }
+}
+
+export class PublicOccurrenceSelector {
+ constructor(private readonly page: Page) {}
+
+ calendar(): Locator {
+ return this.page.locator('.hi-occurrence-datepicker');
+ }
+
+ dayButton(label: RegExp): Locator {
+ return this.page.getByRole('button', { name: label });
+ }
+
+ nextMonthButton(): Locator {
+ return this.page.locator('.hi-dp-nav[data-direction="next"]');
+ }
+
+ previousMonthButton(): Locator {
+ return this.page.locator('.hi-dp-nav[data-direction="previous"]');
+ }
+
+ slotHeaderDay(): Locator {
+ return this.page.locator('.hi-slot-header-day');
+ }
+
+ monthHeader(): Locator {
+ return this.page.locator('.hi-dp-level');
+ }
+
+ productsLoadingOverlay(): Locator {
+ return this.page.locator('.hi-occurrence-loading-overlay');
+ }
+
+ monthLoadingOverlay(): Locator {
+ return this.page.locator('.hi-calendar-month-loading');
+ }
+
+ async waitForMonthLoaded(): Promise {
+ await this.monthLoadingOverlay().waitFor({ state: 'visible', timeout: 300 }).catch(() => {});
+ await this.monthLoadingOverlay().waitFor({ state: 'detached' });
+ }
+
+ async navigateToMonthOf(isoDate: string): Promise {
+ const target = new Date(isoDate);
+ const targetIndex = target.getUTCFullYear() * 12 + target.getUTCMonth();
+ await this.calendar().waitFor();
+ await this.waitForMonthLoaded();
+ for (let attempt = 0; attempt < 24; attempt++) {
+ const header = (await this.monthHeader().innerText()).trim();
+ const displayed = new Date(header.replace(' ', ' 1, '));
+ const displayedIndex = displayed.getFullYear() * 12 + displayed.getMonth();
+ if (displayedIndex === targetIndex) return;
+ if (displayedIndex < targetIndex) {
+ await this.nextMonthButton().click();
+ } else {
+ await this.previousMonthButton().click();
+ }
+ await this.waitForMonthLoaded();
+ }
+ throw new Error(`Could not navigate the occurrence calendar to the month of ${isoDate}`);
+ }
+
+ async selectDay(label: RegExp): Promise {
+ await this.calendar().waitFor();
+ await this.waitForMonthLoaded();
+ for (let attempt = 0; attempt < 2 && (await this.dayButton(label).count()) === 0; attempt++) {
+ await this.nextMonthButton().click();
+ await this.waitForMonthLoaded();
+ }
+ await this.dayButton(label).click();
+ }
+}
diff --git a/e2e/pages/order-summary.page.ts b/e2e/pages/order-summary.page.ts
new file mode 100644
index 0000000000..636a16905b
--- /dev/null
+++ b/e2e/pages/order-summary.page.ts
@@ -0,0 +1,27 @@
+import type { Page } from '@playwright/test';
+
+export class OrderSummaryPage {
+ constructor(private readonly page: Page) {}
+
+ async goto(eventId: number, orderShortId: string): Promise {
+ await this.page.goto(`/checkout/${eventId}/${orderShortId}/summary`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ async editOrderFirstName(firstName: string): Promise {
+ await this.page.getByTestId('order-edit-button').click();
+ await this.page.getByRole('dialog').getByLabel(/^First Name/).fill(firstName);
+ await this.page.getByRole('dialog').getByRole('button', { name: 'Save' }).click();
+ }
+
+ async editFirstAttendeeFirstName(firstName: string): Promise {
+ await this.page.getByTestId('attendee-edit-button').first().click();
+ await this.page.getByRole('dialog').getByLabel(/^First Name/).fill(firstName);
+ await this.page.getByRole('dialog').getByRole('button', { name: 'Save' }).click();
+ }
+
+ async resendConfirmation(): Promise {
+ this.page.once('dialog', (dialog) => dialog.accept());
+ await this.page.getByTestId('resend-confirmation-button').click();
+ }
+}
diff --git a/e2e/pages/order.page.ts b/e2e/pages/order.page.ts
new file mode 100644
index 0000000000..93be4c9c92
--- /dev/null
+++ b/e2e/pages/order.page.ts
@@ -0,0 +1,45 @@
+import type { Locator, Page } from '@playwright/test';
+
+export class OrderPage {
+ constructor(private readonly page: Page) {}
+
+ async goto(eventId: number): Promise {
+ await this.page.goto(`/manage/event/${eventId}/orders`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ rowByEmail(email: string): Locator {
+ return this.page.getByRole('row').filter({ hasText: email });
+ }
+
+ async chooseRowAction(email: string, action: string): Promise {
+ const row = this.rowByEmail(email);
+ await row.getByTestId('order-actions-trigger').click();
+ await this.page.getByRole('menuitem', { name: action }).click();
+ }
+
+ detailsDrawer(): Locator {
+ return this.page.getByRole('dialog');
+ }
+
+ async sendMessageToBuyer(subject: string, body: string): Promise {
+ await this.page.getByRole('heading', { name: 'Send a message' }).waitFor();
+ await this.page.getByLabel(/^Subject/).fill(subject);
+
+ const editor = this.page.locator('.ProseMirror').first();
+ await editor.click();
+ await editor.fill(body);
+
+ await this.page.getByRole('checkbox', { name: /I confirm this is a transactional message/ }).check();
+ await this.page.getByTestId('message-send-button').click();
+ }
+
+ async confirmCancelOrder(): Promise {
+ await this.page.getByRole('heading', { name: /^Cancel Order/ }).waitFor();
+ await this.page.getByRole('button', { name: 'Cancel Order' }).click();
+ }
+
+ exportButton(): Locator {
+ return this.page.getByRole('button', { name: 'Export' });
+ }
+}
diff --git a/e2e/pages/organizer.page.ts b/e2e/pages/organizer.page.ts
new file mode 100644
index 0000000000..235587c2c5
--- /dev/null
+++ b/e2e/pages/organizer.page.ts
@@ -0,0 +1,77 @@
+import type { Locator, Page } from '@playwright/test';
+
+export class OrganizerPage {
+ constructor(private readonly page: Page) {}
+
+ async gotoEventsDashboard(): Promise {
+ await this.page.goto('/manage/events');
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ async gotoSettings(organizerId: number): Promise {
+ await this.page.goto(`/manage/organizer/${organizerId}/settings`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ async gotoReport(organizerId: number, reportType: string): Promise {
+ await this.page.goto(`/manage/organizer/${organizerId}/report/${reportType}`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ async openCreateOrganizerModal(avatarInitials: string): Promise {
+ await this.page.getByRole('button', { name: avatarInitials, exact: true }).click();
+ await this.page.getByRole('menuitem', { name: 'Create Organizer' }).click();
+ }
+
+ get organizationNameInput(): Locator {
+ return this.page.getByLabel(/^Organization Name/);
+ }
+
+ get contactEmailInput(): Locator {
+ return this.page.getByLabel(/^Contact Email/);
+ }
+
+ get continueSetupButton(): Locator {
+ return this.page.getByRole('button', { name: /^Continue to event creation$/ });
+ }
+
+ get settingsNameInput(): Locator {
+ return this.page.locator('#basic-settings').getByLabel(/^Organizer Name/);
+ }
+
+ async saveBasicSettings(): Promise {
+ await this.page.locator('#basic-settings').getByRole('button', { name: 'Save' }).click();
+ }
+
+ reportRow(text: string): Locator {
+ return this.page.getByRole('row').filter({ hasText: text });
+ }
+
+ get exportCsvButton(): Locator {
+ return this.page.getByRole('button', { name: 'Export CSV' });
+ }
+}
+
+export class OrganizerPublicPage {
+ constructor(private readonly page: Page) {}
+
+ async goto(organizerId: number, slug: string): Promise {
+ await this.page.goto(`/events/${organizerId}/${slug}`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ eventLink(title: string): Locator {
+ return this.page.getByRole('link').filter({ has: this.page.getByRole('heading', { name: title }) });
+ }
+
+ get contactButton(): Locator {
+ return this.page.getByRole('button', { name: 'Contact', exact: true });
+ }
+
+ async sendContactMessage(details: { name: string; email: string; message: string }): Promise {
+ await this.page.getByLabel(/^Your Name/).fill(details.name);
+ await this.page.getByLabel(/^Your Email/).fill(details.email);
+ await this.page.getByLabel(/^Message/).fill(details.message);
+ await this.page.getByRole('button', { name: 'Send Message' }).click();
+ }
+}
diff --git a/e2e/pages/product-create.page.ts b/e2e/pages/product-create.page.ts
new file mode 100644
index 0000000000..765ab562a6
--- /dev/null
+++ b/e2e/pages/product-create.page.ts
@@ -0,0 +1,51 @@
+import type { Locator, Page } from '@playwright/test';
+
+export class ProductCreatePage {
+ constructor(private readonly page: Page) {}
+
+ async goto(eventId: number): Promise {
+ await this.page.goto(`/manage/event/${eventId}/products`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ async openCreateModal(): Promise {
+ await this.page.getByTestId('product-create-button').click();
+ await this.page.getByRole('menuitem', { name: 'Ticket or Product' }).click();
+ await this.page.getByRole('heading', { name: 'Create Ticket or Product' }).waitFor();
+ }
+
+ async selectPriceType(optionName: RegExp): Promise {
+ await this.page
+ .getByLabel('Create Ticket or Product')
+ .getByText('Paid Product', { exact: true })
+ .click();
+ await this.page.getByRole('option', { name: optionName }).click();
+ }
+
+ async fillTier(index: number, price: string, label: string): Promise {
+ await this.page.getByLabel(/^Price/).nth(index).fill(price);
+ await this.page.getByLabel(/^Label/).nth(index).fill(label);
+ }
+
+ async addTier(): Promise {
+ await this.page.getByTestId('product-add-tier-button').click();
+ }
+
+ async openAdvancedOptions(): Promise {
+ await this.page.getByRole('button', { name: /Taxes, Fees, Visibility/ }).click();
+ }
+
+ hiddenSwitch(): Locator {
+ return this.page.getByLabel('Hide this product from customers');
+ }
+
+ async submitCreate(): Promise {
+ await this.page.getByRole('button', { name: 'Create Product' }).click();
+ }
+
+ async openEditModal(): Promise {
+ await this.page.getByTestId('product-manage-button').click();
+ await this.page.getByTestId('product-edit-menu-item').click();
+ await this.page.getByRole('heading', { name: 'Edit Product' }).waitFor();
+ }
+}
diff --git a/e2e/pages/product-edit.page.ts b/e2e/pages/product-edit.page.ts
new file mode 100644
index 0000000000..d3afe19596
--- /dev/null
+++ b/e2e/pages/product-edit.page.ts
@@ -0,0 +1,22 @@
+import { expect, type Page } from '@playwright/test';
+
+export class ProductEditPage {
+ constructor(private readonly page: Page) {}
+
+ async goto(eventId: number): Promise {
+ await this.page.goto(`/manage/event/${eventId}/products`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ async renameFirstProduct(currentName: string, newName: string): Promise {
+ await this.page.getByTestId('product-manage-button').click();
+ await this.page.getByTestId('product-edit-menu-item').click();
+ await this.page.getByRole('heading', { name: 'Edit Product' }).waitFor();
+
+ const nameInput = this.page.getByLabel(/^Name/);
+ await expect(nameInput).toHaveValue(currentName);
+ await nameInput.fill(newName);
+
+ await this.page.getByTestId('product-edit-submit-button').click();
+ }
+}
diff --git a/e2e/pages/promo-code.page.ts b/e2e/pages/promo-code.page.ts
new file mode 100644
index 0000000000..383cdc6c6e
--- /dev/null
+++ b/e2e/pages/promo-code.page.ts
@@ -0,0 +1,50 @@
+import type { Page } from '@playwright/test';
+
+interface CreatePromoCodeOptions {
+ discountType?: 'Percentage' | 'Fixed amount';
+ discount?: number;
+ appliesTo?: 'Entire order' | 'Each product';
+ expiryDate?: string;
+}
+
+export class PromoCodePage {
+ constructor(private readonly page: Page) {}
+
+ async goto(eventId: number): Promise {
+ await this.page.goto(`/manage/event/${eventId}/promo-codes`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ async createPromoCode(code: string, options: CreatePromoCodeOptions = {}): Promise {
+ await this.page.getByTestId('promo-code-create-button').click();
+ await this.page.getByRole('heading', { name: 'Create Promo Code' }).waitFor();
+ await this.page.getByLabel(/^Code/).fill(code);
+
+ if (options.discountType) {
+ await this.page.getByRole('combobox', { name: 'Discount Type' }).click();
+ await this.page.getByRole('option', { name: options.discountType }).click();
+ }
+
+ if (options.discount !== undefined) {
+ await this.page.getByLabel(/^Discount (%|in)/).fill(String(options.discount));
+ }
+
+ if (options.appliesTo) {
+ await this.page.getByTestId('promo-code-discount-applies-to').getByText(options.appliesTo).click();
+ }
+
+ if (options.expiryDate) {
+ await this.page.getByTestId('promo-code-advanced-toggle').click();
+ await this.page.getByLabel('Expiry Date').fill(options.expiryDate);
+ }
+
+ await this.page.getByTestId('promo-code-submit-button').click();
+ }
+
+ async openEditModal(code: string): Promise {
+ const row = this.page.getByRole('row').filter({ hasText: code.toUpperCase() });
+ await row.getByTestId('promo-code-actions-button').click();
+ await this.page.getByRole('menuitem', { name: 'Edit Code' }).click();
+ await this.page.getByRole('heading', { name: 'Edit Promo Code' }).waitFor();
+ }
+}
diff --git a/e2e/pages/public-event.page.ts b/e2e/pages/public-event.page.ts
new file mode 100644
index 0000000000..6f8bc70f38
--- /dev/null
+++ b/e2e/pages/public-event.page.ts
@@ -0,0 +1,27 @@
+import { type Locator, type Page } from '@playwright/test';
+
+export class PublicEventPage {
+ constructor(private readonly page: Page) {}
+
+ async goto(eventId: number, slug: string): Promise {
+ await this.page.goto(`/event/${eventId}/${slug}`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ joinWaitlistButton(): Locator {
+ return this.page.getByTestId('join-waitlist-button');
+ }
+
+ async joinWaitlist(details: { firstName: string; email: string }): Promise {
+ await this.joinWaitlistButton().click();
+ const dialog = this.page.getByRole('dialog');
+ await dialog.getByLabel(/^First Name/).fill(details.firstName);
+ await dialog.getByLabel(/^Email/).fill(details.email);
+ await dialog.getByLabel(/agree to receive email notifications/).check();
+ await dialog.getByRole('button', { name: 'Join Waitlist' }).click();
+ }
+
+ async closeWaitlistSuccessModal(): Promise {
+ await this.page.getByRole('dialog').getByRole('button', { name: 'Close' }).click();
+ }
+}
diff --git a/e2e/pages/question.page.ts b/e2e/pages/question.page.ts
new file mode 100644
index 0000000000..61f884ad1a
--- /dev/null
+++ b/e2e/pages/question.page.ts
@@ -0,0 +1,17 @@
+import type { Page } from '@playwright/test';
+
+export class QuestionPage {
+ constructor(private readonly page: Page) {}
+
+ async goto(eventId: number): Promise {
+ await this.page.goto(`/manage/event/${eventId}/questions`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ async addOrderQuestion(title: string): Promise {
+ await this.page.getByTestId('question-add-button').click();
+ await this.page.getByRole('heading', { name: 'Create Question' }).waitFor();
+ await this.page.getByLabel(/^Question Title/).fill(title);
+ await this.page.getByTestId('question-submit-button').click();
+ }
+}
diff --git a/e2e/pages/register.page.ts b/e2e/pages/register.page.ts
new file mode 100644
index 0000000000..b6575f707f
--- /dev/null
+++ b/e2e/pages/register.page.ts
@@ -0,0 +1,21 @@
+import type { Page } from '@playwright/test';
+
+export class RegisterPage {
+ constructor(private readonly page: Page) {}
+
+ async goto(): Promise {
+ await this.page.goto('/auth/register');
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ async register(details: { firstName: string; lastName?: string; email: string; password: string }): Promise {
+ await this.page.getByLabel(/^First Name/).fill(details.firstName);
+ if (details.lastName) {
+ await this.page.getByLabel(/^Last Name/).fill(details.lastName);
+ }
+ await this.page.getByLabel(/^Email/).fill(details.email);
+ await this.page.getByLabel(/^Password/).fill(details.password);
+ await this.page.getByLabel(/^Confirm Password/).fill(details.password);
+ await this.page.getByRole('button', { name: 'Register' }).click();
+ }
+}
diff --git a/e2e/pages/team.page.ts b/e2e/pages/team.page.ts
new file mode 100644
index 0000000000..678db68f2e
--- /dev/null
+++ b/e2e/pages/team.page.ts
@@ -0,0 +1,48 @@
+import type { Locator, Page } from '@playwright/test';
+
+type TeamRole = 'Admin' | 'Organizer';
+
+export class TeamPage {
+ constructor(private readonly page: Page) {}
+
+ async goto(): Promise {
+ await this.page.goto('/account/users');
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ userRow(email: string): Locator {
+ return this.page.getByRole('row').filter({ hasText: email });
+ }
+
+ async inviteUser(details: { firstName: string; lastName: string; email: string; role: TeamRole }): Promise {
+ await this.page.getByTestId('team-invite-button').click();
+ await this.page.getByRole('heading', { name: 'Invite a team member' }).waitFor();
+ await this.page.getByLabel(/^First Name/).fill(details.firstName);
+ await this.page.getByLabel(/^Last Name/).fill(details.lastName);
+ await this.page.getByLabel(/^Email/).fill(details.email);
+ if (details.role !== 'Admin') {
+ await this.selectRole('Admin', details.role);
+ }
+ await this.page.getByRole('button', { name: 'Invite Team Member' }).click();
+ }
+
+ async openEditUserModal(email: string): Promise {
+ await this.userRow(email).getByRole('button').click();
+ await this.page.getByRole('menuitem', { name: 'Edit user' }).click();
+ await this.page.getByRole('heading', { name: 'Edit User' }).waitFor();
+ }
+
+ async selectRole(currentRole: TeamRole, newRole: TeamRole): Promise {
+ await this.page.getByRole('dialog').getByText(currentRole, { exact: true }).click();
+ await this.page.getByRole('option', { name: newRole }).click();
+ }
+
+ async selectStatus(status: 'Active' | 'Inactive'): Promise {
+ await this.page.getByRole('dialog').getByLabel(/^Status/).click();
+ await this.page.getByRole('option', { name: status, exact: true }).click();
+ }
+
+ async submitEditUser(): Promise {
+ await this.page.getByRole('button', { name: 'Edit User', exact: true }).click();
+ }
+}
diff --git a/e2e/pages/waitlist.page.ts b/e2e/pages/waitlist.page.ts
new file mode 100644
index 0000000000..0337a9008b
--- /dev/null
+++ b/e2e/pages/waitlist.page.ts
@@ -0,0 +1,38 @@
+import { type Locator, type Page } from '@playwright/test';
+
+export class WaitlistPage {
+ constructor(private readonly page: Page) {}
+
+ async goto(eventId: number): Promise {
+ await this.page.goto(`/manage/event/${eventId}/sold-out-waitlist`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ entryRow(email: string): Locator {
+ return this.page.getByRole('row').filter({ hasText: email });
+ }
+
+ entryStatus(email: string, status: string): Locator {
+ return this.entryRow(email).locator(`[data-status="${status}"]`);
+ }
+
+ statsCard(label: string): Locator {
+ return this.page.locator('[class*="Paper"]').filter({ has: this.page.getByText(label, { exact: true }) });
+ }
+
+ async offerTickets(productTitle: string): Promise {
+ await this.page.getByTestId('waitlist-offer-next-button').click();
+ const dialog = this.page.getByRole('dialog');
+ const offerButton = dialog.getByRole('row').filter({ hasText: productTitle }).getByRole('button', { name: 'Offer' });
+ await offerButton.click();
+ await offerButton.waitFor({ state: 'visible' });
+ await this.page.keyboard.press('Escape');
+ await dialog.waitFor({ state: 'hidden' });
+ }
+
+ async removeEntry(email: string): Promise {
+ await this.entryRow(email).getByRole('button').click();
+ await this.page.getByRole('menuitem', { name: 'Remove' }).click();
+ await this.page.getByRole('dialog').getByRole('button', { name: 'Remove' }).click();
+ }
+}
diff --git a/e2e/pages/webhook.page.ts b/e2e/pages/webhook.page.ts
new file mode 100644
index 0000000000..313c91c098
--- /dev/null
+++ b/e2e/pages/webhook.page.ts
@@ -0,0 +1,77 @@
+import type { Locator, Page } from '@playwright/test';
+
+export class WebhookPage {
+ constructor(private readonly page: Page) {}
+
+ async goto(organizerId: number): Promise {
+ await this.page.goto(`/manage/organizer/${organizerId}/webhooks`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ async gotoEvent(eventId: number): Promise {
+ await this.page.goto(`/manage/event/${eventId}/webhooks`);
+ await this.page.waitForLoadState('networkidle');
+ }
+
+ async createWebhook(url: string, eventType = 'order.created'): Promise {
+ await this.page.getByTestId('webhook-add-button').click();
+ await this.page.getByRole('heading', { name: 'Create Webhook' }).waitFor();
+
+ await this.page.getByLabel(/^Webhook URL/).fill(url);
+
+ const eventTypesSelect = this.page.getByTestId('webhook-event-types');
+ await eventTypesSelect.click();
+ await this.page.getByTestId(`webhook-event-types-option-${eventType}`).click();
+ await eventTypesSelect.click();
+
+ await this.page.getByTestId('webhook-submit-button').click();
+
+ await this.page.getByRole('heading', { name: 'Webhook Signing Secret' }).waitFor();
+ await this.page.getByTestId('webhook-done-button').click();
+ }
+
+ async createEventWebhook(url: string, eventTypes: string[]): Promise {
+ await this.page.getByRole('button', { name: 'Add Webhook' }).first().click();
+ await this.page.getByRole('heading', { name: 'Create Webhook' }).waitFor();
+
+ await this.page.getByLabel(/^Webhook URL/).fill(url);
+
+ const eventTypesSelect = this.page.getByTestId('webhook-event-types');
+ await eventTypesSelect.click();
+ for (const eventType of eventTypes) {
+ await this.page.getByTestId(`webhook-event-types-option-${eventType}`).click();
+ }
+ await eventTypesSelect.click();
+
+ await this.page.getByRole('button', { name: 'Create Webhook' }).click();
+
+ await this.page.getByRole('heading', { name: 'Webhook Signing Secret' }).waitFor();
+ await this.page.getByRole('button', { name: 'Done' }).click();
+ }
+
+ rowByUrl(url: string): Locator {
+ return this.page.getByRole('row').filter({ hasText: url });
+ }
+
+ async chooseRowAction(url: string, action: string): Promise {
+ await this.rowByUrl(url).getByRole('button').click();
+ await this.page.getByRole('menuitem', { name: action }).click();
+ }
+
+ editUrlInput(): Locator {
+ return this.page.getByLabel(/^Webhook URL/);
+ }
+
+ async selectPausedStatus(): Promise {
+ await this.page.getByRole('dialog').getByText('Enabled', { exact: true }).click();
+ await this.page.getByRole('option', { name: 'Paused' }).click();
+ }
+
+ async submitEditForm(): Promise {
+ await this.page.getByRole('button', { name: 'Edit Webhook' }).click();
+ }
+
+ async confirmDeletion(): Promise {
+ await this.page.getByRole('button', { name: 'Confirm' }).click();
+ }
+}
diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts
new file mode 100644
index 0000000000..996ccbe2f9
--- /dev/null
+++ b/e2e/playwright.config.ts
@@ -0,0 +1,34 @@
+import { defineConfig, devices } from '@playwright/test';
+import { config as loadEnv } from 'dotenv';
+
+loadEnv();
+
+const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:8123';
+const isCI = !!process.env.CI;
+
+export default defineConfig({
+ testDir: './tests',
+ fullyParallel: true,
+ forbidOnly: isCI,
+ retries: isCI ? 2 : 0,
+ workers: isCI ? 2 : 4,
+ timeout: 60_000,
+ expect: { timeout: 15_000 },
+ reporter: isCI
+ ? [['github'], ['html', { open: 'never' }], ['list']]
+ : [['html', { open: 'on-failure' }], ['list']],
+ use: {
+ baseURL,
+ ignoreHTTPSErrors: true,
+ trace: 'retain-on-failure',
+ video: 'retain-on-failure',
+ screenshot: 'only-on-failure',
+ testIdAttribute: 'data-testid',
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+});
diff --git a/e2e/run-e2e.sh b/e2e/run-e2e.sh
new file mode 100755
index 0000000000..ce3bc270d9
--- /dev/null
+++ b/e2e/run-e2e.sh
@@ -0,0 +1,126 @@
+#!/usr/bin/env bash
+#
+# Runs the Hi.Events E2E suite against the hermetic docker stack.
+# Works locally and in CI (the GitHub Actions workflow calls this script).
+#
+# Local default: REUSE the stack if it is already up and healthy (fast iteration);
+# create it if not. The stack is left running afterwards. In CI ($CI set) the
+# stack is always recreated from scratch and left up for log collection.
+#
+# Usage:
+# ./e2e/run-e2e.sh # reuse or create stack, run all tests
+# ./e2e/run-e2e.sh --fresh # force a clean stack recreation first
+# ./e2e/run-e2e.sh --teardown # tear the stack down after the run
+# ./e2e/run-e2e.sh --skip-stack # don't manage the stack; run against whatever
+# is already up (e.g. the dev stack)
+# ./e2e/run-e2e.sh --skip-deps # skip npm ci / browser install (fast re-runs)
+# ./e2e/run-e2e.sh -- --grep @smoke # pass remaining args through to playwright
+#
+# NOTE: `docker compose up` never rebuilds images. After changing backend/ or
+# frontend/ source, rebuild before running:
+# docker compose -f docker/e2e/docker-compose.e2e.yml build backend frontend
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+COMPOSE_FILE="$REPO_ROOT/docker/e2e/docker-compose.e2e.yml"
+
+if [ -f "$SCRIPT_DIR/.env" ]; then
+ while IFS= read -r line || [ -n "$line" ]; do
+ case "$line" in ''|\#*) continue ;; esac
+ key="${line%%=*}"
+ [ -n "$(printenv "$key")" ] && continue
+ eval "export $line"
+ done < "$SCRIPT_DIR/.env"
+fi
+
+FRESH="${FRESH:-0}"
+TEARDOWN="${TEARDOWN:-0}"
+SKIP_DEPS="${SKIP_DEPS:-0}"
+SKIP_STACK="${SKIP_STACK:-0}"
+PW_ARGS=()
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --fresh) FRESH=1; shift ;;
+ --teardown) TEARDOWN=1; shift ;;
+ --skip-stack) SKIP_STACK=1; shift ;;
+ --skip-deps) SKIP_DEPS=1; shift ;;
+ --) shift; PW_ARGS=("$@"); break ;;
+ *) PW_ARGS+=("$1"); shift ;;
+ esac
+done
+
+if [ -n "${CI:-}" ]; then
+ FRESH=1
+ TEARDOWN=0
+fi
+
+compose() { docker compose -f "$COMPOSE_FILE" "$@"; }
+
+BASE_URL="${E2E_BASE_URL:-http://localhost:8123}"
+SA_EMAIL="${E2E_SUPERADMIN_EMAIL:-superadmin@e2e.test}"
+SA_PASSWORD="${E2E_SUPERADMIN_PASSWORD:-SuperAdminPass123!}"
+
+cleanup() {
+ if [ "$SKIP_STACK" = "1" ]; then
+ return
+ fi
+ if [ "$TEARDOWN" = "1" ]; then
+ echo "==> Tearing down stack"
+ compose down --remove-orphans -v
+ else
+ echo "==> Leaving stack up (base URL: $BASE_URL). Tear down with: ./e2e/run-e2e.sh --teardown, or docker compose -f docker/e2e/docker-compose.e2e.yml down -v"
+ fi
+}
+trap cleanup EXIT
+
+stack_is_healthy() {
+ [ "$(compose ps --services --status running 2>/dev/null | wc -l | tr -d ' ')" -ge 6 ]
+}
+
+superadmin_exists() {
+ curl -fsS -o /dev/null -X POST "$BASE_URL/api/auth/login" \
+ -H 'Content-Type: application/json' -H 'Accept: application/json' \
+ -d "{\"email\":\"$SA_EMAIL\",\"password\":\"$SA_PASSWORD\"}" 2>/dev/null
+}
+
+if [ "$SKIP_STACK" != "1" ]; then
+ if [ "$FRESH" != "1" ] && stack_is_healthy; then
+ echo "==> Reusing running stack (pass --fresh for a clean one; rebuild images first if backend/frontend source changed)"
+ else
+ echo "==> Recreating a clean stack"
+ compose down --remove-orphans -v
+ compose up -d --wait
+ fi
+
+ echo "==> Preparing HTMLPurifier cache directory"
+ compose exec -T backend sh -c 'mkdir -p storage/app/htmlpurifier && chmod -R 775 storage/app/htmlpurifier && chown -R www-data:www-data storage/app/htmlpurifier'
+
+ echo "==> Running migrations"
+ compose exec -T backend php artisan migrate --force
+
+ if superadmin_exists; then
+ echo "==> Superadmin already provisioned"
+ else
+ echo "==> Provisioning superadmin"
+ compose exec -T backend php artisan dev:bootstrap \
+ --email="$SA_EMAIL" \
+ --password="$SA_PASSWORD"
+ fi
+fi
+
+if [ "$SKIP_DEPS" != "1" ]; then
+ echo "==> Installing suite dependencies"
+ ( cd "$SCRIPT_DIR" && npm ci )
+ echo "==> Installing Playwright browsers"
+ ( cd "$SCRIPT_DIR" && npx playwright install --with-deps chromium )
+fi
+
+echo "==> Running Playwright"
+if [ ${#PW_ARGS[@]} -gt 0 ]; then
+ ( cd "$SCRIPT_DIR" && npx playwright test "${PW_ARGS[@]}" )
+else
+ ( cd "$SCRIPT_DIR" && npx playwright test )
+fi
diff --git a/e2e/tests/account/taxes-fees.spec.ts b/e2e/tests/account/taxes-fees.spec.ts
new file mode 100644
index 0000000000..e310dc6c0c
--- /dev/null
+++ b/e2e/tests/account/taxes-fees.spec.ts
@@ -0,0 +1,133 @@
+import type { Locator, Page } from '@playwright/test';
+import { test, expect } from '../../fixtures';
+import { ProductCreatePage } from '../../pages/product-create.page';
+import { createDraftEvent } from '../../api/factory';
+import { uniqueName } from '../../utils/unique';
+
+const gotoTaxesAndFees = async (page: Page): Promise => {
+ await page.goto('/account/taxes-and-fees');
+ await page.waitForLoadState('networkidle');
+};
+
+const taxBlock = (page: Page, name: string): Locator =>
+ page.locator('[class*="taxBlock"]').filter({ hasText: name });
+
+const chooseOption = async (page: Page, targetDescription: string, optionDescription: string): Promise => {
+ await page.getByRole('dialog').getByText(targetDescription, { exact: true }).click();
+ await page.getByRole('option').filter({ hasText: optionDescription }).click();
+};
+
+test.describe('taxes and fees', () => {
+ test('an organizer creates a percentage tax and a fixed fee', async ({ freshAccount }) => {
+ const page = await freshAccount.newAuthedPage();
+ const taxName = uniqueName('VAT');
+ const feeName = uniqueName('Booking Fee');
+
+ await gotoTaxesAndFees(page);
+
+ await page.getByRole('button', { name: 'Add Tax or Fee' }).click();
+ await page.getByRole('dialog').getByLabel(/^Name/).fill(taxName);
+ await page.getByRole('dialog').getByLabel(/^Percentage Amount/).fill('10');
+ await page.getByRole('button', { name: 'Create Tax' }).click();
+
+ await expect(taxBlock(page, taxName)).toBeVisible();
+ await expect(taxBlock(page, taxName).getByText(/^10(\.0+)?%$/)).toBeVisible();
+
+ await page.getByRole('button', { name: 'Add Tax or Fee' }).click();
+ await chooseOption(page, 'A standard tax, like VAT or GST', 'A fee, like a booking fee or a service fee');
+ await chooseOption(
+ page,
+ 'A percentage of the product price. E.g., 3.5% of the product price',
+ 'A fixed amount per product',
+ );
+ await page.getByRole('dialog').getByLabel(/^Name/).fill(feeName);
+ await page.getByRole('dialog').getByLabel(/^Amount/).fill('2.50');
+ await page.getByRole('button', { name: 'Create Fee' }).click();
+
+ await expect(taxBlock(page, feeName)).toBeVisible();
+ await expect(taxBlock(page, feeName).getByText('$2.50')).toBeVisible();
+ });
+
+ test('an organizer edits a tax rate', async ({ freshAccount }) => {
+ const { id: accountId } = await freshAccount.api.getAccount();
+ const taxName = uniqueName('Edit Tax');
+ await freshAccount.api.createTaxOrFee(accountId, {
+ name: taxName,
+ calculation_type: 'PERCENTAGE',
+ type: 'TAX',
+ rate: 10,
+ is_active: true,
+ is_default: false,
+ });
+
+ const page = await freshAccount.newAuthedPage();
+ await gotoTaxesAndFees(page);
+
+ await taxBlock(page, taxName).getByRole('button').click();
+ await page.getByRole('menuitem', { name: 'Edit' }).click();
+
+ const rateInput = page.getByRole('dialog').getByLabel(/^Percentage Amount/);
+ await expect(rateInput).toHaveValue(/^10/);
+ await rateInput.fill('15');
+ await page.getByRole('button', { name: 'Update Tax' }).click();
+
+ await expect(taxBlock(page, taxName).getByText(/^15(\.0+)?%$/)).toBeVisible();
+ });
+
+ test('an organizer deletes a fee', async ({ freshAccount }) => {
+ const { id: accountId } = await freshAccount.api.getAccount();
+ const feeName = uniqueName('Doomed Fee');
+ await freshAccount.api.createTaxOrFee(accountId, {
+ name: feeName,
+ calculation_type: 'FIXED',
+ type: 'FEE',
+ rate: 1.5,
+ is_active: true,
+ is_default: false,
+ });
+
+ const page = await freshAccount.newAuthedPage();
+ await gotoTaxesAndFees(page);
+
+ await expect(taxBlock(page, feeName)).toBeVisible();
+ await taxBlock(page, feeName).getByRole('button').click();
+ await page.getByRole('menuitem', { name: 'Delete' }).click();
+ await page.getByRole('button', { name: 'Confirm' }).click();
+
+ await expect(taxBlock(page, feeName)).toHaveCount(0);
+ });
+
+ test('a tax selected during product creation shows attached when reopening the edit modal', async ({ freshAccount }) => {
+ const { id: accountId } = await freshAccount.api.getAccount();
+ const taxName = uniqueName('Attach Tax');
+ await freshAccount.api.createTaxOrFee(accountId, {
+ name: taxName,
+ calculation_type: 'PERCENTAGE',
+ type: 'TAX',
+ rate: 12.5,
+ is_active: true,
+ is_default: false,
+ });
+ const event = await createDraftEvent(freshAccount.api, freshAccount.organizerId);
+ const title = uniqueName('Taxed Ticket');
+
+ const page = await freshAccount.newAuthedPage();
+ const products = new ProductCreatePage(page);
+ await products.goto(event.eventId);
+ await products.openCreateModal();
+ await page.getByLabel(/^Name/).fill(title);
+ await page.getByLabel(/^Price/).fill('30');
+ await products.openAdvancedOptions();
+
+ await page.getByRole('combobox', { name: 'Taxes and Fees' }).click();
+ await page.getByRole('option', { name: new RegExp(`^${taxName}`) }).click();
+ await page.getByRole('heading', { name: 'Create Ticket or Product' }).click();
+ await products.submitCreate();
+
+ await expect(page.getByRole('heading', { name: title })).toBeVisible();
+
+ await products.openEditModal();
+ await products.openAdvancedOptions();
+ await expect(page.getByRole('dialog').getByText(new RegExp(`^${taxName}`))).toBeVisible();
+ });
+});
diff --git a/e2e/tests/account/team-invite.spec.ts b/e2e/tests/account/team-invite.spec.ts
new file mode 100644
index 0000000000..16a33c1f35
--- /dev/null
+++ b/e2e/tests/account/team-invite.spec.ts
@@ -0,0 +1,75 @@
+import { test, expect } from '../../fixtures';
+import { TeamPage } from '../../pages/team.page';
+import { uniqueEmail } from '../../utils/unique';
+
+const INVITEE_PASSWORD = 'InviteePass123!';
+
+test.describe('team invites', () => {
+ test('an invited team member accepts the invitation and logs in', { tag: '@smoke' }, async ({ freshAccount, page, mailpit }) => {
+ const inviteeEmail = uniqueEmail('invitee');
+
+ const ownerPage = await freshAccount.newAuthedPage();
+ const team = new TeamPage(ownerPage);
+ await team.goto();
+ await team.inviteUser({ firstName: 'Robin', lastName: 'Member', email: inviteeEmail, role: 'Organizer' });
+
+ await expect(team.userRow(inviteeEmail)).toBeVisible();
+ await expect(team.userRow(inviteeEmail).getByRole('cell', { name: /^Invited$/i })).toBeVisible();
+
+ const inviteUrl = await mailpit.waitForLink(inviteeEmail, /accept-invitation/, { subjectContains: 'invited to join' });
+ await page.goto(inviteUrl.pathname);
+ await expect(page.getByRole('heading', { name: 'Accept invitation' })).toBeVisible();
+ await expect(page.getByLabel(/^First Name/)).toHaveValue('Robin');
+
+ await page.getByLabel(/^Password/).fill(INVITEE_PASSWORD);
+ await page.getByLabel(/^Confirm Password/).fill(INVITEE_PASSWORD);
+ await page.getByRole('checkbox', { name: /I agree/ }).check();
+ await page.getByRole('button', { name: 'Accept Invitation' }).click();
+
+ await expect(page).toHaveURL(/\/auth\/login/);
+ await page.getByLabel(/^Email/).fill(inviteeEmail);
+ await page.getByLabel(/^Password/).fill(INVITEE_PASSWORD);
+ await page.getByRole('button', { name: 'Log in' }).click();
+
+ await expect(page).toHaveURL(/\/manage\/organizer\/\d+/);
+ await expect(page.getByRole('heading', { level: 1, name: /Dashboard/ })).toBeVisible();
+ });
+
+ test('an owner promotes a member to admin and deactivates them', async ({ freshAccount, page, mailpit, publicApi }) => {
+ const memberEmail = uniqueEmail('member');
+
+ await freshAccount.api.inviteUser({ first_name: 'Casey', last_name: 'Member', email: memberEmail, role: 'ORGANIZER' });
+ const inviteUrl = await mailpit.waitForLink(memberEmail, /accept-invitation/, { subjectContains: 'invited to join' });
+ const inviteToken = inviteUrl.pathname.split('/').pop();
+ const acceptResponse = await publicApi.post(`auth/invitation/${inviteToken}`, {
+ data: {
+ first_name: 'Casey',
+ last_name: 'Member',
+ password: INVITEE_PASSWORD,
+ password_confirmation: INVITEE_PASSWORD,
+ timezone: 'UTC',
+ },
+ });
+ expect(acceptResponse.ok()).toBeTruthy();
+
+ const ownerPage = await freshAccount.newAuthedPage();
+ const team = new TeamPage(ownerPage);
+ await team.goto();
+ await expect(team.userRow(memberEmail).getByRole('cell', { name: /^Active$/i })).toBeVisible();
+
+ await team.openEditUserModal(memberEmail);
+ await team.selectRole('Organizer', 'Admin');
+ await team.selectStatus('Inactive');
+ await team.submitEditUser();
+
+ await expect(team.userRow(memberEmail).getByRole('cell', { name: /^Admin$/i })).toBeVisible();
+ await expect(team.userRow(memberEmail).getByRole('cell', { name: /^Inactive$/i })).toBeVisible();
+
+ await page.goto('/auth/login');
+ await page.waitForLoadState('networkidle');
+ await page.getByLabel(/^Email/).fill(memberEmail);
+ await page.getByLabel(/^Password/).fill(INVITEE_PASSWORD);
+ await page.getByRole('button', { name: 'Log in' }).click();
+ await expect(page.getByText('Please check your email and password and try again')).toBeVisible();
+ });
+});
diff --git a/e2e/tests/admin/admin-dashboard.spec.ts b/e2e/tests/admin/admin-dashboard.spec.ts
new file mode 100644
index 0000000000..2d072b5711
--- /dev/null
+++ b/e2e/tests/admin/admin-dashboard.spec.ts
@@ -0,0 +1,48 @@
+import { test, expect } from '../../fixtures';
+import { AdminPage } from '../../pages/admin.page';
+import { createCompletedOrder, createLiveEventWithProduct } from '../../api/factory';
+import { uniqueName } from '../../utils/unique';
+
+test.describe('admin dashboard', () => {
+ test('a superadmin views platform stats and drills into an account', { tag: ['@smoke', '@admin'] }, async ({ superAdminPage }) => {
+ const admin = new AdminPage(superAdminPage);
+ await admin.gotoDashboard();
+
+ await expect(superAdminPage.getByRole('heading', { name: 'Admin Dashboard', exact: true })).toBeVisible();
+ await expect(superAdminPage.getByText('Total Users')).toBeVisible();
+ await expect(superAdminPage.getByText('Total Accounts')).toBeVisible();
+
+ await admin.openAccountsFromSidebar();
+ await expect(superAdminPage.getByRole('heading', { name: 'Accounts', exact: true })).toBeVisible();
+ await expect(admin.viewDetailsButtons().first()).toBeVisible();
+
+ await admin.viewDetailsButtons().first().click();
+ await expect(superAdminPage.getByText('Account Information')).toBeVisible();
+ await expect(superAdminPage.getByRole('button', { name: 'Back to Accounts' })).toBeVisible();
+ });
+
+ test('users, events and orders admin pages render results', { tag: '@admin' }, async ({ superAdminPage, api, account, publicApi }) => {
+ const event = await createLiveEventWithProduct(api, {
+ organizerId: account.organizerId,
+ title: uniqueName('Admin Event'),
+ });
+ const order = await createCompletedOrder(publicApi, event);
+
+ const admin = new AdminPage(superAdminPage);
+
+ await admin.gotoSection('users');
+ await expect(superAdminPage.getByRole('heading', { name: 'Users', exact: true })).toBeVisible();
+ await admin.search(account.email);
+ await expect(superAdminPage.getByText(account.email).first()).toBeVisible();
+
+ await admin.gotoSection('events');
+ await expect(superAdminPage.getByRole('heading', { name: 'Events', exact: true })).toBeVisible();
+ await admin.search(event.title);
+ await expect(superAdminPage.getByRole('row').filter({ hasText: event.title }).first()).toBeVisible();
+
+ await admin.gotoSection('orders');
+ await expect(superAdminPage.getByRole('heading', { name: 'Orders', exact: true })).toBeVisible();
+ await admin.search(order.buyerEmail);
+ await expect(superAdminPage.getByRole('row').filter({ hasText: order.buyerEmail }).first()).toBeVisible();
+ });
+});
diff --git a/e2e/tests/admin/configurations.spec.ts b/e2e/tests/admin/configurations.spec.ts
new file mode 100644
index 0000000000..852a164855
--- /dev/null
+++ b/e2e/tests/admin/configurations.spec.ts
@@ -0,0 +1,66 @@
+import { test, expect } from '../../fixtures';
+import type { Page } from '@playwright/test';
+import { uniqueName } from '../../utils/unique';
+
+const configCard = (page: Page, name: string) =>
+ page.locator('[class*="configCard"]').filter({ hasText: name });
+
+const gotoConfigurations = async (page: Page) => {
+ await page.goto('/admin/configurations');
+ await page.waitForLoadState('networkidle');
+};
+
+test.describe('admin configurations', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ const originalName = uniqueName('E2E Config');
+ const renamedName = uniqueName('E2E Config Renamed');
+
+ test('a superadmin creates a configuration', { tag: '@admin' }, async ({ superAdminPage }) => {
+ await gotoConfigurations(superAdminPage);
+ await expect(superAdminPage.getByRole('heading', { name: 'Configurations', exact: true })).toBeVisible();
+
+ await superAdminPage.getByRole('button', { name: 'Create Configuration' }).click();
+
+ const dialog = superAdminPage.getByRole('dialog');
+ await dialog.getByLabel(/^Name/).fill(originalName);
+ await dialog.getByLabel(/^Fixed Fee/).fill('1.50');
+ await dialog.getByLabel(/^Percentage Fee/).fill('2.5');
+ await dialog.getByRole('button', { name: 'Create Configuration' }).click();
+
+ const card = configCard(superAdminPage, originalName);
+ await expect(card).toBeVisible();
+ await expect(card.getByText('2.5%')).toBeVisible();
+ });
+
+ test('a superadmin edits a configuration', { tag: '@admin' }, async ({ superAdminPage }) => {
+ await gotoConfigurations(superAdminPage);
+
+ const card = configCard(superAdminPage, originalName);
+ await expect(card).toBeVisible();
+ await card.getByRole('button').first().click();
+
+ const dialog = superAdminPage.getByRole('dialog');
+ await expect(dialog.getByLabel(/^Name/)).toHaveValue(originalName);
+ await dialog.getByLabel(/^Name/).fill(renamedName);
+ await dialog.getByLabel(/^Percentage Fee/).fill('5');
+ await dialog.getByRole('button', { name: 'Save Changes' }).click();
+
+ const renamedCard = configCard(superAdminPage, renamedName);
+ await expect(renamedCard).toBeVisible();
+ await expect(renamedCard.getByText('5%')).toBeVisible();
+ await expect(configCard(superAdminPage, originalName)).toHaveCount(0);
+ });
+
+ test('a superadmin deletes a configuration', { tag: '@admin' }, async ({ superAdminPage }) => {
+ await gotoConfigurations(superAdminPage);
+
+ const card = configCard(superAdminPage, renamedName);
+ await expect(card).toBeVisible();
+
+ superAdminPage.once('dialog', (dialog) => dialog.accept());
+ await card.getByRole('button').last().click();
+
+ await expect(card).toHaveCount(0);
+ });
+});
diff --git a/e2e/tests/admin/impersonation.spec.ts b/e2e/tests/admin/impersonation.spec.ts
new file mode 100644
index 0000000000..02b76939d5
--- /dev/null
+++ b/e2e/tests/admin/impersonation.spec.ts
@@ -0,0 +1,33 @@
+import { test, expect } from '../../fixtures';
+import { createLiveEventWithProduct } from '../../api/factory';
+import { uniqueName } from '../../utils/unique';
+
+test.describe('admin impersonation', () => {
+ test('a superadmin impersonates an organizer and returns to the admin context', { tag: '@admin' }, async ({ freshAccount, superAdminPage }) => {
+ const event = await createLiveEventWithProduct(freshAccount.api, {
+ organizerId: freshAccount.organizerId,
+ title: uniqueName('Impersonated Event'),
+ });
+
+ await superAdminPage.goto('/admin/accounts');
+ await superAdminPage.waitForLoadState('networkidle');
+ await superAdminPage.getByPlaceholder(/^Search by account name/).fill(freshAccount.email);
+
+ await expect(superAdminPage.getByText(freshAccount.email).first()).toBeVisible();
+ const impersonateButton = superAdminPage.getByTestId('admin-impersonate-menu-item');
+ await expect(impersonateButton).toHaveCount(1);
+ await impersonateButton.click();
+
+ await expect(superAdminPage.getByText(/You are impersonating/)).toBeVisible();
+ await expect(superAdminPage.getByRole('button', { name: 'Stop Impersonating' })).toBeVisible();
+ await expect(superAdminPage.getByRole('link', { name: new RegExp(event.title) })).toBeVisible();
+
+ await superAdminPage.getByRole('button', { name: 'Stop Impersonating' }).click();
+ await expect(superAdminPage.getByText(/You are impersonating/)).toHaveCount(0);
+ await expect(superAdminPage.getByRole('heading', { name: 'Users', exact: true })).toBeVisible();
+
+ await superAdminPage.goto('/admin');
+ await superAdminPage.waitForLoadState('networkidle');
+ await expect(superAdminPage.getByRole('heading', { name: 'Admin Dashboard', exact: true })).toBeVisible();
+ });
+});
diff --git a/e2e/tests/admin/message-approval.spec.ts b/e2e/tests/admin/message-approval.spec.ts
new file mode 100644
index 0000000000..4b5acd5452
--- /dev/null
+++ b/e2e/tests/admin/message-approval.spec.ts
@@ -0,0 +1,37 @@
+import { test, expect } from '../../fixtures';
+import { MessagePage } from '../../pages/message.page';
+import { createEventWithAttendee } from '../../api/factory';
+import { uniqueName } from '../../utils/unique';
+
+test.describe('admin message approval', () => {
+ test('an untrusted account message is held for review, approved by an admin, then delivered', { tag: '@admin' }, async ({ freshAccount, adminApi, superAdminPage, mailpit }) => {
+ const { id: accountId } = await freshAccount.api.getAccount();
+ await adminApi.setMessagingTier(accountId, 1);
+ const event = await createEventWithAttendee(freshAccount.api, freshAccount.organizerId);
+ const subject = uniqueName('Venue update');
+
+ const organizerPage = await freshAccount.newAuthedPage();
+ const messages = new MessagePage(organizerPage);
+ await messages.goto(event.eventId);
+ await messages.sendToAllAttendees(subject, 'The venue has changed, please check your tickets.');
+
+ await expect(messages.listItem(subject)).toBeVisible();
+ await expect(messages.listItem(subject).getByText(/^PENDING_REVIEW$/)).toBeVisible();
+
+ await superAdminPage.goto('/admin/messages');
+ await superAdminPage.waitForLoadState('networkidle');
+ await superAdminPage.getByPlaceholder(/^Search by subject/).fill(subject);
+
+ const messageRow = superAdminPage.getByRole('row').filter({ hasText: subject });
+ await expect(messageRow).toBeVisible();
+ await messageRow.getByTestId('admin-approve-message-button').click();
+ await expect(messageRow.getByText(/^SENT$/)).toBeVisible();
+
+ const delivered = await mailpit.waitForMessage(event.attendeeEmail, { subjectContains: subject });
+ expect(delivered.Subject).toContain(subject);
+
+ await organizerPage.reload();
+ await organizerPage.waitForLoadState('networkidle');
+ await expect(messages.listItem(subject).getByText(/^SENT$/)).toBeVisible();
+ });
+});
diff --git a/e2e/tests/auth/password.spec.ts b/e2e/tests/auth/password.spec.ts
new file mode 100644
index 0000000000..f404c348c2
--- /dev/null
+++ b/e2e/tests/auth/password.spec.ts
@@ -0,0 +1,56 @@
+import { test, expect } from '../../fixtures';
+
+test.describe('password management', () => {
+ test('a user changes their password and logs back in with it', async ({ freshAccount }) => {
+ const newPassword = 'ChangedPass456!';
+ const page = await freshAccount.newAuthedPage();
+
+ await page.goto('/manage/profile');
+ await page.waitForLoadState('networkidle');
+ await expect(page.getByRole('heading', { name: 'Manage Profile' })).toBeVisible();
+
+ await page.getByRole('tab', { name: 'Password' }).click();
+ await page.getByLabel(/^Current Password/).fill(freshAccount.password);
+ await page.getByLabel(/^New Password/).fill(newPassword);
+ await page.getByLabel(/^Confirm New Password/).fill(newPassword);
+ await page.getByRole('button', { name: 'Change password' }).click();
+ await expect(page.getByText('Profile updated successfully')).toBeVisible();
+
+ await page.getByRole('button', { name: 'EO', exact: true }).click();
+ await page.getByRole('menuitem', { name: 'Logout' }).click();
+ await expect(page).toHaveURL(/\/auth\/login/);
+
+ await page.getByLabel(/^Email/).fill(freshAccount.email);
+ await page.getByLabel(/^Password/).fill(newPassword);
+ await page.getByRole('button', { name: 'Log in' }).click();
+
+ await expect(page).toHaveURL(/\/manage\/organizer\/\d+/);
+ await expect(page.getByRole('heading', { level: 1, name: /Dashboard/ })).toBeVisible();
+ });
+
+ test('a user resets a forgotten password via email', async ({ freshAccount, page, mailpit }) => {
+ const newPassword = 'ResetPass789!';
+
+ await page.goto('/auth/forgot-password');
+ await page.waitForLoadState('networkidle');
+ await page.getByLabel(/^Email/).fill(freshAccount.email);
+ await page.getByRole('button', { name: 'Send reset link' }).click();
+ await expect(page.getByRole('heading', { name: 'Check your email' })).toBeVisible();
+
+ const resetUrl = await mailpit.waitForLink(freshAccount.email, /reset-password/, { subjectContains: 'Password reset' });
+ await page.goto(resetUrl.pathname);
+ await expect(page.getByRole('heading', { name: 'Create new password' })).toBeVisible();
+
+ await page.getByLabel(/^New Password/).fill(newPassword);
+ await page.getByLabel(/^Confirm Password/).fill(newPassword);
+ await page.getByRole('button', { name: 'Reset password' }).click();
+ await expect(page).toHaveURL(/\/auth\/login/);
+
+ await page.getByLabel(/^Email/).fill(freshAccount.email);
+ await page.getByLabel(/^Password/).fill(newPassword);
+ await page.getByRole('button', { name: 'Log in' }).click();
+
+ await expect(page).toHaveURL(/\/manage\/organizer\/\d+/);
+ await expect(page.getByRole('heading', { level: 1, name: /Dashboard/ })).toBeVisible();
+ });
+});
diff --git a/e2e/tests/auth/registration.spec.ts b/e2e/tests/auth/registration.spec.ts
new file mode 100644
index 0000000000..667da0ce10
--- /dev/null
+++ b/e2e/tests/auth/registration.spec.ts
@@ -0,0 +1,18 @@
+import { test, expect } from '../../fixtures';
+import { RegisterPage } from '../../pages/register.page';
+import { uniqueEmail } from '../../utils/unique';
+
+test.describe('registration', () => {
+ test('a new organizer can register and reach the welcome page', { tag: '@smoke' }, async ({ page, mailpit }) => {
+ const email = uniqueEmail();
+
+ const register = new RegisterPage(page);
+ await register.goto();
+ await register.register({ firstName: 'New', lastName: 'Organizer', email, password: 'Password123!' });
+
+ await expect(page).toHaveURL(/\/welcome/);
+
+ const welcomeEmail = await mailpit.waitForMessage(email, { subjectContains: 'Welcome to Hi.Events' });
+ expect(welcomeEmail.Subject).toContain('Welcome to Hi.Events');
+ });
+});
diff --git a/e2e/tests/check-in/check-in-app.spec.ts b/e2e/tests/check-in/check-in-app.spec.ts
new file mode 100644
index 0000000000..41db29426b
--- /dev/null
+++ b/e2e/tests/check-in/check-in-app.spec.ts
@@ -0,0 +1,75 @@
+import type { APIRequestContext } from '@playwright/test';
+import { test, expect } from '../../fixtures';
+import { CheckInPage } from '../../pages/check-in.page';
+import { createCompletedOrder, createLiveEventWithFreeTicket } from '../../api/factory';
+import type { ApiClient } from '../../api/api-client';
+import { uniqueName, uniqueShort } from '../../utils/unique';
+
+async function seedCheckInList(api: ApiClient, publicApi: APIRequestContext, organizerId: number) {
+ const event = await createLiveEventWithFreeTicket(api, organizerId);
+ const order = await createCompletedOrder(publicApi, event, {
+ quantity: 2,
+ buyerFirstName: 'Casey',
+ buyerLastName: uniqueShort('Lee').replace(' ', ''),
+ });
+ const list = await api.createCheckInList(event.eventId, { name: uniqueName('Main Door') });
+ return { order, list };
+}
+
+test.describe('public check-in app', () => {
+ test('a staff member searches for an attendee and checks them in', { tag: '@smoke' }, async ({ page, api, publicApi, account }) => {
+ const { order, list } = await seedCheckInList(api, publicApi, account.organizerId);
+ const attendee = order.attendees[0];
+
+ const checkIn = new CheckInPage(page);
+ await checkIn.goto(list.short_id);
+ await checkIn.openSearchTab();
+ await checkIn.search(order.buyerLastName);
+
+ await expect(checkIn.attendeeRow(attendee.publicId)).toBeVisible();
+
+ await checkIn.checkInButton(attendee.publicId).click();
+
+ await expect(checkIn.checkOutButton(attendee.publicId)).toBeVisible();
+ await expect(checkIn.progressChip()).toHaveText('1/2');
+ });
+
+ test('a staff member undoes a check-in and the attendee reverts to pending', async ({ page, api, publicApi, account }) => {
+ const { order, list } = await seedCheckInList(api, publicApi, account.organizerId);
+ const attendee = order.attendees[0];
+
+ const checkIn = new CheckInPage(page);
+ await checkIn.goto(list.short_id);
+ await checkIn.openSearchTab();
+ await checkIn.search(order.buyerLastName);
+ await checkIn.checkInButton(attendee.publicId).click();
+ await expect(checkIn.checkOutButton(attendee.publicId)).toBeVisible();
+ await expect(checkIn.progressChip()).toHaveText('1/2');
+
+ await checkIn.checkOutButton(attendee.publicId).click();
+
+ await expect(checkIn.checkInButton(attendee.publicId)).toBeVisible();
+ await expect(checkIn.progressChip()).toHaveText('0/2');
+ });
+
+ test('the stats tab reflects attendance totals and check-ins', async ({ page, api, publicApi, account }) => {
+ const { order, list } = await seedCheckInList(api, publicApi, account.organizerId);
+ const attendee = order.attendees[0];
+
+ const checkIn = new CheckInPage(page);
+ await checkIn.goto(list.short_id);
+ await checkIn.openSearchTab();
+ await checkIn.search(order.buyerLastName);
+ await checkIn.checkInButton(attendee.publicId).click();
+ await expect(checkIn.checkOutButton(attendee.publicId)).toBeVisible();
+
+ await checkIn.openStatsTab();
+
+ await expect(page.getByText('attendees checked in')).toBeVisible();
+ await expect(page.getByText('/ 2', { exact: true })).toBeVisible();
+ await expect(page.getByText('50%')).toBeVisible();
+ await expect(page.getByText('Latest check-ins')).toBeVisible();
+ await expect(page.getByText(attendee.publicId)).toBeVisible();
+ await expect(checkIn.progressChip()).toHaveText('1/2');
+ });
+});
diff --git a/e2e/tests/checkout/checkout-questions.spec.ts b/e2e/tests/checkout/checkout-questions.spec.ts
new file mode 100644
index 0000000000..6e378f33ba
--- /dev/null
+++ b/e2e/tests/checkout/checkout-questions.spec.ts
@@ -0,0 +1,31 @@
+import { test, expect } from '../../fixtures';
+import { CheckoutPage } from '../../pages/checkout.page';
+import { createEventWithQuestions } from '../../api/factory';
+import { uniqueEmail } from '../../utils/unique';
+
+test.describe('checkout questions', () => {
+ test('required order and attendee questions block completion until answered', async ({ page, api, account }) => {
+ const event = await createEventWithQuestions(api, account.organizerId);
+ const buyer = { firstName: 'Curious', lastName: 'Buyer', email: uniqueEmail('questions') };
+
+ const checkout = new CheckoutPage(page);
+ await checkout.gotoPublicEvent(event.eventId, event.slug);
+ await checkout.setFirstProductQuantity(1);
+ await checkout.continueToCheckout();
+ await checkout.fillOrderDetails(buyer);
+ await checkout.fillFirstAttendee(buyer);
+
+ await expect(page.getByLabel(/^How did you hear about us/)).toBeVisible();
+ await expect(page.getByRole('radio', { name: 'Medium' })).toBeVisible();
+
+ await page.getByRole('button', { name: 'Complete Order' }).click();
+
+ await expect(page.getByText('This field is required.')).toHaveCount(2);
+
+ await checkout.answerTextQuestion('How did you hear about us?', 'From a friend');
+ await checkout.chooseRadioOption('Medium');
+ await checkout.completeFreeOrder();
+
+ await expect(page.getByText(`You're going to ${event.title}`)).toBeVisible();
+ });
+});
diff --git a/e2e/tests/checkout/donation-tiered-checkout.spec.ts b/e2e/tests/checkout/donation-tiered-checkout.spec.ts
new file mode 100644
index 0000000000..cc1a1eb202
--- /dev/null
+++ b/e2e/tests/checkout/donation-tiered-checkout.spec.ts
@@ -0,0 +1,66 @@
+import { test, expect } from '../../fixtures';
+import { CheckoutPage } from '../../pages/checkout.page';
+import { createLiveEventWithProduct, enableOfflinePayments } from '../../api/factory';
+import { uniqueEmail } from '../../utils/unique';
+
+test.describe('donation and tiered checkout', () => {
+ test('a buyer completes a free order on the standard tier of a tiered product', async ({ page, api, account }) => {
+ const event = await createLiveEventWithProduct(api, {
+ organizerId: account.organizerId,
+ productType: 'TIERED',
+ productTitle: 'Tiered Ticket',
+ prices: [
+ { price: 0, label: 'Standard' },
+ { price: 20, label: 'VIP' },
+ ],
+ });
+ const buyer = { firstName: 'Tier', lastName: 'Buyer', email: uniqueEmail('tierbuyer') };
+
+ const checkout = new CheckoutPage(page);
+ await checkout.gotoPublicEvent(event.eventId, event.slug);
+
+ const standardRow = page.locator('.hi-price-tier-row').filter({ hasText: 'Standard' });
+ const vipRow = page.locator('.hi-price-tier-row').filter({ hasText: 'VIP' });
+ await expect(standardRow.locator('.hi-price-tier-label')).toHaveText('Standard');
+ await expect(standardRow.getByText('Free')).toBeVisible();
+ await expect(vipRow.locator('.hi-price-tier-label')).toHaveText('VIP');
+ await expect(vipRow.getByText('$20.00')).toBeVisible();
+
+ await standardRow.locator('.hi-product-quantity-selector input').fill('1');
+ await checkout.continueToCheckout();
+ await checkout.fillOrderDetails(buyer);
+ await checkout.fillFirstAttendee(buyer);
+ await checkout.completeFreeOrder();
+
+ await expect(page.getByText(`You're going to ${event.title}`)).toBeVisible();
+ await page.getByRole('button', { name: /Order Summary/ }).click();
+ await expect(page.getByText('Tiered Ticket - Standard').first()).toBeVisible();
+ });
+
+ test('a buyer completes a donation order with a custom amount', async ({ page, api, account }) => {
+ const event = await createLiveEventWithProduct(api, {
+ organizerId: account.organizerId,
+ productType: 'DONATION',
+ price: 5,
+ productTitle: 'Donation Ticket',
+ });
+ await enableOfflinePayments(api, event.eventId);
+ const buyer = { firstName: 'Donor', lastName: 'Buyer', email: uniqueEmail('donor') };
+
+ const checkout = new CheckoutPage(page);
+ await checkout.gotoPublicEvent(event.eventId, event.slug);
+
+ const amountInput = page.getByLabel(/^Amount/);
+ await expect(amountInput).toBeVisible();
+ await amountInput.fill('15');
+ await checkout.setFirstProductQuantity(1);
+ await checkout.continueToCheckout();
+ await checkout.fillOrderDetails(buyer);
+ await checkout.fillFirstAttendee(buyer);
+ await checkout.continueToPayment();
+ await checkout.chooseOfflinePayment();
+
+ await expect(page.getByText('Your order is awaiting payment')).toBeVisible();
+ await expect(page.getByText('$15.00').first()).toBeVisible();
+ });
+});
diff --git a/e2e/tests/checkout/free-checkout.spec.ts b/e2e/tests/checkout/free-checkout.spec.ts
new file mode 100644
index 0000000000..bd908052fc
--- /dev/null
+++ b/e2e/tests/checkout/free-checkout.spec.ts
@@ -0,0 +1,23 @@
+import { test, expect } from '../../fixtures';
+import { CheckoutPage } from '../../pages/checkout.page';
+import { createLiveEventWithFreeTicket } from '../../api/factory';
+import { uniqueEmail } from '../../utils/unique';
+
+test.describe('free checkout', () => {
+ test('a buyer completes a free-ticket order', { tag: '@smoke' }, async ({ page, api, account, mailpit }) => {
+ const event = await createLiveEventWithFreeTicket(api, account.organizerId);
+ const buyerEmail = uniqueEmail('buyer');
+ const buyer = { firstName: 'Buyer', lastName: 'One', email: buyerEmail };
+
+ const checkout = new CheckoutPage(page);
+ await checkout.gotoPublicEvent(event.eventId, event.slug);
+ await checkout.setFirstProductQuantity(1);
+ await checkout.continueToCheckout();
+ await checkout.fillOrderDetails(buyer);
+ await checkout.fillFirstAttendee(buyer);
+ await checkout.completeFreeOrder();
+
+ await expect(page.getByText(`You're going to ${event.title}`)).toBeVisible();
+ await mailpit.waitForMessage(buyerEmail);
+ });
+});
diff --git a/e2e/tests/checkout/kitchen-sink-recurring.spec.ts b/e2e/tests/checkout/kitchen-sink-recurring.spec.ts
new file mode 100644
index 0000000000..438d1165a4
--- /dev/null
+++ b/e2e/tests/checkout/kitchen-sink-recurring.spec.ts
@@ -0,0 +1,155 @@
+import type { Page } from '@playwright/test';
+import { test, expect } from '../../fixtures';
+import { PublicOccurrenceSelector } from '../../pages/occurrence.page';
+import {
+ arrangeKitchenSinkEvent,
+ runKitchenSinkCheckout,
+ type KitchenSinkCheckoutOptions,
+ type KitchenSinkTotals,
+ type RecurringKitchenSinkScenario,
+} from './kitchen-sink.shared';
+import type { ApiClient } from '../../api/api-client';
+import type { Occurrence } from '../../api/types';
+import { uniqueName } from '../../utils/unique';
+import { STRIPE_PUBLIC_KEY } from '../../utils/env';
+import { nonSaasOnly } from '../../utils/mode';
+
+const TOTALS: KitchenSinkTotals = {
+ standardBase: '$30.00',
+ standardInclusive: '$35.75',
+ subtotal: '$132.50',
+ fees: '$5.00',
+ taxes: '$4.50',
+ total: '$142.00',
+};
+
+const OCCURRENCE_LABEL = 'Dockside Session';
+const BASE_STANDARD_INCLUSIVE = '$30.25';
+
+const utcParts = (isoDate: string) => {
+ const date = new Date(isoDate);
+ return {
+ weekday: date.toLocaleString('en-US', { weekday: 'long', timeZone: 'UTC' }),
+ month: date.toLocaleString('en-US', { month: 'long', timeZone: 'UTC' }),
+ day: date.getUTCDate(),
+ year: date.getUTCFullYear(),
+ };
+};
+
+const dayButtonLabel = (isoDate: string): RegExp => {
+ const { weekday, month, day } = utcParts(isoDate);
+ return new RegExp(`^${weekday}, ${month} ${day},`);
+};
+
+const paneHeaderDay = (isoDate: string): string => {
+ const { weekday, month, day } = utcParts(isoDate);
+ return `${weekday}, ${month} ${day}`;
+};
+
+async function arrangeRecurringKitchenSink(
+ api: ApiClient,
+ organizerId: number,
+): Promise<{ scenario: RecurringKitchenSinkScenario; occurrences: Occurrence[] }> {
+ const scenario = await arrangeKitchenSinkEvent(api, organizerId, { eventType: 'RECURRING' });
+ const occurrences = [...scenario.occurrences].sort((a, b) => a.start_date.localeCompare(b.start_date));
+
+ const location = await api.createOrganizerLocation(organizerId, {
+ name: uniqueName('Warehouse 9'),
+ structured_address: {
+ venue_name: 'Warehouse 9',
+ address_line_1: '9 Dock Road',
+ city: 'Brooklyn',
+ country: 'US',
+ },
+ });
+
+ const overridden = occurrences[1];
+ await api.updateOccurrence(scenario.eventId, overridden.id, {
+ start_date: overridden.start_date,
+ end_date: overridden.end_date,
+ label: OCCURRENCE_LABEL,
+ event_location: { type: 'IN_PERSON', location_id: location.id },
+ });
+ await api.setOccurrencePriceOverride(scenario.eventId, overridden.id, {
+ product_price_id: scenario.standardPriceId,
+ price: 30,
+ });
+
+ return { scenario, occurrences };
+}
+
+function buildCheckoutOptions(
+ paymentMode: 'offline' | 'stripe',
+ occurrences: Occurrence[],
+): KitchenSinkCheckoutOptions {
+ const [first, second] = occurrences;
+ const secondParts = utcParts(second.start_date);
+
+ const select = async (page: Page) => {
+ const selector = new PublicOccurrenceSelector(page);
+ const pane = page.locator('.hi-products-pane');
+ const paneTime = pane.locator('.hi-slot-header-time');
+ const paneLocation = pane.locator('.hi-slot-header-location');
+ const standardRow = page.locator('.hi-product-row').filter({ hasText: 'Standard Ticket' });
+
+ await expect(page.getByRole('heading', { name: 'Select a Date & Time' })).toBeVisible();
+ for (const occurrence of occurrences) {
+ await selector.navigateToMonthOf(occurrence.start_date);
+ await expect(selector.dayButton(dayButtonLabel(occurrence.start_date))).toBeVisible();
+ }
+
+ await selector.navigateToMonthOf(first.start_date);
+ await expect(selector.slotHeaderDay()).toHaveText(paneHeaderDay(first.start_date));
+ await expect(paneTime).toContainText(/7:00\s?PM/i);
+ await expect(paneLocation).toHaveCount(0);
+ await expect(selector.productsLoadingOverlay()).toHaveCount(0);
+ await expect(standardRow.getByText(BASE_STANDARD_INCLUSIVE)).toBeVisible();
+
+ const secondLabel = dayButtonLabel(second.start_date);
+ await selector.navigateToMonthOf(second.start_date);
+ await selector.dayButton(secondLabel).click();
+ await expect(selector.slotHeaderDay()).toHaveText(paneHeaderDay(second.start_date));
+ await expect(paneTime).toContainText(/7:00\s?PM/i);
+ await expect(pane.getByText(OCCURRENCE_LABEL)).toBeVisible();
+ await expect(paneLocation).toContainText('Warehouse 9, Brooklyn');
+ await expect(selector.productsLoadingOverlay()).toHaveCount(0);
+ await expect(standardRow.getByText(TOTALS.standardInclusive)).toBeVisible();
+ };
+
+ const expectSummaryDetails = async (page: Page) => {
+ const detailItem = (label: string) => page.locator('[class*="detailItem"]').filter({ hasText: label });
+ await expect(detailItem('Event Date')).toContainText(new RegExp(`${secondParts.month} ${secondParts.day}\\b`));
+ await expect(detailItem('Event Date')).toContainText(OCCURRENCE_LABEL);
+ await expect(detailItem('Location')).toContainText('Warehouse 9');
+ await expect(detailItem('Location')).toContainText('9 Dock Road');
+ };
+
+ return {
+ paymentMode,
+ attendeeCollection: 'PER_ORDER',
+ totals: TOTALS,
+ occurrence: { select, expectSummaryDetails },
+ emailBodyContains: [`${secondParts.month} ${secondParts.day}, ${secondParts.year}`, '7:00 PM'],
+ };
+}
+
+test.describe('kitchen sink recurring checkout', () => {
+ test('a buyer completes the recurring kitchen-sink checkout with offline payment', async ({ page, api, account, publicApi, mailpit }) => {
+ test.slow();
+
+ const { scenario, occurrences } = await arrangeRecurringKitchenSink(api, account.organizerId);
+ await runKitchenSinkCheckout(page, scenario, { api, publicApi, mailpit }, buildCheckoutOptions('offline', occurrences));
+ });
+
+ test.describe(() => {
+ test.skip(!STRIPE_PUBLIC_KEY, 'Requires STRIPE_PUBLIC_KEY (Stripe test mode) to be configured.');
+ nonSaasOnly();
+
+ test('a buyer completes the recurring kitchen-sink checkout with a Stripe card payment', { tag: '@stripe' }, async ({ page, api, account, publicApi, mailpit }) => {
+ test.slow();
+
+ const { scenario, occurrences } = await arrangeRecurringKitchenSink(api, account.organizerId);
+ await runKitchenSinkCheckout(page, scenario, { api, publicApi, mailpit }, buildCheckoutOptions('stripe', occurrences));
+ });
+ });
+});
diff --git a/e2e/tests/checkout/kitchen-sink.shared.ts b/e2e/tests/checkout/kitchen-sink.shared.ts
new file mode 100644
index 0000000000..c5f56e7ab1
--- /dev/null
+++ b/e2e/tests/checkout/kitchen-sink.shared.ts
@@ -0,0 +1,413 @@
+import { expect, type APIRequestContext, type Page } from '@playwright/test';
+import { CheckoutPage, type BuyerDetails } from '../../pages/checkout.page';
+import type { ApiClient } from '../../api/api-client';
+import type { Occurrence } from '../../api/types';
+import { createDraftEvent, OFFLINE_PAYMENT_INSTRUCTIONS } from '../../api/factory';
+import { deliverPaymentIntentSucceededWebhook, parsePaymentReturnUrl } from '../../api/stripe';
+import type { MailpitClient } from '../../utils/mailpit';
+import { uniqueCode, uniqueEmail, uniqueName, uniqueShort } from '../../utils/unique';
+
+const GA_DESCRIPTION = 'Access tickets for the main event floor.';
+const EXTRAS_DESCRIPTION = 'Merchandise and add-ons.';
+const PRE_CHECKOUT_MESSAGE = 'Welcome! Please review ticket options carefully.';
+const POST_CHECKOUT_MESSAGE = 'Thanks for coming — see you at the doors!';
+
+export interface KitchenSinkScenario {
+ eventId: number;
+ slug: string;
+ title: string;
+ promoCode: string;
+ gaCategoryName: string;
+ extrasCategoryName: string;
+}
+
+export interface RecurringKitchenSinkScenario extends KitchenSinkScenario {
+ occurrences: Occurrence[];
+ standardPriceId: number;
+}
+
+const futureStartDate = (): string => {
+ const date = new Date();
+ date.setDate(date.getDate() + 30);
+ date.setHours(21, 0, 0, 0);
+ return date.toISOString();
+};
+
+async function createRecurringKitchenSinkEvent(
+ api: ApiClient,
+ organizerId: number,
+ title: string,
+): Promise<{ eventId: number; slug: string; occurrences: Occurrence[] }> {
+ const event = await api.createEvent({
+ title,
+ type: 'RECURRING',
+ organizer_id: organizerId,
+ start_date: futureStartDate(),
+ category: 'MUSIC',
+ currency: 'USD',
+ timezone: 'UTC',
+ });
+ await api.generateOccurrences(event.id, {
+ frequency: 'weekly',
+ range: { type: 'count', count: 3 },
+ days_of_week: ['friday'],
+ times_of_day: ['19:00'],
+ duration_minutes: 120,
+ });
+ const occurrences = await api.listOccurrences(event.id);
+ return { eventId: event.id, slug: event.slug, occurrences };
+}
+
+export async function arrangeKitchenSinkEvent(api: ApiClient, organizerId: number): Promise;
+export async function arrangeKitchenSinkEvent(
+ api: ApiClient,
+ organizerId: number,
+ opts: { eventType: 'RECURRING' },
+): Promise;
+export async function arrangeKitchenSinkEvent(
+ api: ApiClient,
+ organizerId: number,
+ opts: { eventType?: 'SINGLE' | 'RECURRING' } = {},
+): Promise {
+ const { id: accountId } = await api.getAccount();
+ const tax = await api.createTaxOrFee(accountId, {
+ name: uniqueName('Sales Tax'),
+ calculation_type: 'PERCENTAGE',
+ type: 'TAX',
+ rate: 10,
+ is_active: true,
+ is_default: false,
+ });
+ const fee = await api.createTaxOrFee(accountId, {
+ name: uniqueName('Booking Fee'),
+ calculation_type: 'FIXED',
+ type: 'FEE',
+ rate: 2.5,
+ is_active: true,
+ is_default: false,
+ });
+
+ const title = uniqueName('Kitchen Sink');
+ const isRecurring = opts.eventType === 'RECURRING';
+ const { eventId, slug, occurrences } = isRecurring
+ ? await createRecurringKitchenSinkEvent(api, organizerId, title)
+ : { ...(await createDraftEvent(api, organizerId, { title })), occurrences: [] as Occurrence[] };
+
+ const gaCategory = await api.createProductCategory(eventId, {
+ name: uniqueShort('General Admission'),
+ description: GA_DESCRIPTION,
+ is_hidden: false,
+ });
+ const extrasCategory = await api.createProductCategory(eventId, {
+ name: uniqueShort('Extras'),
+ description: EXTRAS_DESCRIPTION,
+ is_hidden: false,
+ });
+
+ const standard = await api.createProduct(eventId, {
+ title: 'Standard Ticket',
+ product_type: 'TICKET',
+ type: 'PAID',
+ product_category_id: gaCategory.id,
+ prices: [{ price: 25 }],
+ tax_and_fee_ids: [tax.id, fee.id],
+ });
+ await api.createProduct(eventId, {
+ title: 'Seated Ticket',
+ product_type: 'TICKET',
+ type: 'TIERED',
+ product_category_id: gaCategory.id,
+ prices: [
+ { price: 15, label: 'Balcony' },
+ { price: 40, label: 'Front Row' },
+ ],
+ });
+ await api.createProduct(eventId, {
+ title: 'Supporter Donation',
+ product_type: 'TICKET',
+ type: 'DONATION',
+ product_category_id: gaCategory.id,
+ prices: [{ price: 5 }],
+ });
+ const secretVip = await api.createProduct(eventId, {
+ title: 'Secret VIP',
+ product_type: 'TICKET',
+ type: 'PAID',
+ product_category_id: gaCategory.id,
+ prices: [{ price: 50 }],
+ is_hidden_without_promo_code: true,
+ });
+ await api.createProduct(eventId, {
+ title: 'Staff Comp',
+ product_type: 'TICKET',
+ type: 'FREE',
+ product_category_id: gaCategory.id,
+ prices: [{ price: 0 }],
+ is_hidden: true,
+ });
+ await api.createProduct(eventId, {
+ title: 'Event T-Shirt',
+ product_type: 'GENERAL',
+ type: 'PAID',
+ product_category_id: extrasCategory.id,
+ prices: [{ price: 10 }],
+ tax_and_fee_ids: [tax.id, fee.id],
+ });
+
+ const promoCode = uniqueCode();
+ await api.createPromoCode(eventId, {
+ code: promoCode,
+ discount_type: 'FIXED',
+ discount: 10,
+ applicable_product_ids: [secretVip.id],
+ });
+
+ await api.createQuestion(eventId, {
+ title: 'T-shirt size',
+ type: 'RADIO',
+ belongs_to: 'PRODUCT',
+ product_ids: [standard.id],
+ options: ['Small', 'Medium', 'Large'],
+ required: true,
+ is_hidden: false,
+ });
+ await api.createQuestion(eventId, {
+ title: 'How did you hear about us?',
+ type: 'SINGLE_LINE_TEXT',
+ belongs_to: 'ORDER',
+ product_ids: [],
+ required: true,
+ is_hidden: false,
+ });
+ await api.createQuestion(eventId, {
+ title: 'Anything else we should know?',
+ type: 'SINGLE_LINE_TEXT',
+ belongs_to: 'ORDER',
+ product_ids: [],
+ required: false,
+ is_hidden: false,
+ });
+
+ await api.updateEventSettings(eventId, {
+ payment_providers: ['STRIPE', 'OFFLINE'],
+ offline_payment_instructions: OFFLINE_PAYMENT_INSTRUCTIONS,
+ pre_checkout_message: PRE_CHECKOUT_MESSAGE,
+ post_checkout_message: POST_CHECKOUT_MESSAGE,
+ });
+ await api.publishEvent(eventId);
+
+ const scenario: KitchenSinkScenario = {
+ eventId,
+ slug,
+ title,
+ promoCode,
+ gaCategoryName: gaCategory.name,
+ extrasCategoryName: extrasCategory.name,
+ };
+
+ if (!isRecurring) {
+ return scenario;
+ }
+
+ const standardProduct = await api.getProduct(eventId, standard.id);
+ const standardPriceId = standardProduct.prices?.[0]?.id;
+ if (!standardPriceId) {
+ throw new Error(`Product ${standard.id} has no prices in GET response`);
+ }
+ return { ...scenario, occurrences, standardPriceId };
+}
+
+export interface KitchenSinkTotals {
+ standardBase: string;
+ standardInclusive: string;
+ subtotal: string;
+ fees: string;
+ taxes: string;
+ total: string;
+}
+
+export interface KitchenSinkCheckoutDeps {
+ api: ApiClient;
+ publicApi: APIRequestContext;
+ mailpit: MailpitClient;
+}
+
+export interface KitchenSinkCheckoutOptions {
+ paymentMode: 'offline' | 'stripe';
+ attendeeCollection: 'PER_ATTENDEE' | 'PER_ORDER';
+ totals: KitchenSinkTotals;
+ occurrence?: {
+ select: (page: Page) => Promise;
+ expectSummaryDetails: (page: Page) => Promise;
+ };
+ emailBodyContains?: string[];
+}
+
+async function fillContactBlock(page: Page, index: number, details: BuyerDetails): Promise {
+ await page.getByLabel(/^First Name/).nth(index).fill(details.firstName);
+ await page.getByLabel(/^Last Name/).nth(index).fill(details.lastName);
+ await page.getByLabel(/^Email Address/).nth(index).fill(details.email);
+ await page.getByLabel(/^Confirm Email Address/).nth(index).fill(details.email);
+}
+
+export async function runKitchenSinkCheckout(
+ page: Page,
+ scenario: KitchenSinkScenario,
+ deps: KitchenSinkCheckoutDeps,
+ opts: KitchenSinkCheckoutOptions,
+): Promise